Chapter 13: Error Handling and Diagnostics
Error handling in C# leans on exceptions, structured diagnostics, and clear separation between normal program flow and fault handling. These tools help you respond to unexpected states without cluttering everyday logic. .NET supports rich debugging, structured logging, and strong exception hierarchies which together form a reliable diagnostic toolkit.
Exceptions and best practices
Exceptions represent exceptional conditions rather than routine branching. They carry stack information, message text, and optional inner exceptions that trace chains of failure. Good practice involves throwing only meaningful exceptions, catching them at boundaries where recovery is possible, and letting them bubble upward when the caller is better suited to decide what to do.
When to throw and when to avoid exceptions
Throw an exception when something breaks a contract such as invalid input, missing resources, or impossible states. Avoid exceptions for predictable control flow because frequent throwing is slower and obscures the intent of the code.
int ParsePort(string text)
{
if (!int.TryParse(text, out int port))
{
throw new FormatException("Port must be a valid integer");
}
return port;
}
TryParse and similar methods when invalid input is common because this prevents unnecessary exceptions during routine checks.
Preserving stack information with rethrowing
If you catch an exception only to log or wrap it you can rethrow with throw to preserve the original stack trace. Using throw ex resets the trace which loses important context.
try
{
DangerousWork();
}
catch (Exception ex)
{
Log(ex);
throw; // preserves stack trace
}
Preserving the trace makes debugging far clearer because the original fault location remains intact.
Creating custom exceptions
Custom exceptions help describe domain-specific errors. They should derive from Exception or an appropriate subclass, include the standard constructors, and stay focused on their purpose. Adding custom properties is fine when the extra information aids error handling.
Defining a well-formed custom exception
The following pattern includes the common constructors and preserves serialization compatibility in full .NET environments.
using System;
public class CalculationException : Exception
{
public int Operand { get; }
public CalculationException(string message, int operand)
: base(message)
{
Operand = operand;
}
public CalculationException(string message, Exception inner, int operand)
: base(message, inner)
{
Operand = operand;
}
}
Wrapping lower-level exceptions
When you translate errors between layers wrap the original exception as an inner exception. This preserves context and helps the caller see the chain of failure.
try
{
LoadUserProfile(path);
}
catch (IOException ex)
{
throw new ProfileException("Profile load failed", ex);
}
Wrapped exceptions form a breadcrumb trail that leads back to the original source.
Exception handling
C# structures exception handling with try, catch, optional filters, and finally. Filters let you narrow which exceptions a block should handle. A finally section always runs which provides a safe place for cleanup.
Using filters to narrow catch scope
A filter uses when to decide whether a catch block should run. This is more expressive than catching then rethrowing.
try
{
Process(value);
}
catch (Exception ex) when (ex is InvalidOperationException)
{
Console.Error.WriteLine("Operation error");
}
Filters improve clarity because you can explain the condition succinctly and avoid unnecessary handling.
Ensuring cleanup with finally
The finally clause is ideal for releasing handles, closing streams, or resetting state. It executes whether the operation succeeds, fails, or returns early.
FileStream? fs = null;
try
{
fs = File.OpenRead(path);
ReadConfig(fs);
}
finally
{
fs?.Dispose();
}
using statements handle disposal automatically and often remove the need for an explicit finally block.
Debugging with the CLI and IDEs
Debugging in .NET works well from both the command line and graphical environments. The CLI provides inspection and control tools while IDEs such as Visual Studio and VS Code offer breakpoints, watches, step-through execution, and rich visualizers.
Using the dotnet CLI for diagnostics
The dotnet toolchain includes commands for structured diagnostics. You can produce dumps, inspect dependencies, and run analyzers directly from the terminal.
dotnet dump collect --process-id 1234
dotnet trace collect --process-id 1234
dotnet tool run analyze …
These tools help when debugging remote systems or processes without graphical interfaces.
Working with breakpoints and watches in IDEs
Breakpoints pause execution exactly at the point of interest. Watches let you track variables and expressions as you step through code which gives you a close-up view of program state.
// Example stub for debugging session
int x = ComputeTotal();
int y = ComputeTotal();
int z = x + y; // breakpoint here
Console.WriteLine(z);
Assertions and defensive programming
Assertions verify assumptions during development. Defensive checks validate inputs and states at runtime. C# and .NET provide mechanisms for both which help prevent subtle logic errors.
Assertions for development-time assumptions
Assertions confirm internal expectations. They are not intended to handle user-facing errors but instead catch programming mistakes early.
using System.Diagnostics;
Debug.Assert(items.Count > 0, "Items must not be empty");
// production code follows
Assertions only fire in debug builds so they introduce no overhead in release configurations.
Writing clear defensive checks
Defensive checks ensure the program does not continue with invalid data. They should use precise exceptions and clear messages.
public void SetRate(double rate)
{
if (rate < 0) throw new ArgumentOutOfRangeException(nameof(rate));
_rate = rate;
}
Logging basics
Logging provides a running record of application events. .NET’s ILogger system offers structured logs, scoped categories, and pluggable providers such as console output, files, or centralized collectors. Good logs help you reconstruct the story of a failure with minimal guesswork.
Structured messages with ILogger
Structured logging separates message templates from data values which enhances filtering and analysis. This pattern is used by built-in .NET logging frameworks.
using Microsoft.Extensions.Logging;
void Run(ILogger logger)
{
int count = 3;
logger.LogInformation("Processing {Count} items", count);
}
Log entries stay consistent because names like Count remain stable even as values vary.
Configuring providers and levels
You configure loggers by adding providers and choosing levels. Console logging is common during development while production systems often route logs to structured stores or observability platforms.
using Microsoft.Extensions.Logging;
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder
.AddConsole()
.SetMinimumLevel(LogLevel.Information);
});
var logger = loggerFactory.CreateLogger("Demo");
logger.LogWarning("Low disk space");
A thoughtful logging strategy becomes a long-term ally when diagnosing rare or intermittent faults.