Exception Handling & Custom Error Handling in .NET — Every Way, Every App Type
In short: throw; rethrows the current exception and preserves the original stack trace.
throw ex;throws the same object as if it originated at that line, resetting the stack trace and hiding where the error really happened.- Use
throw;or wrap in a new exception with the original asInnerException.
In short: Validate early with guard clauses;
- catch only specific exceptions you can handle;
- never swallow exceptions silently;
- use
usingfor cleanup; - rethrow with
throw;; - wrap with context and inner exceptions;
- don't use exceptions for normal flow (
TryParse); - have one global handler at each application boundary that logs with a correlation id and returns safe responses;
- custom exceptions only when callers handle them specifically.
In short: finally runs after the try (and any catch) whether an exception occurred or not — for cleanup.
- It doesn't run if the process is killed, on
Environment.FailFast, on a StackOverflowException, or if the thread hangs inside try. usingstatements compile to try/finally.
In short: Derive from Exception (or your own base like AppException), name it ...Exception, provide constructors for message and inner exception, and add properties with useful data (entity, key, error code).
- Create one when callers need to distinguish that case (e.g. map to 404 or 409); otherwise use built-in exceptions.
In short: Use UseExceptionHandler() with AddProblemDetails() and one or more IExceptionHandler implementations (.NET 8+) — or custom middleware registered first in older versions — to log the exception with a trace id and map exception types to status codes (404, 409, 422, 500) with ProblemDetails bodies.
- Add
UseStatusCodePagesfor empty error responses, exception filters for MVC-specific mapping, and the developer exception page only in development.
In short: Exception middleware/handler wraps the whole pipeline, so it catches errors from routing, other middleware, controllers, minimal APIs and views — the right place for global logging and response shaping.
- Exception filters only see exceptions from MVC actions and filters but have access to MVC context (action, model state) — useful for controller-specific mapping.
- Typically: a global handler plus optional filters.
In short: Web Forms: try/catch → Page_Error → Application_Error in Global.asax → customErrors/httpErrors in web.config (ELMAH or log frameworks for logging).
- MVC 5: try/catch → controller
OnException→[HandleError]/customIExceptionFilter(global in FilterConfig) →Application_Error(for routing/404 and non-MVC errors) → customErrors/httpErrors. - Web API 2 adds
IExceptionHandler/IExceptionLogger.
In short: Use exceptions for unexpected or exceptional conditions (bugs, infrastructure down, broken invariants) — they bubble to a global handler.
- Use Result objects for expected outcomes that callers must handle (validation failed, not found in a lookup, business rule rejected) — explicit in the signature and cheaper.
- Mixing them consistently (documented in the team) is common.
In short: await unwraps and rethrows the task's exception directly (the first one).
Task.WhenAllfaults with all exceptions inAggregateException, butawaitrethrows only the first — inspecttask.Exception.InnerExceptionsfor all..Result/.Wait()wrap inAggregateExceptionand risk deadlocks.async voidexceptions can't be caught by callers and crash the process.
In short: A standard JSON format (RFC 9457, previously 7807) for HTTP API errors with type, title, status, detail, instance and extension fields (e.g. traceId, errorCode, validation errors).
- ASP.NET Core produces it for validation errors (
[ApiController]) and, withAddProblemDetails, for exceptions and status codes — clients get one consistent error shape.
In short: Catch DbUpdateException (EF) or SqlException with Number 2627/2601, translate it to a domain/conflict exception, and return 409 with a friendly message ("email already registered").
- Better still, check for duplicates first for a nicer UX, but keep the unique constraint as the real guarantee against race conditions.
In short: An unhandled exception on a thread (not a Task) terminates the process after raising AppDomain.UnhandledException.
- In a
BackgroundService, an exception escapingExecuteAsyncstops the host by default since .NET 6 (configurable withBackgroundServiceExceptionBehavior). - Unobserved faulted tasks raise
TaskScheduler.UnobservedTaskExceptionbut no longer crash the process. - Wrap each unit of work in try/catch, log, and continue or back off.
In short: It hides bugs and unexpected states, makes the program continue with corrupted data, catches exceptions you can't handle (OutOfMemory, cancellation) and duplicates logging.
- Catch
Exceptiononly at boundaries — global handlers, top of background loops, plug-in hosts — where you log and fail safely.
In short: Turn on custom errors (customErrors mode="On"/RemoteOnly and IIS httpErrors in .NET Framework;
UseExceptionHandlerand not the developer page outside Development in Core), make sureASPNETCORE_ENVIRONMENT=Production/compilation debug="false", add a global handler that logs the full exception with a correlation id and shows a friendly page with that id, and review what else leaked (connection strings, versions).
In short: Distinguish expected failures from bugs: validate input (400 via [ApiController]/FluentValidation), throw or return a NotFound result (404), map business rule violations to 409/422 with error codes — all through a global IExceptionHandler producing ProblemDetails.
- Keep 500 for real unexpected errors only, which then become meaningful alerts.
In short: They are SQL error 1205 — transient by nature.
- Retry the whole transaction a few times with backoff (EF Core execution strategy / Polly), keeping the operation idempotent.
- In parallel fix the cause: consistent access order of tables, shorter transactions, proper indexes, RCSI isolation to reduce reader/writer blocking.