How To Handle Errors Gracefully In ASP.NET Core
A production application is defined as much by its failure behaviour as by its successful responses. Requests will arrive with invalid data, expired credentials, missing records, slow dependencies and unexpected exceptions. In ASP.NET Core, a graceful error strategy turns those situations into predictable responses for users, useful diagnostics for developers and sensible signals for monitoring systems.
The goal is not to hide every problem. A good API or web application tells the caller what can be fixed, records enough context to investigate what went wrong, and avoids exposing implementation details. It also keeps the response format consistent, whether the request fails in a controller, minimal API endpoint, validation filter or middleware component.
This matters for Australian applications serving customers across Sydney, Melbourne, Brisbane and regional areas. A mobile user on an unreliable connection needs a clear retryable response, while a business processing payments or personal information needs careful treatment of logs and privacy. Error handling is therefore an application design concern, not a final layer added after the main features are complete.
ASP.NET Core provides several useful building blocks: exception-handling middleware, Problem Details, model validation, structured logging, status code pages and custom middleware. Used together, they create a boundary between internal failures and public responses without forcing every controller action to repeat the same code.
Define A Consistent Error Contract
Clients should not have to understand several unrelated error formats. Returning a plain text message for one failure, a framework-generated HTML page for another and a custom JSON object for a third makes frontend code brittle. A consistent contract gives every consumer a stable way to identify the HTTP status, understand the problem and locate a diagnostic reference.
ASP.NET Core supports the Problem Details format through AddProblemDetails(). A basic configuration can provide a standard response for unhandled errors and status codes:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
};
});
Register the related middleware early in the pipeline:
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler();
}
app.UseStatusCodePages();
A typical response might contain type, title, status, detail and instance. The detail field should be safe for the caller. It can explain that an invoice could not be found, but it should never contain a SQL statement, stack trace, connection string or internal file path.
Use domain-specific exceptions sparingly. An OrderNotFoundException can be translated to a 404, while a BusinessRuleViolationException may become a 409 or 422. Keep the mapping centralised so that controller authors do not invent a different status code each time. For broader ASP.NET Core and development discussions, the development articles provide useful surrounding context.
Separate Expected Failures From Defects
Expected failures are part of normal application behaviour. A customer may submit an invalid Australian Business Number, request a product that has sold out or attempt to cancel an order after dispatch. These cases should be represented deliberately, with an appropriate status code and a message that helps the user recover.
Unexpected failures are different. A null reference, broken database connection or malformed response from a payment provider indicates a defect or infrastructure problem. The public response should usually be a generic 500-level Problem Details document, while logs capture the exception and its context for investigation.
A practical distinction is to ask whether the endpoint can reasonably act on the failure. Validation errors commonly produce 400, authentication failures produce 401, permission failures produce 403, missing resources produce 404, conflicts produce 409, and rate limits produce 429. A dependency outage or unhandled coding error should not be disguised as a validation problem.
Avoid catching Exception inside every action. This often creates duplicated logging, inconsistent status codes and accidentally swallowed failures. Catch a specific exception only when the application can recover, add meaningful domain information or translate it into a known response. Let the central exception handler deal with everything else.
Validate At The Request Boundary
Input validation should happen as close to the HTTP boundary as possible. Data annotations, FluentValidation or endpoint-specific validators can reject malformed requests before they reach business services. This keeps service methods easier to use from background jobs, message handlers and tests, while the web layer remains responsible for presenting validation failures.
For controller-based APIs, [ApiController] automatically returns a 400 response when model state is invalid. The response can be customised to match the rest of the application:
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problem = new ValidationProblemDetails(context.ModelState)
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "The request could not be processed"
};
return new UnprocessableEntityObjectResult(problem);
};
});
Choose 400 or 422 consistently and document the choice. A 400 response usually means the request is malformed, while 422 can indicate that the structure is valid but the values violate business rules. Either approach can work if clients can rely on it.
Validation messages should be useful without disclosing sensitive rules. “The delivery postcode is invalid” is more helpful than “Validation failed,” while a message explaining internal fraud thresholds may reveal information that should remain private. For Australian systems, validation may include state abbreviations, mobile number formats, Australian postcodes and dates that users expect to enter as day-month-year.
Log For Diagnosis Without Leaking Data
An error response and an error log serve different audiences. The response should be concise and safe; the log should help an engineer reproduce and resolve the problem. Use structured logging rather than interpolated strings so that fields such as route, user identifier, status code and correlation ID can be searched consistently.
try
{
await next(context);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Unhandled exception for {Method} {Path} with trace {TraceId}",
context.Request.Method,
context.Request.Path,
context.TraceIdentifier);
throw;
}
Useful fields include the request method, route template, response status, elapsed time, deployment version, trace ID and a safe business identifier. Avoid recording full request bodies by default. They may contain passwords, payment details, health information or identity documents.
Australian organisations handling personal information should treat logs as part of their data environment. The Privacy Act 1988 and the Australian Privacy Principles make careless collection and retention a real operational concern, and a serious data exposure may trigger obligations under the Notifiable Data Breaches scheme. Mask email addresses, tokens, access keys and payment data, and apply retention rules to log stores.
Fields Worth Capturing In Error Logs
- A trace or correlation ID returned to support staff and safe for the client
- The route template, HTTP method and resulting status code
- A stable, non-sensitive account or transaction reference
- Elapsed time, application version and dependency information
- The exception type and stack trace for unexpected failures
Logging volume also needs control. A client repeatedly sending invalid data should not fill the error index with thousands of identical entries. Record expected validation failures at an appropriate level, use rate limiting where appropriate and configure alerts around error rates rather than individual noisy events.
Protect The Pipeline And Its Dependencies
Middleware order affects whether errors are handled at all. The exception handler must be registered before components that may throw. Authentication and authorisation should run before protected endpoint logic, while response compression, static files and routing should be placed according to the application model. Test the actual pipeline instead of assuming registration order is harmless.
External services deserve their own failure policy. A timeout calling a shipping provider is not equivalent to a permanent “postcode not supported” response. Use explicit timeouts, cancellation tokens and resilient HTTP client configuration. Polly or the resilience handlers available through Microsoft.Extensions.Http.Resilience can provide carefully bounded retries, circuit breakers and timeouts.
Retries need idempotency. Repeating a GET is generally safe, while repeating a payment or order-creation request can produce duplicate side effects. Use idempotency keys for operations that may be retried, and make downstream calls aware of cancellation so a disconnected browser does not leave unnecessary work running.
builder.Services.AddHttpClient<ShippingClient>(client =>
{
client.BaseAddress = new Uri("https://shipping.example");
client.Timeout = TimeSpan.FromSeconds(10);
});
Do not retry every 500 response automatically. A retry storm can make an outage worse. Retry transient network faults and selected 429 or 503 responses with a limit and backoff, then return a clear temporary-failure response. Australian services may experience traffic peaks around local business hours, end-of-month billing or major retail events, so capacity and dependency limits should be observed under realistic load.
Make Error Responses Useful To People
Graceful handling includes the user interface, not just server middleware. A web client should distinguish between an invalid form, an expired session, a missing record and a temporary outage. It can highlight fields for validation errors, redirect to sign-in for an expired credential, offer a retry for a timeout and show a support reference for an unexpected failure.
Never display raw exception messages in production. They can expose database names, framework versions and security-sensitive details. In development, detailed errors are valuable, but they should be restricted to local or controlled environments. Environment-based configuration must be tested so that a staging setting cannot accidentally expose diagnostics to public users.
The response should include a trace identifier when a user may need support. A customer contacting a help desk from Perth or a call centre in Melbourne can provide that reference without seeing the underlying exception. It is especially useful when logs are distributed across multiple application instances and background services.
Status Codes And User-Facing Behaviour
- 400 or 422: Show the fields or rules that need correction
- 401: Ask the user to authenticate again without revealing account details
- 403: Explain that the action is unavailable for the current permissions
- 404: Offer a safe not-found message without confirming sensitive records
- 409: Explain the conflict and provide a fresh state where possible
- 429, 502, 503 or 504: Suggest waiting or retrying without promising recovery
Accessibility and localisation should be considered in the presentation. Clear text, useful focus management and status announcements help keyboard and assistive-technology users. A concise Australian English message is usually preferable to a technical phrase such as “upstream dependency failure.” Avoid assuming that every user is online continuously; preserve form input where safe and make retry actions explicit.
Test Failure Paths As Deliberately As Success Paths
Error handling is only dependable when it is tested. Unit tests can verify exception-to-status mappings and validation rules, while integration tests can send real HTTP requests through the configured middleware. Assert the status code, content type, Problem Details fields, absence of stack traces and presence of a trace identifier.
Test dependency failures with controlled fakes or mock HTTP handlers. Simulate timeouts, malformed JSON, 429 responses, connection refusals and slow database queries. Check that cancellation is honoured and that a retry policy does not duplicate a non-idempotent operation.
Security testing should confirm that authentication and authorisation errors do not reveal whether a sensitive resource exists. Operational testing should verify that logs contain enough diagnostic context without capturing secrets. Run these checks in CI so that a framework upgrade or middleware change cannot silently alter the public error contract.
A useful reference is the Suema-R resource, which can sit alongside framework documentation and project-specific test guidance when building a broader engineering practice. Keep the application’s error schema documented in OpenAPI, including representative validation, conflict and server-error responses, so frontend and integration teams can work from the same expectations.
The strongest implementations also measure failure behaviour in production. Track rates by endpoint and status class, monitor dependency latency, and alert on unusual changes rather than treating every isolated 404 as an incident. Review recurring errors during development work: repeated validation confusion may indicate a poor interface, while repeated 503 responses may point to capacity or dependency design.
Adopt a single Problem Details contract, centralise exception handling, validate requests at the boundary and log with privacy in mind. Add bounded resilience for dependencies, test failure paths through the real pipeline and give users a safe reference when something unexpected occurs. With those practices in place, errors become controlled application behaviour rather than confusing interruptions or avoidable security risks.