Articles · Mediator & CQRS ·004

Errors & HTTP

Runnable sample on GitHub

Sample: samples/004-errors-and-http — a Payments API that maps thrown exceptions to RFC 7807 responses. dotnet run to poke it, dotnet test to prove it.

Prerequisites: 001 — Getting Started.

Overview

A handler’s job is to express what went wrong in business terms — “this payment was not found,” “the amount exceeds the limit,” “the card was declined” — not to know that those map to HTTP 404, 422, and 402. Relay’s Web layer closes that gap. Handlers throw meaning; a single middleware maps the exception type to an HTTP status and a consistent RFC 7807 application/problem+json body. Status codes appear nowhere in your handlers, error shapes never drift between endpoints, and every error carries a correlation id you can trace to the logs.

This article covers the exception hierarchy, the mapper, ProblemDetails, custom mappings for your own exceptions, and the correlation-id middleware — the error surface that every Relay HTTP service shares.

Why this exists

Hand-rolled error handling in a web service fails in three predictable ways:

  1. Inconsistent shapes. One endpoint returns { "error": "not found" }, another returns a bare string, a third returns a 500 with a stack trace. Clients cannot write one error handler; each integration is bespoke. The error contract is as much an API as the success contract, and an inconsistent one is a broken one.
  2. Leaked internals. A NullReferenceException becomes a 500 whose body is a full stack trace and a connection string in an inner exception. Now your error responses are a reconnaissance tool.
  3. Status-code soup in handlers. Handlers return Results.NotFound() / Results.Conflict() / Results.UnprocessableEntity(), mixing transport concerns into business logic, and the same “not found” is a 404 in one handler and a 400 in another because two people wrote them.

Centralised, type-driven mapping fixes all three. There is one place that decides “this exception type → this status and this body,” so the shape is uniform, internals are redacted by default, and handlers stay about the domain. You change the policy once, not per endpoint.

When to use this

Use Relay’s exception handling on every HTTP service you build with Relay — it is not optional infrastructure, it is the contract your clients depend on:

  • Payments. “Insufficient funds” (402), “amount over limit” (422), “already captured” (409), “payment not found” (404) — a payment client needs each as a distinct, stable status with a machine-readable body, never a generic 500.
  • Order management. “Out of stock” (422), “order already shipped” (409), “no such order” (404).
  • Booking systems. “Seat taken” (409), “outside booking window” (422).
  • Customer management. “Email already registered” (409), validation failures (400 with per-field errors so the form can highlight the right boxes).
  • Any service a partner integrates with. A documented, consistent error body (type, title, status, detail, errors, traceId) is the difference between an integration that takes an afternoon and one that takes a week of “what does this 500 mean?” emails.

The throughline: the moment a service is consumed over HTTP by anything you do not also control, its error responses are a public contract and must be consistent, safe, and traceable.

When not to use this

This is about as close to “always use it” as anything in Relay gets, but the edges:

  • Non-HTTP hosts. A pure background worker, a console tool, or a message consumer has no HTTP surface to map exceptions onto. There, an exception is just an exception (and message-consumption error handling is a different mechanism — articles 012–014). Don’t add the Web middleware to a host that serves no requests.
  • You need a fundamentally different error format. If you must emit a vendor-specific error envelope (some payment networks mandate their own), you will register your own IExceptionMapper or format — Relay’s TryAdd registration steps aside for your implementation. Use the framework’s mechanism (centralised, type-driven) even then; just supply your own shape.
  • A gateway already owns error normalisation. If an API gateway in front of you rewrites all error bodies to a house style, double-mapping can fight it. Decide where normalisation lives — usually the service, because it knows the semantics — and don’t do it twice.

The costs are modest but real: there is a fixed vocabulary to learn (which exception means which status), a discipline to maintain (throw the semantic exception, not a generic one), and a redaction policy to get right (expose safe messages, hide the rest) so you neither leak internals nor starve clients of useful detail.

Concepts

The exception hierarchy. RelayException is the base. Its subclasses carry HTTP meaning: ValidationException (400, with an optional per-field Errors dictionary), NotFoundException (404), ConflictException (409), BusinessRuleException (422), UnauthorizedException (401), ForbiddenException (403), ExternalServiceException (503). Throwing one of these is choosing a status, semantically rather than numerically.

IExceptionMapper. Turns an Exception into (statusCode, type, title) and decides whether the exception’s message is IsSafeToExposeMessage. DefaultExceptionMapper implements the table above, plus full-name matches for EF Core’s DbUpdateException/DbUpdateConcurrencyException (→ 409 on a unique-constraint / Postgres SQLSTATE 23505 violation) and the Auth and pipeline exceptions — all by full type name, so the Web package depends on none of those packages.

ProblemDetails. The response body (application/problem+json, camelCase): type (a URI for the problem class), title, status, detail, instance (the request path — not the query string, which may carry secrets), traceId, an optional errors map for validation, and a timestamp.

Redaction policy. With IncludeExceptionDetails = false (production), 5xx details are always generic (“An error occurred…”), and 4xx details are exposed only for known-safe families (the RelayException hierarchy, Auth, pipeline validation) — a custom mapping for, say, ArgumentException to a 4xx still gets a generic detail, because its message might leak internals. With the flag on (development), all messages are shown.

Custom exception mappings. ExceptionHandlingOptions.CustomExceptionMappings lets you map your own exception types to a status without subclassing a framework type. An exact type match wins; otherwise the most-derived assignable mapping wins.

Correlation id. The CorrelationIdMiddleware (added by UseRelayExceptionHandling) ensures every request has a correlation id, surfaced as traceId in the body and tied to the log entry the handler writes — so a client can quote a traceId and you can find the exact failure.

Architecture

sequenceDiagram
    participant C as Client
    participant CM as CorrelationIdMiddleware
    participant EX as GlobalExceptionHandlerMiddleware
    participant EP as Relay endpoint → pipeline → handler
    participant M as IExceptionMapper

    C->>CM: POST /payments
    CM->>CM: ensure correlation id (→ context.Items)
    CM->>EX: next
    EX->>EP: next
    EP-->>EX: throws BusinessRuleException("over limit")
    EX->>M: Map(exception)
    M-->>EX: (422, type-uri, "Business Rule Violation")
    EX->>EX: build ProblemDetails (detail per redaction policy, traceId, instance)
    EX-->>C: 422 application/problem+json
    Note over EX: also sets Activity error status + logs with traceId

UseRelayExceptionHandling wires two middlewares in order: the correlation-id middleware first (so the id exists for everything downstream), then the global exception handler (which wraps the endpoints). If an exception escapes a handler — and by default Relay endpoints let it bubble rather than swallowing it — the handler middleware maps it, marks the current trace as failed, logs it (warning for 4xx, error for 5xx), and writes the ProblemDetails body. One subtlety worth knowing: if the response has already started streaming, a body can no longer be written, so it logs and rethrows to abort the connection rather than send a corrupt half-response.

Building it step by step

The full Payments API is in samples/004-errors-and-http.

1. Throw meaning in handlers

No status codes — just the right exception type:

public Task<Payment> Handle(CapturePaymentCommand command, CancellationToken ct)
{
    var payment = store.Get(command.Id)
        ?? throw new NotFoundException($"Payment '{command.Id}' was not found.");   // → 404
    if (payment.Status == "Captured")
        throw new ConflictException($"Payment '{command.Id}' has already been captured."); // → 409
    return Task.FromResult(store.Save(payment with { Status = "Captured" }));
}

2. Two flavours of 400

Shape validation lives in a validator and produces a pipeline ValidationException (400, message but no field map). A domain validation the validator cannot express throws the Web ValidationException, which carries a per-field Errors dictionary that lands in problem.errors:

if (!Supported.Contains(currency))
{
    throw new ValidationException(
        "Unsupported currency",
        new Dictionary<string, string[]> { ["currency"] = [$"'{currency}' is not supported. Use USD, EUR, or GBP."] });
}

3. A business rule and a custom-mapped exception

if (command.Amount > 10_000m)
    throw new BusinessRuleException($"Amount exceeds the per-transaction limit of 10000."); // → 422

if (IsDeclined(command.Card))
    throw new InsufficientFundsException("The card was declined for insufficient funds.");  // → custom 402

InsufficientFundsException is a plain Exception defined in the service — not a RelayException. It gets a status through configuration, not inheritance.

4. Configure the mapping and the policy

services.AddRelayExceptionHandling(options =>
{
    options.IncludeExceptionDetails = true;                       // dev: show messages
    options.TypeUrlBase = "https://api.payments.example/problems"; // problem "type" URIs
    options.CustomExceptionMappings = new Dictionary<Type, (int StatusCode, string Type, string Title)>
    {
        [typeof(InsufficientFundsException)] =
            (402, "https://api.payments.example/problems/insufficient-funds", "Insufficient Funds"),
    };
});

5. Wire the middleware

app.UseRelayExceptionHandling();   // correlation id + global exception handler (wraps everything)
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapRelayEndpoints());

Complete source code

File Contents
Payments/Commands.cs Handlers throwing each exception type; the validator
Payments/Queries.cs Get-by-id that throws NotFoundException
Payments/Payment.cs The record, the store, and InsufficientFundsException
PaymentsServiceCollectionExtensions.cs AddRelayExceptionHandling + custom mapping
Program.cs UseRelayExceptionHandling + MapRelayEndpoints

Running the example

dotnet run --project samples/004-errors-and-http/Payments.Api
curl -i -X POST localhost:5000/payments -H 'content-type: application/json' \
  -d '{"amount":100,"currency":"JPY","card":"4111111111111234"}'
HTTP/1.1 400 Bad Request
content-type: application/problem+json
{
  "type": "https://api.payments.example/problems/validation-error",
  "title": "Validation Error",
  "status": 400,
  "detail": "Unsupported currency",
  "instance": "/payments",
  "traceId": "f1e2…",
  "errors": { "currency": ["'JPY' is not supported. Use USD, EUR, or GBP."] },
  "timestamp": "2026-06-16T16:30:00Z"
}

A declined card returns 402 with "title": "Insufficient Funds"; an over-limit amount returns 422; an unknown payment returns 404. None of those numbers appear in the handlers.

Testing

The sample’s ErrorMappingTests.cs has one test per mapping, run through an in-process server. Status-code-mapping bugs are exactly the kind that unit tests miss and clients hit, so they are tested at the HTTP boundary:

[Fact]
public async Task A_declined_card_maps_to_the_custom_402()
{
    using var server = PaymentsTestHost.Create();
    using var client = server.CreateClient();

    var response = await client.PostAsJsonAsync("/payments", Body(100m, "USD", "4111 1111 1111 0000"));

    response.StatusCode.Should().Be(HttpStatusCode.PaymentRequired);          // 402, via CustomExceptionMappings
    var problem = await response.Content.ReadFromJsonAsync<ProblemDetailsResponse>();
    problem!.Title.Should().Be("Insufficient Funds");
}

The two-flavours-of-400 distinction is pinned explicitly — the pipeline validator yields no errors map, the handler’s Web ValidationException does — because that difference is easy to break and confusing to debug:

// validator path: 400, no field errors
problem.Errors.Should().BeNull();
// handler path: 400 with field errors
problem.Errors.Should().ContainKey("currency");

And one test asserts the cross-cutting guarantees every error shares — application/problem+json, a traceId, and the request path (never the query string) as instance:

response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json");
problem.TraceId.Should().NotBeNullOrEmpty();
problem.Instance.Should().Be("/payments/pay_missing");
dotnet test samples/004-errors-and-http/Payments.Api.Tests

Production considerations

  • Keep IncludeExceptionDetails = false in production. This is the single most important setting. On, it exposes every exception message (handy in dev, a leak in prod). Off, 5xx are generic and only safe 4xx messages are shown. Bind it to the environment, and never ship it hard-coded to true.
  • traceId is your support workflow. Surface it in client errors and log it on every failure (the middleware does the latter). “Send us the traceId” turns an unreproducible bug report into a one-line log query. Wire it into your tracing backend (article 021) so it joins the distributed span.
  • The database maps itself — once you have one. The default mapper already turns a unique-constraint violation (Postgres SQLSTATE 23505) into a 409, and an optimistic-concurrency DbUpdateConcurrencyException into a 409, by full type name with no EF Core dependency in the Web package. When you add the persistence stack (article 005), concurrency conflicts and duplicate keys get sensible status codes for free — but prefer throwing a domain ConflictException where you can express the intent yourself.
  • Don’t let the query string into errors or logs. The middleware uses the path only for instance precisely because query strings carry tokens and PII. Hold that line in your own logging too.
  • Version the error contract like any contract. The type URI identifies the problem class; clients may branch on it. Treat adding a new type as additive and changing an existing status/type as a breaking change.
  • Map third-party failures deliberately. A flaky downstream should surface as 503 (ExternalServiceException), not bleed through as a 500 with the vendor’s stack trace. Catch at the integration boundary and rethrow the semantic exception.

Common mistakes

  • Returning status codes from handlers. return Results.NotFound() drags transport into the domain and guarantees inconsistency across endpoints. Throw NotFoundException; let the mapper decide the number.
  • Shipping IncludeExceptionDetails = true. The most common production leak. Gate it on environment.
  • Using the wrong exception for the meaning. Throwing BusinessRuleException for “not found” (→ 422 instead of 404) or ValidationException for a conflict confuses clients and breaks their retry logic. Match the exception to the semantics.
  • Catching everything in the handler and returning a 200 with an error field. This defeats every client’s error handling, hides failures from monitoring, and turns 4xx/5xx into silent 200s. Let exceptions propagate to the mapper.
  • Putting secrets in messages. throw new NotFoundException($"No user with token {token}") echoes the token into a client-visible, safe-to-expose message. Messages are part of the response; treat them like it.
  • Catching Exception in middleware before Relay’s handler. Your own broad catch upstream can swallow exceptions before the mapper sees them, producing inconsistent ad-hoc responses. Let Relay’s handler be the one place that turns exceptions into HTTP.

Tradeoffs

Benefits. One consistent, documented error contract across every endpoint; internals redacted by default; handlers free of transport concerns; your own exceptions mappable without inheritance; and a correlation id threaded from client to log.

Costs. A fixed exception vocabulary the team must learn and apply correctly; a redaction policy to configure per environment; and a small layer of “magic” (the status code is decided elsewhere than the handler) that newcomers must be shown once.

Alternatives

  • ASP.NET Core’s ProblemDetails / IExceptionHandler. The platform has its own ProblemDetails support and exception-handler abstraction; you can hand-write a mapping with it. Relay’s version is a pre-built, type-driven mapping integrated with its exception hierarchy, correlation id, and redaction policy — less to assemble, and consistent with the rest of the framework. If you are not using Relay’s handlers, the platform primitives are a reasonable build-it-yourself path.
  • Per-endpoint try/catch returning Results.*. Maximally explicit, maximally inconsistent, and maximally repetitive. It drifts the instant two people write two endpoints. Avoid for anything beyond a one-endpoint toy.
  • Let everything 500 and read the logs. Viable only for an internal tool with one trusted user. For anything a client integrates against, undifferentiated 500s are a non-contract.

Objectively, centralised type-driven mapping wins wherever error responses are a contract; the alternatives win only when there is effectively no external client to keep a contract with.

Lessons from production systems

  • The error contract is discovered by clients at the worst time. Teams that treat errors as an afterthought ship inconsistent shapes, and every client integration pays for it in support load. The teams whose APIs are pleasant to integrate decided the error shape on day one — which is exactly what adopting this from the first endpoint gives you for free.
  • IncludeExceptionDetails = true in production is a recurring incident. It leaks connection strings, internal hostnames, and stack traces. Make it environment-bound and, ideally, assert in a test that production configuration leaves it off.
  • traceId pays for itself the first week. The first time a customer quotes a traceId and you find the exact failing request in seconds, the feature has justified its existence. Without it, “it failed sometimes yesterday” is unactionable.
  • Most “weird 500s” are a missing semantic exception. When an operation throws a raw InvalidOperationException that maps to 500, the fix is almost always to throw the right RelayException (or add a custom mapping) so the client gets a 4xx it can act on. A 500 should mean “we have a bug,” not “you sent something we don’t support.”

Should you use this?

Situation Recommendation
Any HTTP service consumed by clients you don’t control Strong fit — the error contract is public; make it consistent
You have domain exceptions with natural HTTP meanings Strong fit — throw meaning, map centrally
You need a non-standard error envelope Use the mechanism, supply your own IExceptionMapper/format
A pure background worker / console / consumer Avoid — no HTTP surface to map onto
A throwaway internal tool with one trusted user Optional — consistency matters less; still cheap to add

Next steps

Everything so far has lived in memory: the catalog, the aggregate, the tickets, the payments. That is about to change. The next article makes state durable by storing the stream of events an aggregate produces and rebuilding it by replay — event sourcing — using the very same apply-only aggregate you built in article 002. The transactional pipeline you have heard referenced repeatedly finally comes online.

➡️ 005 — Event Sourcing Basics (planned — see the coverage plan)