Centralized Error Handling and Problem Details
Pattern classification: General learning material
Difficulty: Intermediate
Prerequisites: Basic familiarity with ASP.NET Core middleware and ILogger. Middleware Ordering Changes Behavior and Structured Logging Without Sensitive-Data Sprawl provide useful context. Policy Context and Explicit Decision Outcomes is recommended when mapping governance outcomes to HTTP.
Learning objective: Design one application-level boundary for unexpected failures, return a stable and deliberately limited Problem Details contract, map known failures without hiding operational meaning, correlate public errors with internal diagnostics, and keep expected governance decisions distinct from exceptions.
Pattern Card
Problem: Error handling scattered across controllers, endpoint handlers, services, and repositories produces inconsistent status codes, duplicate exception logs, accidental information disclosure, and transport-specific logic that is difficult to review.
Pattern: Let expected outcomes remain explicit data. Let unexpected exceptions cross to one centralized application boundary. At that boundary, map only failures the host understands, log once with deliberate diagnostic context, return a safe RFC 9457 Problem Details response, and use a generic
500fallback for everything else.Use when: An ASP.NET Core application needs a consistent public error contract, centralized exception normalization, correlation with operational telemetry, or a clear distinction between expected domain/governance outcomes and unexpected execution failures.
Prefer something simpler when: A small application has one or two endpoints and a local result-mapping function already provides a consistent contract without duplicated exception handling. Centralization should reduce ambiguity, not add ceremony for its own sake.
Observe: An expected denial, deferral, acknowledgment requirement, or escalation recommendation can be translated directly by the host without throwing an exception merely to reach the error handler.
The central rule is:
Centralized error handling should normalize unexpected failures without erasing meaningful domain and governance outcomes.
A denied decision is not an exception merely because execution does not proceed.
Start by Separating Three Kinds of Outcomes
Many confusing error-handling designs begin by treating every non-success path as the same thing.
A more useful model separates three categories.
1. Expected application or governance outcomes
These are states the application deliberately models and expects to occur.
Examples include:
Validation failed
Resource not found
Authorization denied
Governance decision = Denied
Governance decision = Deferred
Governance decision = AcknowledgmentRequired
Governance decision = EscalationRecommended
These outcomes should normally remain data.
The host can translate them to HTTP deliberately.
2. Known operational exceptions
An exception can represent a failure the host recognizes and can map safely.
For example:
CatalogUnavailableException
↓
Known temporary dependency failure
↓
503 Service Unavailable
The public response should not expose the dependency host name, connection details, credentials, or raw exception message merely because the host recognizes the exception type.
3. Unexpected exceptions
An unmapped exception means the application did not complete the operation as expected and does not have a more specific public contract for the failure.
The safe fallback is usually:
Unexpected exception
↓
Central handler
↓
Operational error log
↓
Generic 500 Problem Details
The client learns that the request failed and receives a correlation reference.
Operators keep the richer diagnostic evidence inside the application's observability boundary.
The three categories can be summarized as:
Expected outcome
↓
Explicit result / decision
↓
Host HTTP mapping
Known exception
↓
Central exception handler
↓
Deliberate status + safe Problem Details
Unknown exception
↓
Central exception handler
↓
Generic 500 + safe Problem Details
This separation is more important than any particular exception class hierarchy.
Why Scattered try/catch Becomes an Architecture Problem
Consider an application where several layers catch the same failure:
Repository catches + logs
↓
Service catches + logs
↓
Endpoint catches + logs
↓
Endpoint invents HTTP response
A single failure may now produce:
- Several nearly identical log entries.
- Different status codes depending on which endpoint called the service.
- Different public response shapes.
- Repeated exception-to-HTTP translation logic.
- Inconsistent handling of correlation identifiers.
- A greater chance that one endpoint returns an exception message or stack trace.
The code may look locally defensive while the application-level contract becomes less predictable.
A centralized design instead aims for:
Application code
↓
Expected outcomes returned explicitly
↓
Unexpected exceptions allowed to propagate
↓
One exception boundary
↓
One public normalization policy
This does not mean try/catch is forbidden below the boundary.
A lower layer may legitimately catch an exception when it can do something meaningful such as:
- Retry a transient operation.
- Translate a provider-specific exception into an application-specific exception.
- Compensate or roll back local work.
- Add context that changes the application's understanding of the failure.
- Consume an exception because the failure has actually been handled.
The problem is catching only to log and rethrow at every layer.
The Central Exception Boundary Must Wrap the Work It Owns
ASP.NET Core exception handling is middleware behavior.
That means placement still matters.
Conceptually:
Exception boundary
↓
Application middleware
↓
Endpoint
↓
Application service
An exception thrown by behavior that runs before the exception boundary is entered cannot be normalized by that boundary.
This is the same wrapping rule demonstrated in Middleware Ordering Changes Behavior:
Boundary enters first
↓
Boundary awaits downstream work
↓
Downstream exception occurs
↓
Boundary can handle it
Do not memorize "exception handling goes first" as a universal pipeline slogan.
Ask instead:
Which failures is this boundary expected to normalize, and does the middleware actually wrap those failure-producing components?
Some hosting, proxy, server, or very-early pipeline failures can occur outside the application's exception handler.
A centralized application boundary is strong, but it is not the only failure boundary in the system.
ASP.NET Core Primitives
Modern ASP.NET Core provides complementary primitives for this design:
AddProblemDetails()
↓
Registers Problem Details services
AddExceptionHandler<THandler>()
↓
Registers an IExceptionHandler implementation
UseExceptionHandler()
↓
Adds the application exception boundary
UseStatusCodePages()
↓
Can produce bodies for status codes that otherwise have no response body
A minimal registration can look like:
WebApplicationBuilder builder =
WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<ApplicationExceptionHandler>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
The exact pipeline may contain other middleware.
The architectural responsibilities remain:
IExceptionHandlerhandles exceptions that reach the boundary.IProblemDetailsServicewrites a standardized Problem Details representation.- Status-code pages handle HTTP failures that did not require an exception.
- Endpoint or application code still returns expected outcomes explicitly.
IExceptionHandler Makes the Boundary Explicit
A small handler can own three decisions:
- Which exception types have an intentional public mapping?
- What internal operational event should be recorded?
- What safe public response should be returned?
For example:
public sealed class ApplicationExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<ApplicationExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
ExceptionProblem problem = exception switch
{
CatalogUnavailableException => new(
StatusCodes.Status503ServiceUnavailable,
"Service Unavailable",
"/problems/catalog-unavailable",
"dependency.catalog-unavailable",
"The catalog is temporarily unavailable."),
_ => new(
StatusCodes.Status500InternalServerError,
"Internal Server Error",
"/problems/unexpected-failure",
"unexpected.failure",
"An unexpected error occurred.")
};
string traceId =
Activity.Current?.TraceId.ToString()
?? httpContext.TraceIdentifier;
logger.LogError(
exception,
"Application exception handled centrally. " +
"StatusCode: {StatusCode}. ProblemCode: {ProblemCode}. " +
"TraceId: {TraceId}.",
problem.Status,
problem.Code,
traceId);
var details = new ProblemDetails
{
Status = problem.Status,
Title = problem.Title,
Type = problem.Type,
Detail = problem.PublicDetail,
Instance = httpContext.Request.Path.Value
};
details.Extensions["code"] = problem.Code;
details.Extensions["traceId"] = traceId;
httpContext.Response.StatusCode = problem.Status;
return await problemDetailsService.TryWriteAsync(
new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = details
});
}
}
The companion sample uses the same shape with source-generated logging and focused integration tests.
The example is intentionally small.
A production handler may need additional mappings, metrics, localization policy, cancellation handling, content negotiation, or host-specific operational behavior.
The important constraint is that the handler should not become a place where every domain rule is recreated as an exception mapping.
Problem Details Is a Public Error Contract
RFC 9457 defines the Problem Details format for machine-readable HTTP API errors.
A typical response can contain fields such as:
{
"type": "/problems/catalog-unavailable",
"title": "Service Unavailable",
"status": 503,
"detail": "The catalog is temporarily unavailable.",
"instance": "/failure/catalog",
"code": "dependency.catalog-unavailable",
"traceId": "00-..."
}
The standard fields have different purposes:
| Field | Purpose |
|---|---|
type |
Identifies the problem type or category. |
title |
Short human-readable summary. |
status |
HTTP status associated with the occurrence. |
detail |
Human-readable detail safe for the caller. |
instance |
Identifies the specific request/problem occurrence, often with a request path or URI reference. |
Extensions can add application-specific fields such as:
code
traceId
Do not make clients parse the human-readable detail text to determine program behavior.
Prefer a stable code such as:
dependency.catalog-unavailable
The message can then change for wording, localization, or usability without breaking the machine contract.
Stable code does not mean internal implementation detail
A public problem code should identify a supported client-facing category.
Avoid exposing identifiers such as:
SqlException-18456
ClusterNode-east-prod-07
InternalPolicyClass42
unless those identifiers are deliberately part of the public API contract.
A stable code should help the client react without publishing unnecessary internal topology.
Public Error Information and Internal Diagnostics Serve Different Boundaries
The same failure may need two representations.
Internal operational event
Operators may need:
Exception type
Stack trace
Dependency name
Failure category
Status mapping
Trace ID
Request path
Timing
Public Problem Details
The caller may need only:
Status
Stable problem type/code
Safe explanatory text
Trace/correlation reference
The relationship is:
Rich internal diagnostic evidence
↓
Operator observability boundary
Safe public problem contract
↓
Caller boundary
Do not copy the internal event into the public response.
Do not make the public response so empty that support cannot correlate it with internal telemetry.
A trace or correlation identifier often provides the bridge.
Information Disclosure: What Not to Return
A production error response should not expose values merely because they exist on an exception.
Avoid returning:
- Stack traces.
- Raw exception messages by default.
- Connection strings.
- Database server names unless intentionally public.
- API keys, tokens, cookies, or credentials.
- File-system paths.
- Internal service topology.
- Raw request or response bodies.
- Provider-specific query text.
- Internal policy objects or unreviewed identifiers.
- Personal or regulated data not required by the caller.
For example, suppose an internal exception says:
Connection to sql.internal.example failed.
Password=demo-secret
The public response should not repeat that text.
It can instead say:
The catalog is temporarily unavailable.
with a stable code and trace ID.
This is not about hiding every fact from a caller.
It is about making disclosure a reviewed API decision rather than an accidental consequence of exception.Message.
Development Versus Production Behavior
Development environments often need more diagnostics.
ASP.NET Core can provide developer-oriented exception information during local development.
That does not require the production API contract to become verbose.
A useful separation is:
Development diagnostics
↓
Developer tooling / local logs / debugger
Public API contract
↓
Safe Problem Details shape
If an application chooses to include additional details in Development responses, make the environment boundary explicit and test that Production does not expose them.
Do not rely only on a developer remembering not to deploy a verbose flag.
The companion sample deliberately returns the same safe public exception details in every environment so its disclosure tests remain deterministic.
A production application can layer development tooling around that boundary without changing the lesson.
Map Only Exceptions You Understand
A mapping table should describe application meaning, not merely CLR type names.
For example:
| Exception or failure | Possible HTTP mapping | Reasoning |
|---|---|---|
CatalogUnavailableException |
503 Service Unavailable |
Host has classified the condition as temporary dependency unavailability. |
| Explicit request-parse failure owned by the HTTP boundary | 400 Bad Request |
The request is invalid because of caller-controlled input. |
| Unknown/unmapped exception | 500 Internal Server Error |
Host has no narrower supported public contract. |
Be cautious with broad mappings such as:
ArgumentException = 400
An ArgumentException can be caused by a server-side programming bug just as easily as by bad caller input.
If all argument exceptions automatically become 400, an internal defect can be mislabeled as a client error.
Prefer a specific request-validation result or a narrow application exception when the caller truly owns the invalid input.
The working NetCoreApplicationTemplate reference follows this conservative distinction in its centralized handler.
Expected Governance Outcomes Are Not Exceptions
The foundational governance material uses explicit outcomes such as:
Allowed
Denied
Deferred
AcknowledgmentRequired
EscalationRecommended
These values describe what the governed workflow should do next.
They should remain transport-independent.
The HTTP host can translate them.
For example:
| Governance outcome | Possible HTTP representation | Important note |
|---|---|---|
Allowed |
Normal success result | The endpoint continues under the host's normal execution contract. |
Denied |
Often 403 Forbidden |
A host may choose another representation for concealment or domain-specific reasons; do not make 403 part of the governance enum. |
Deferred |
Often 503 Service Unavailable or another retriable host result |
Use only when the transport meaning matches the actual deferral condition. Retry metadata may be appropriate. |
AcknowledgmentRequired |
Often 409 Conflict with an explicit problem code |
The client needs a workflow step, not an exception stack trace. |
EscalationRecommended |
Often 409, 422, or a workflow-specific result |
The exact transport mapping belongs to the host/API contract. |
The important architecture is:
GovernanceDecisionOutcome
↓
Host mapping
↓
HTTP status + Problem Details when useful
not:
GovernanceDecisionOutcome
↓
Throw exception
↓
Central handler guesses governance meaning
A host mapper can remain explicit
For example:
static IResult ToHttpResult(
GovernanceDecision decision,
HttpContext httpContext)
{
return decision.Outcome switch
{
GovernanceDecisionOutcome.Allowed =>
Results.NoContent(),
GovernanceDecisionOutcome.Denied =>
Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Forbidden",
type: "/problems/governance-denied",
detail: decision.PublicDetail,
instance: httpContext.Request.Path,
extensions: new Dictionary<string, object?>
{
["code"] = decision.Code
}),
GovernanceDecisionOutcome.Deferred =>
Results.Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Service Unavailable",
type: "/problems/governance-deferred",
detail: decision.PublicDetail,
instance: httpContext.Request.Path,
extensions: new Dictionary<string, object?>
{
["code"] = decision.Code
}),
_ =>
MapOtherExpectedWorkflowOutcome(decision, httpContext)
};
}
The mapper is transport code.
The policy evaluator does not need to know what 403, 409, or 503 means.
That preserves the boundary established in Policy Context and Explicit Decision Outcomes.
HTTP Status Is a Translation, Not the Domain Model
It is tempting to encode HTTP directly into application decisions:
public sealed record GovernanceDecision(
int HttpStatusCode,
string Message);
That makes the decision harder to reuse in:
- Background jobs.
- Message consumers.
- CLI applications.
- Desktop hosts.
- Tests that should reason about policy independently of HTTP.
- Future transports with different response semantics.
Prefer:
Domain/governance outcome
↓
Host-specific translation
The HTTP status communicates the transport-level consequence.
The stable decision/problem code preserves the application meaning.
Log the Exception Where It Is Handled
A centralized exception handler is a natural owner for the operational exception event because that is where the application decides:
This exception crossed the application boundary
↓
This is how it is classified
↓
This is the public response
Prefer:
Repository throws
↓
Service cannot recover → lets it propagate
↓
Endpoint does not catch only to rethrow
↓
Central handler logs once
rather than:
Repository logs
Service logs
Endpoint logs
Central handler logs
A lower layer should still log if it genuinely handles, retries, translates, or consumes a failure and that event has independent operational value.
The rule is not "only one log line may ever mention an incident."
The rule is:
Do not repeatedly log the same exception merely because it crosses another method boundary.
In .NET 10, handled-exception diagnostic emission by the exception-handler middleware is suppressed by default when an IExceptionHandler successfully handles the exception. If a host changes that diagnostic behavior, review whether the custom handler and framework diagnostics now duplicate the same event.
Correlate the Public Problem with Internal Logs
A useful incident path looks like:
Caller receives:
traceId = 3a4f...
↓
Support searches operational telemetry
↓
Handler log contains:
TraceId = 3a4f...
↓
Internal exception evidence found
The public response does not need the stack trace to make the incident diagnosable.
For example:
string traceId =
Activity.Current?.TraceId.ToString()
?? httpContext.TraceIdentifier;
problemDetails.Extensions["traceId"] = traceId;
and the structured event can use the same value:
logger.LogError(
exception,
"Application exception handled centrally. TraceId: {TraceId}.",
traceId);
Treat externally supplied correlation identifiers as untrusted input if you use them in addition to the host trace identifier.
Bound and validate them as described in Structured Logging Without Sensitive-Data Sprawl.
A correlation field links evidence.
It does not create authentication, authorization, or proof of causality by itself.
Status-Code Pages Handle Non-Exceptional HTTP Failures
Not every HTTP error should require an exception.
Examples include:
404 Not Found
405 Method Not Allowed
401 Unauthorized challenge
403 Forbidden result
Some of these responses may be created by routing, authorization, or explicit endpoint results rather than by thrown exceptions.
UseStatusCodePages() can fill a response body when an error status would otherwise have no body, and AddProblemDetails() lets ASP.NET Core use Problem Details for that representation when a writer is available.
This gives two distinct paths:
Exception thrown
↓
UseExceptionHandler / IExceptionHandler
↓
Problem Details
and:
HTTP status produced without exception
↓
Status-code pages when no body exists
↓
Problem Details
Do not throw an exception simply to manufacture a 404 or 403 body when the host already knows the expected HTTP outcome.
The companion sample includes a missing-route integration test to make this distinction observable.
Do Not Rewrite a Response That Has Already Started
HTTP has another practical boundary:
Headers/body begin streaming
↓
Later exception occurs
Once the response has started, the application may no longer be able to replace it cleanly with a new Problem Details document and status code.
A robust handler should therefore treat:
httpContext.Response.HasStarted
as a meaningful boundary.
If the response has already started, returning false from a custom handler can allow the framework/host to continue its fallback behavior rather than pretending a complete normalized response was written.
For streaming endpoints, file transfers, server-sent events, or long-running responses, failure behavior needs additional design beyond a normal request/response Problem Details contract.
Safe Fallback Behavior for Unmapped Exceptions
A common mistake is trying to infer a specific status from every exception.
That encourages fragile rules such as:
NullReferenceException → 404
ArgumentException → 400
InvalidOperationException → 409
Those mappings may hide programming defects behind client-looking responses.
A safer default is:
Known, deliberately classified exception
↓
Specific safe mapping
Everything else
↓
500 Internal Server Error
↓
Generic public detail
↓
Rich internal diagnostic event
The fallback should not fail open.
An error in exception classification should not result in the application continuing a consequential side effect as though the request succeeded.
Cancellation Needs Its Own Policy
Cancellation can represent several different situations:
- The caller disconnected.
- A request timeout expired.
- The application is shutting down.
- A dependency operation was canceled.
- Application code intentionally canceled work.
Do not automatically turn every OperationCanceledException into a generic 500 without considering which cancellation token triggered it and what the host should report.
Likewise, do not swallow cancellation indiscriminately just to make logs look quieter.
The important rule is:
Classify cancellation according to the operation and host lifecycle rather than treating it as ordinary policy denial or arbitrary server failure.
The companion sample keeps cancellation outside its intentionally small scope.
Problem Details Is Not Governance Audit Evidence
A Problem Details response is a client-facing transport artifact.
It may contain:
status
problem type
stable code
trace ID
That does not make it the durable record of why a consequential governance decision occurred.
A governance receipt may need different evidence such as:
Decision ID
Policy identity/version
Reason codes
Acknowledgment state
Capability reference
Execution state
Correlation ID
The relationship can be:
Governance receipt
↓
Purpose-built evidence store
HTTP Problem Details
↓
Client-facing representation
Shared correlation / decision reference
↓
Links the two when appropriate
Do not copy the entire governance receipt into the public response merely because both are structured JSON.
See Acknowledgment and Audit Residue for the evidence boundary.
Test the Public Error Contract as an Invariant
A centralized handler is valuable only if its behavior remains consistent during refactoring.
Useful integration tests include the following.
Denied governance decision does not become an unhandled exception
Governance outcome = Denied
↓
Host maps to 403 Problem Details
↓
Central exception handler invocation count = 0
This proves the expected-outcome boundary.
Unexpected exception becomes a safe 500
Endpoint throws unmapped exception
↓
500 Problem Details
↓
code = unexpected.failure
Sensitive exception detail is absent from the public response
Create a fictional exception message containing a recognizable fake secret or internal host name.
Then prove the response body does not contain it.
The point is not the fake value.
The point is preventing a future refactor from replacing reviewed public detail with exception.Message.
Known application exception maps consistently
CatalogUnavailableException
↓
503 Problem Details
↓
code = dependency.catalog-unavailable
Public trace ID matches the handler log
Problem Details.traceId
=
Structured exception log.TraceId
This proves the observability bridge without exposing the diagnostic payload.
Missing route gets a Problem Details body without throwing
Unknown route
↓
404 status
↓
Status-code pages
↓
Problem Details
This proves that exception handling and status-code handling are separate responsibilities.
The companion sample implements these tests against a small in-memory ASP.NET Core host.
Review the Contract from Both Sides
An error-handling review should ask both client and operator questions.
Client questions
- Is the HTTP status appropriate to the supported API contract?
- Is the Problem Details
typeor stable code documented enough to react to? - Is the
detailtext safe to disclose? - Is there a trace/correlation reference when support may need one?
- Can clients distinguish a retryable deferral from a permanent denial?
- Is an acknowledgment or escalation step represented explicitly rather than disguised as
500?
Operator questions
- Is the actual exception logged where it is handled?
- Does the event contain a stable problem category and trace ID?
- Are duplicate exception logs being emitted at several layers?
- Can a known mapped exception still trigger the alerting needed for an outage?
- Can internal exception text contain secrets or regulated data that should be minimized or redacted?
- What retention and access policy applies to the diagnostic event?
Good error handling needs both views.
A safe public response without internal visibility is hard to operate.
Rich internal diagnostics copied to the client are unsafe.
Working Implementation References
Learning keeps the tutorial intentionally smaller than a production application.
NetCoreApplicationTemplate provides a fuller working ASP.NET Core specimen for centralized exception handling and Problem Details.
| Learning concept | Working reference | What to inspect |
|---|---|---|
Central IExceptionHandler mapping |
ProblemDetailsExceptionHandler.cs |
Known status mapping, generic 500 fallback, response-started checks, Problem Details writing, trace/request identifiers, and logging at the handler boundary. |
| Problem Details registration and status-code behavior | ProblemDetailsExtensions.cs |
AddProblemDetails, custom response enrichment, environment behavior, exception-handler registration, and status-code pages. |
| Error-contract tests | ProjectTemplate.Web.Tests |
Integration and customization tests around the working application's Problem Details behavior. |
| Operational telemetry boundary | Structured Logging Without Sensitive-Data Sprawl | Why exception diagnostics and public error responses should carry different information even when they share correlation. |
Use the implementation repository as a specimen, not as a requirement to reproduce every mapping or middleware choice.
One especially useful detail in the working handler is its conservative treatment of broad ArgumentException failures: a generic argument exception is not automatically labeled 400 Bad Request because it may represent a server-side developer defect.
That is the kind of boundary this tutorial is intended to make reviewable.
When a Simpler Pattern Is Better
A custom IExceptionHandler is not mandatory merely because ASP.NET Core supports it.
A smaller application may already have:
- One endpoint group.
- A small, consistent result type.
- Framework validation for caller errors.
- No special exception mapping beyond generic
500behavior. - Built-in authorization for access control.
In that case, the framework defaults plus a small amount of host result mapping may be sufficient.
Add a custom handler when it gives the application a clearer contract for:
- Known exception classes.
- Public Problem Details shape.
- Correlation.
- Central exception logging.
- Safe fallback behavior.
- Cross-endpoint consistency.
Do not build a deep exception taxonomy simply to demonstrate architectural sophistication.
The smallest clear boundary is usually the better one.
Tradeoffs
Benefits
- One application boundary creates more consistent public failure behavior.
- Expected governance outcomes remain explicit rather than exception-driven.
- Problem Details provides a standard machine-readable response shape.
- Stable problem codes reduce dependence on human-readable wording.
- Central logging reduces duplicate exception events.
- Correlation lets public incidents be matched with private diagnostics.
- A generic
500fallback prevents arbitrary exception details from becoming public API behavior. - Integration tests can protect disclosure and mapping invariants.
Costs
- Exception classification becomes an application-owned policy that must be maintained.
- A centralized handler can become an oversized catch-all if domain rules are pushed into it.
- Incorrect status mappings can hide server defects or mislead clients.
- Correlation and structured logging require observability conventions.
- Streaming or already-started responses need additional failure design.
- Development diagnostics and production disclosure rules must remain clearly separated.
- A uniform Problem Details shape does not remove the need to document individual problem codes and retry/workflow semantics.
The goal is not to make every failure look identical.
The goal is to make each category predictable without erasing what the failure actually means.
Official References
- Handle errors in ASP.NET Core
IExceptionHandlerIProblemDetailsService- RFC 9457 — Problem Details for HTTP APIs
Review Questions
Before moving on, you should be able to answer:
- Why is an expected governance denial different from an unexpected exception?
- What problem does one application-level exception boundary solve that scattered
try/catchblocks do not? - Why must exception-handler middleware wrap the failures it is expected to normalize?
- What responsibilities belong to
IExceptionHandler? - What does Problem Details standardize, and what remains application-specific?
- Why should clients use a stable problem code rather than parse
detailtext? - Why is returning
exception.Messageunsafe as a production default? - Why can broad
ArgumentException → 400mapping hide server bugs? - Why should
GovernanceDecisionOutcomeremain independent of HTTP status codes? - How can
Denied,Deferred,AcknowledgmentRequired, andEscalationRecommendedbe mapped by the HTTP host without throwing? - Why is logging the same exception at several layers usually harmful?
- How can one trace ID connect the public response to internal structured logs?
- What is the difference between exception handling and status-code pages?
- Why can an already-started response prevent normal Problem Details replacement?
- What should happen to an exception that has no deliberate mapping?
- Why does cancellation need its own host policy rather than automatic
500mapping? - Why is a Problem Details response not a governance audit receipt?
- Which integration tests protect the public disclosure and mapping contract?
- When would framework defaults or a smaller result-mapping function be enough?
If these answers are unclear, the application may have error responses, but it does not yet have a deliberate error-handling architecture.
Related Content
- ASP.NET Core learning area
- Middleware Ordering Changes Behavior
- Secure-by-Default ASP.NET Core Configuration
- Structured Logging Without Sensitive-Data Sprawl
- Policy Context and Explicit Decision Outcomes
- Acknowledgment and Audit Residue
- Trust Boundaries and Least Privilege
- Centralized Error Handling and Problem Details sample
- NetCoreApplicationTemplate
Read it. Run it. Question it. Improve it.