Exception Handling and Logging
Catch errors in one place, tell clients little, and tell yourself everything
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
What is the difference between throw; and throw ex; inside a catch block?
Real-world analogy
What is it?
Exception handling decides what happens when code fails. In ASP.NET Core you register one handler in the pipeline that turns exceptions into consistent HTTP responses, using the ProblemDetails format. Logging records what happened, through ILogger<T>, with levels and named fields. Together they give clients a safe short error and give developers the full story.
Real-world relevance
Without a global handler every controller grows its own try/catch, with different JSON shapes and some forgotten ones that return a raw HTML error page. Teams then wire the trace id into the error body, so a customer can send "my request id is abc" and the developer finds the exact log line. Structured logs also let you ask "show all failures for OrderId 42" without grepping text.
Key points
- One global handler — Use UseExceptionHandler or an IExceptionHandler once. Actions then stay short and stop repeating the same try/catch block.
- ProblemDetails is the standard body — RFC 9457 defines a JSON shape with status, title, detail and type. Clients can parse errors the same way.
- Never leak internals — A stack trace shows file paths, library versions and queries. Return a short message to the client and keep the details in your logs.
- Map exceptions to status codes — A NotFoundException becomes 404. A validation failure becomes 400. Anything unexpected becomes 500, because that is a bug on your side.
- Rethrow with throw; — throw; keeps the original stack trace. throw ex; resets it to the current line, so you lose where the error began.
- Never swallow errors — An empty catch hides failures. Catch only what you can handle, and let the rest reach the global handler.
- Structured log templates — Write LogInformation("Order {OrderId} paid", id), not string interpolation. Templates keep OrderId as a searchable field in your log tool.
- Log levels and trace ids — Use Information for normal events, Warning for odd ones, Error for failures. Add the trace id to error logs so support can find one request quickly.
- What never to log — Never log passwords, tokens, card numbers or personal data. Logs are copied to many systems, and often more people can read them than can read the database.
Quick check
Answer from memory — scrolling up first makes it stick less.
Which log call keeps OrderId as a searchable field?
An unexpected NullReferenceException reaches the global handler. What should the client receive?
Code example
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) => _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken ct)
{
var (status, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Resource not found"),
_ => (StatusCodes.Status500InternalServerError, "Something went wrong")
};
// Template with named fields, and the exception object keeps the stack trace
_logger.LogError(exception, "Request {TraceId} failed with {Status}",
context.TraceIdentifier, status);
context.Response.StatusCode = status;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = status,
Title = title,
Extensions = { ["traceId"] = context.TraceIdentifier }
}, ct);
return true; // handled, stop here
}
}
// Program.cs
// builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
// builder.Services.AddProblemDetails();
// app.UseExceptionHandler();Line-by-line walkthrough
- 1. NotFoundException is a small custom type. Services throw it, and the handler decides what HTTP code it means.
- 2. GlobalExceptionHandler implements IExceptionHandler. The logger arrives through the constructor from DI.
- 3. The switch maps NotFoundException to 404. The discard case maps everything else to a generic 500.
- 4. LogError gets the exception object plus a template with TraceId and Status, so the stack trace and the fields are both saved.
- 5. The handler writes a ProblemDetails body with the status, a short title and the trace id. No stack trace is sent.
- 6. Returning true tells ASP.NET Core the error is handled. The three registration lines at the end wire it up.
Spot the bug
public async Task<Order> GetOrderAsync(int id)
{
try
{
return await _repo.FindAsync(id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Loading order {OrderId} failed", id);
throw ex;
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Handle errors in ASP.NET Core (Microsoft Learn)
- Logging in .NET and ASP.NET Core (Microsoft Learn)
Lock it in