Architecture Decision Record
ADR-013: Result Pattern over Exceptions for Flow Control
Status
Accepted. Revised 2026-07-21 (exception-handler chain / ProblemDetails edge contract documented).
Context
Operations at every layer fail in expected ways: input is invalid, a domain invariant is broken, a requested entity is missing, a uniqueness conflict occurs, the caller lacks permission. There are two common ways to signal those: throw an exception and translate it near the edge, or return an explicit value that the caller must inspect. Using exceptions for expected business outcomes has real costs: the failure is invisible in the method signature, it is easy to forget to catch, it is comparatively expensive on the throw path, and it conflates "the user asked for something we will not do" with "the process is broken."
Decision
Model expected failures as values using Result / Result<T> (MMCA.Common.Shared.Abstractions),
not exceptions.
- A
Resultis either success or failure; a failure carries one or moreErrorrecords (Code,Message,Typeof typeErrorType, optionalSource/Target). ErrorTypeis a transport-agnostic category:Validation,Invariant,NotFound,Conflict,Unauthorized,Forbidden,UnprocessableEntity,Failure. The domain never names an HTTP status.- Domain factory methods and mutators return
Result<T>; application command/query handlers thread results through theMatch(),Map(), andBindAsync()combinators instead oftry/catch. - The transport mapping lives only at the edge.
ApiControllerBase.HandleFailure()maps the first error'sErrorTypeto an HTTP status via aFrozenDictionaryand returns an RFC 9457 ProblemDetails body carrying all errors. gRPC does the equivalent over the wire (GrpcResultExceptionInterceptor, ADR-007), so callers keep programming againstResult<T>across a process boundary. - Exceptions are reserved for the genuinely exceptional: programming errors (null-argument guards) and infrastructure faults (DB / transaction failures) that should abort the request rather than be modeled as a business outcome.
- When an exception does escape to the HTTP edge, the API layer converges it onto the same RFC 9457
ProblemDetails shape via an ordered
IExceptionHandlerchain, so both channels (Result and exception) return one wire contract.AddCommonExceptionHandlers()first registersAddProblemDetails(which stamps arequestIdextension from the request's trace identifier), then registers the handlers in a load-bearing order; ASP.NET Core runs them in registration order and stops at the first handler that reports the exception handled, so most-specific-first placement is the mechanism, not a comment (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:116-127, registrations at lines 121-125):OperationCanceledExceptionHandler(registered first) maps a client-disconnectOperationCanceledExceptionto the non-standard HTTP 499 Client Closed Request, so monitoring can tell an abandoned request apart from a server fault (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/OperationCanceledExceptionHandler.cs:27,32).DomainExceptionHandlermaps aDomainException(a business-rule violation reaching the edge as an exception rather than aResult) to HTTP 400 Bad Request (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/DomainExceptionHandler.cs:27,32).DbUpdateExceptionHandlermaps an EF CoreDbUpdateException(concurrency, unique-constraint, or foreign-key failure) to HTTP 409 Conflict, returning a generic detail so no database schema detail leaks to the client (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/DbUpdateExceptionHandler.cs:28,33,37).ValidationExceptionHandlermaps a FluentValidationValidationExceptionto HTTP 400, grouping the failures by property name into anerrorsextension that matches ASP.NET Core's model-validation shape (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ValidationExceptionHandler.cs:28,33,48-54).GlobalExceptionHandler(registered last) is the catch-all that turns any remaining unhandled exception into HTTP 500 (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/GlobalExceptionHandler.cs:26-28).
Rationale
- Failures are in the signature. A method that can fail returns
Result<T>, so the caller cannot silently ignore the failure path the way an uncaught exception allows. - Category, not status code, at the core.
ErrorTypekeeps Domain and Application transport- agnostic; only the API (or gRPC interceptor) translates it, so the same handler serves REST and gRPC. - Composable. The railway-oriented combinators chain steps without an
IsFailurecheck at every line, and short-circuit on the first failure. - Cheap and predictable. No throw/catch on the common "won't do it" path.
- One wire contract for both channels. Whether a request ends in a
Result.Failuremapped byHandleFailure()or an escaped exception caught by the handler chain, the client receives the same RFC 9457 ProblemDetails body (carrying the sharedrequestIdextension), so consumers parse one error shape regardless of which channel produced it.
Trade-offs
- More ceremony at call sites than letting an exception bubble; the combinators absorb most of it.
- Two error channels coexist (Result for expected, exceptions for exceptional). The boundary is a
judgment call: "could a well-behaved caller reasonably trigger this?" then return a
Result, otherwise throw. - The
ErrorTypeto HTTP mapping is one-directional and first-error-wins for the status code (all errors still serialize into the ProblemDetails body). - The exception-handler registration order is load-bearing. Because ASP.NET Core stops at the first
handler that reports the exception handled, a mis-ordered registration (for example the catch-all
GlobalExceptionHandlerahead of a specific handler) would swallow the more precise status;GlobalExceptionHandlermust stay registered last (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:121-125).
Related
ADR-007 (Result over the wire via gRPC), ADR-014 (the decorator pipeline returns Result.Failure to
short-circuit a command before it reaches the handler).