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).
Revised 2026-08-26 (ErrorType.Unexpected, severity-ranked status selection for aggregated
failures, and the full combinator surface).
Revised 2026-08-27 (v1.164.0): the UI layer deviation is retired. MMCA.Common.UI services
return Result end to end instead of throwing, the severity ranking is hoisted into
MMCA.Common.Shared so the HTTP and gRPC edges classify one aggregate identically, and the round
trip back into Result is a shipped reader rather than a per-service convention.
Revised 2026-08-31: the catch-all exception handler now maps CrossTenantWriteException to HTTP 400
ahead of its 500 fallback, so a write refused at the tenant boundary reads as a caller fault.
Revised 2026-09-03: the error Code is gated as a per-repo public vocabulary (one literal code, one
owning type) by an IL-reading architecture rule.
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,Unexpected. The domain never names an HTTP status.Unexpectedis the one category reserved for a genuine server-side fault (the request was well formed and permitted, the server could not complete it,MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ErrorType.cs:36-41): it maps to HTTP 500 (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:30) and to gRPCInternal(MMCA.Common/Source/Presentation/MMCA.Common.Grpc/ResultGrpcExtensions.cs:46), and it is explicitly not for business-rule violations, which is what keeps the other eight categories caller-fixable (ErrorType.cs:38-39, factory at.../Shared/Abstractions/Error.cs:114-115).Domain factory methods and mutators return
Result<T>; application command/query handlers thread results through combinators instead oftry/catch. The surface is:- Lifting without naming a factory. An
Errorconverts implicitly to a failedResult(.../Shared/Abstractions/Result.cs:43) orResult<T>(:233-237), and a value converts implicitly to a successfulResult<T>(:249), so a guard clause writesreturn someError;and a happy path writesreturn theValue;.Result.FromErroris the named alternate for the non-generic conversion (:49-53); the inheritedResult.Failure<T>/Result.Success<T>factories are the named alternates on the generic type (:229-232,:245-248). - On
Result<T>:Match(:260-268) /MatchAsync(:355-365), exactly one branch running in both;Map(:276-280),Bind(:302-306) /BindAsync(:289-293),Tap(a side effect on the success value, returning the same instance,:314-324) andEnsure(fail the chain with a suppliedErrorwhen the value does not satisfy a predicate,:334-345). - On the non-generic
Result:Match(:155-161),Bind(:187-191) andOnFailure(:169-179), so a valueless step composes the same way instead of forcing anIsFailurecheck. - On a pending
Task<Result<T>>:ResultExtensions(.../Shared/Abstractions/ResultExtensions.cs:10) carriesBindAsyncover both aTask-returning and a synchronous binder (:20-29,:39-48),MapAsync(:58-67),TapAsync(:77-91) andMatchAsync(:102-113), so an asynchronous pipeline composes end to end without an intermediateawaitand its temporary local between every step. Each awaits the incoming task once and delegates to the same instance combinator, so the short-circuit behavior is identical (:3-9). - Every combinator short-circuits: a failed result never invokes the delegate it was handed
(
Result.cs:279,:292,:305,:339-342).
- Lifting without naming a factory. An
The transport mapping lives only at the edge.
ApiControllerBase.HandleFailure()(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:35-60) maps anErrorTypeto an HTTP status through aFrozenDictionary(ErrorHttpMapping.cs:20-31, resolved atApiControllerBase.cs:48) and returns an RFC 9457 ProblemDetails body carrying all errors (:50-58, projection atErrorHttpMapping.cs:61-69). gRPC does the equivalent over the wire (GrpcResultExceptionInterceptor, ADR-007) from a mirrored table (Validation/Invariant/FailuretoInvalidArgument,NotFoundtoNotFound,ConflicttoAborted,UnauthorizedtoUnauthenticated,ForbiddentoPermissionDenied,UnprocessableEntitytoFailedPrecondition,UnexpectedtoInternal:ResultGrpcExtensions.cs:34-46), so callers keep programming againstResult<T>across a process boundary.A failure carrying several errors takes the status of the most severe one, never of the first, and both edges rank it the same way.
Result.Combineaggregates errors in evaluation order (Result.cs:124-145), so a positional rule let an incidental validation failure downgrade a real 403 or 500 to a 400. The ranking is one table in the Shared layer,ErrorTypeSeverity(MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ErrorTypeSeverity.cs:30), most to least severe:Unexpected(70) >Unauthorized(60) >Forbidden(50) >Conflict(40) >NotFound(30) >UnprocessableEntity(20) >Invariant/Validation/Failure(one shared rank of 10,:39-47), with the reasoning for each rank written onto the type itself (:16-25).MostSeverekeeps the earliest error on a tie (:69-96, strict>at:88) and an unmapped category ranks lowest, so a category added toErrorTypewithout a rank can never silently outrank a real 403 or 500 (:57, stated at:50-53). The scan is index-based rather than a LINQMaxBybecause it runs on every failure response (:83-84).It lives in Shared rather than in one presentation package because two edges consume it. HTTP resolves the status from
ErrorTypeSeverity.MostSevere(.../MMCA.Common.API/Middleware/ErrorHttpMapping.cs:50-51, called fromApiControllerBase.HandleFailureatApiControllerBase.cs:48) and gRPC'sToRpcExceptiondoes the same (.../MMCA.Common.Grpc/ResultGrpcExtensions.cs:116-118, argued at:100-107). Only the status is ranked: every error still travels in the ProblemDetailserrorsarray (ErrorHttpMapping.cs:61-69) and in the gRPC trailers aserror-{i}-code/-message/-type(ResultGrpcExtensions.cs:128-130). Ranking in one place is what makes the two transports agree; the previous arrangement, with the table insideMMCA.Common.API, left gRPC classifying an aggregate by its first error while HTTP classified it by its worst.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:135-147, registrations at lines 140-144):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:67-80, status set at:69). It maps exactly one exception by type before that fallback: aCrossTenantWriteException(the save-time tenant-boundary rejection,.../MMCA.Common.Infrastructure/Persistence/Interceptors/CrossTenantWriteException.cs:24) is a caller fault rather than a server fault and is answered with HTTP 400, logged at warning rather than error because a tenant-scoped API refusing an untenanted write is routine (:47-64, the warning at:51, the 400 at:53). The special case sits inside the catch-all rather than in its own handler because the exception derives fromInvalidOperationException, so no handler ahead of this one claims it, and every other save-time invariant failure of that family still ends at the 500 (:13-19). The response body names no tenant id and no entity type: echoing either would tell an unauthorized caller which tenant owns the row it just tried to write, so the detail is a fixed string and the full failure stays in the log (:31-39).
The client half: the UI layer returns Result too (2026-08-27)
Until v1.164.0 this record held everywhere except the one layer a user actually sees. A UI service
called the API, pulled the domain wording out of the ProblemDetails body, and rethrew it as a
DomainInvariantViolationException for the page to catch: the pattern was inverted at the last hop,
and every page paid for it with a try/catch around a call whose failure was entirely expected.
That deviation is retired. Every HTTP-typed client service in MMCA.Common.UI now returns
Result / Result<T>, and ServiceExceptionHelper is deleted rather than deprecated.
Two halves make a service method honestly typed, and they are deliberately separate types:
ProblemDetailsResultReaderconverts a response. (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:58.) It is the exact reverse ofApiControllerBase.HandleFailure:ReadAsync(response, ct)answers a valuelessResult(:223) andReadAsync<T>(response, options, ct)deserializes a 2xx body or parses the failure (:257), with the pureParseProblemDetails(status, body)core testable against captured payloads (:153). It understands four payload shapes (:20-49): the MMCA error array, wherecode,message,type,sourceandtargetall round-trip andtypeparses straight back intoErrorType(:319-354, the parse at:403-408), the ASP.NET Core validation dictionary (:356-382), plain ProblemDetails with noerrorsextension, and a non-JSON or empty body, each of the last three synthesizing one error codedHttp.{status}(:410-414). Property lookup is case-insensitive on purpose, because a hand-built PascalCase payload would otherwise silently lose every field (:438-443). It lives inMMCA.Common.Sharedand uses nothing beyond the BCL, because the consumer isMMCA.Common.UI, which references Shared only (:14-19).HttpResultExecutorconverts the absence of one. (.../MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:31.) It does not make the request: it takes the caller's whole send-and-read operation as aFunc<Task<Result>>/Func<Task<Result<T>>>and wraps it (:52,:87), so the two halves compose without either knowing the other's shape. A refused connection, a DNS failure, a dropped socket, an unreadable body (HttpRequestException,IOException,JsonException) becomes a failedResultcodedHttp.TransportFailure(:121-127, code at:34), and anHttpClienttimeout becomesHttp.Timeout(:129-130, code at:37). Anything else is a genuine programming fault and keeps travelling as an exception (:118-119). The exception's own text goes onError.Source, never onMessage, because it is diagnostic detail that is neither localizable nor safe to render (:124-127).
OperationCanceledException is the one exception that still crosses the boundary, and that is the
decision, not an omission. When the caller's own token is why the operation stopped, the
cancellation is rethrown (HttpResultExecutor.cs:65-68, :100-103, argued at :17-23): a disposed
component or a superseded grid fetch owns its own cancellation and must not have it handed back as
an error to render. A client timeout raises the same exception type with the token not cancelled,
and that one does become a failure (:69-72). The token is also checked before the call, so an
already-abandoned request never reaches the network (:59, :94).
Pages branch; they do not catch. ResultUiExtensions
(.../MMCA.Common.UI/Common/ResultUiExtensions.cs:63) is the page-side idiom, written once so no
page hand-rolls it: TryGetValue unwraps inside a conditional the way Dictionary.TryGetValue
does, deciding the failing branch on IsFailure rather than on the value so a value-type default is
not mistaken for success (:82-97, the overload handing the errors back at :116);
OnFailureSetError pushes the composed message into a page field (:226) and NotifyOnFailure
raises it as exactly one snackbar, never one per error (:265); and HasErrorType with
IsNotFound / IsUnauthorized lets a page turn a 404 into an empty state and a 401 into a redirect
instead of an alert (:303-323). Messages are localized as resource keys with pass-through, so
one call site handles both an API error the server already translated and a client-side error whose
Message is a key (:17-23, :325-334, ADR-027), and they are deduplicated and ordered by the
same ErrorTypeSeverity rank the edges use, so a real 403 leads and an incidental validation
message never buries it (:145-159, the ordering at :155). The shared ErrorSummary component
renders the same list as one deduplicating MudAlert, taking a failed Result and the
MudForm.Errors shape together and rendering nothing at all when there is nothing to say
(.../MMCA.Common.UI/Components/Forms/ErrorSummary.razor:8, both shapes merged at :81-102, one message
inline and several as a list so a screen reader announces them as several items, :15-29).
A component that shows a retry needs the failure, not an exception.
MobileInfiniteScrollList takes one page fetcher, FetchPageResult, a required delegate returning
Task<Result<(IReadOnlyList<TItem> Items, int TotalItems)>>
(.../MMCA.Common.UI/Components/Lists/MobileInfiniteScrollList.razor.cs:37-39), and renders the failure's
localized message beside its inline retry affordance (:259-268, the message read from the failed
Result at :262). A tuple-returning delegate is not offered beside it: a tuple has no way to carry
a failure at all, so the component would be left showing a Retry button that cannot name what it is
retrying. The delegate is checked at initialization, so a call site that omits it throws instead of
rendering as a load failure that can never succeed (:93-105).
The error Code is a public vocabulary, gated for uniqueness (2026-09-03)
Code is the half of a failure that crosses the wire verbatim (ErrorHttpMapping.cs:61-69) and the
key ADR-027 localizes the message by, so a client switches on Order.NotFound and a support ticket
quotes it. Two modules that both ship Item.Invalid make that vocabulary ambiguous, and the
ambiguity only surfaces in production
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Contracts/ArchitectureRules.ErrorCatalog.cs:31-37).
The catalog is therefore frozen the way ADR-010 froze integration-event schemas and ADR-015 Section B
froze the .proto contracts. A Mono.Cecil IL scan reads every Error factory call site (:169,
:206-208, the recognized members at :17-29 and :218-222) across a repo's per-module Domain and
Application assemblies only (:184-189); ErrorCodesAreUnique (:62) fails the build when one
literal code is constructed by more than one declaring type (:73), and
ErrorCodesUseAnAllowedPrefix (:102) requires the owning prefix. A code built at run time is
reported as UNVERIFIABLE rather than passed or failed (:210-213, :151-155). Consumers subclass
ErrorCatalogTestsBase (.../Bases/Contracts/ErrorCatalogTestsBase.cs:20, the three facts at :46,
:50, :54) and allowlist a deliberately shared code (:35-36) rather than rename a shipped one:
Store (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Contracts/ErrorCatalogTests.cs:19)
and ADC (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Contracts/ErrorCatalogTests.cs:20)
do; MMCA.Common ships no module catalog and self-tests the rules against fixtures instead
(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Contracts/ErrorCatalogFitnessTests.cs:15).
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. The implicit conversions are what keep the ceremony proportional: a guard clause returns theErroritself (Result.cs:37-43), so the cheapest thing to write is also the correct one. - An aggregate answers with its worst problem. Ranking the categories makes the status independent
of the order invariants happen to be evaluated in, which is the one thing a caller cannot see and
the one thing
Result.Combinemakes arbitrary (ErrorHttpMapping.cs:40-47, restated on the enum itself atErrorType.cs:5-8). - 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. - The layer with the most expected failures had the least Result. A UI service call fails for
every reason this record was written about: the input was rejected, the record is gone, the caller
signed out. Converting the response back into an exception at the last hop meant the one layer
whose whole job is rendering failure was the one layer that had to catch, and it made a
business-rule violation and a dropped socket arrive the same way. Returning
Resultthere closes the round trip: the category the domain produced is the category the page branches on. - Ranking in Shared rather than at each edge is what makes "the same aggregate" mean something. A ranking table copied into two presentation packages is two tables, and the gRPC edge proved it by classifying by position while HTTP classified by severity. One table in the layer both edges already reference removes the possibility rather than the habit.
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. - One status still has to stand for a whole list. Severity ranking removes the ordering dependency
but not the collapse: a failure carrying a 403 and a 400 answers 403, and the 400 is visible only
to a client that reads the
errorsarray (ErrorHttpMapping.cs:61-69). The three 400-mapped categories share one rank (ErrorTypeSeverity.cs:45-47), so among them the earliest error still wins. - The reverse mapping is lossy on 400, and only on 400. A client reading an MMCA error array
gets the original
ErrorTypeverbatim, because the edge writesTypeas a field (ErrorHttpMapping.cs:61-69) and the reader parses it back (ProblemDetailsResultReader.cs:346,:403-408). Every other payload shape (a validation dictionary, a plain ProblemDetails, a non-JSON body) has to derive the category from the status code, and the forward map sendsValidation,InvariantandFailureall to 400, so the reverse can only pick one: it picksValidation(:96-106, admitted twice at:50-56and:121-124, pinned byMMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Http/ProblemDetailsResultReaderTests.cs:228). A client that needsInvariantdistinguished fromValidationmust call an endpoint that emits the error array, which everyApiControllerBasefailure does but an escaped exception handled by the handler chain does not. - The UI's Result surface is a breaking change for every consumer page. Retiring the deviation
moved
MMCA.Common.UI's public API by 41 removals and 98 additions in one release, so every page that wrapped a service call intry/catchhad to be rewritten to branch. That is the cost of having deferred the conversion: the deviation was cheap to keep and expensive to remove, and it grew with every page added while it stood. - An exception reaching a page still costs it the reason. Where a failed
Resultcarries a localized message the page can render, an exception that escapes a consumer-supplied fetcher falls back to a generic resource string, because its own text is neither translatable nor safe to render (MobileInfiniteScrollList.razor.cs:248-268, the fallback at:266). ReturningResultis what makes the specific message available at all; nothing forces a call site to produce one. - 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:140-144).
Related
ADR-007 (Result over the wire via gRPC, the second edge the shared severity
ranking now serves),
ADR-014 (the decorator pipeline returns Result.Failure to
short-circuit a command before it reaches the handler),
ADR-094 (the client base hierarchy whose dispatch method now ends
in a Result instead of a rethrow, and which owns the retry and idempotency behavior around it),
ADR-027 (the localization contract the page-side helpers honor: an API
message arrives already translated, a client-side one is a resource key, and pass-through lookup
serves both from one call site).