ADR-0010: Domain - Error Code Sealed Record Hierarchy
Context and Problem
Section titled “Context and Problem”Suppose an API response returns a "NotFound" error. From the error string alone, it is impossible to distinguish whether this is a domain error because the order does not exist, or an adapter error because an external payment service returned 404. Even when a monitoring dashboard aggregates "NotFound" occurrence counts, domain issues and infrastructure issues are mixed together, making meaningful analysis impossible.
The problems with string-based error management do not stop there. If one developer writes "NotFound" and another writes "Notfound", the same error is represented by different strings, breaking consistency. Such typos are not caught at compile time and lurk until runtime, manifesting as matching failures in switch branches that handle specific errors. Since business rule violations (domain), authorization failures (application), and external service outages (adapter) are errors of different natures, the error type itself must structurally include layer information and context.
Considered Options
Section titled “Considered Options”- Per-layer sealed record hierarchy + automatic error code generation
- Enum-based error types
- String constants
- Exception class hierarchy
- Single ErrorType (no layer distinction)
Decision
Section titled “Decision”Chosen option: “Per-layer sealed record hierarchy + automatic error code generation”, to represent errors through the type system instead of strings, blocking typos and duplicates at compile time and enabling immediate identification of error origin from the code itself.
- DomainErrorType: Defines 27 domain error types as sealed records, including
NotFound,InvalidState,InvalidTransition, andDuplicateValue. The 27 types are the result of cataloging recurring domain error patterns from actual business scenarios. - ApplicationErrorType: Defines application layer errors such as
Unauthorized,Forbidden, andConflict. - AdapterErrorType: Defines adapter layer errors such as
ExternalServiceFailureandDatabaseError. - Error code format: Structured codes in the format
{Layer}.{Context}.{Name}— such asDomain.Order.InvalidTransitionandApplication.Auth.Unauthorized— are automatically generated, enabling immediate identification of the layer and originating Aggregate from just the error code in logs. - Factory: The
DomainError.For<T>()method automatically extracts Context information from the generic typeTto generate error codes, eliminating manual string assembly.
Consequences
Section titled “Consequences”- Good, because using
DomainErrorType.NotFoundmeans a typo like"Notfound"is immediately caught as a compile error, structurally preventing runtime matching failures. - Good, because
switchexpressions on the sealed record hierarchy display unhandled error types as compile warnings, ensuring exhaustive handling of all error cases. - Good, because when
Domain.Order.InvalidTransitionappears in logs, “state transition failure in the Order Aggregate at the domain layer” can be immediately understood from the error code alone. - Good, because
DomainError.For<Order>()automatically extracts the Context (Order) from the generic type, eliminating the need to manually compose error code strings. - Bad, because the initial design and classification of per-layer sealed record hierarchies (27 DomainErrorType variants + ApplicationErrorType + AdapterErrorType) requires significant investment.
- Bad, because when new domain error patterns emerge, new types must be added to the sealed record hierarchy, and existing
switchexpressions must be updated with the corresponding cases.
Confirmation
Section titled “Confirmation”- Verify through architecture rule tests that all error types belong to their respective layer’s sealed record hierarchy.
- Verify through unit tests that error code formats follow the
{Layer}.{Context}.{Name}pattern.
Pros and Cons of the Options
Section titled “Pros and Cons of the Options”Per-Layer Sealed Record Hierarchy + Automatic Error Code Generation
Section titled “Per-Layer Sealed Record Hierarchy + Automatic Error Code Generation”- Good, because representing errors as types like
DomainErrorType.NotFoundblocks typos and case-sensitivity mismatches at compile time. - Good, because
switchexpressions on sealed records alert unhandled cases as compile warnings, preventing missed error handling. - Good, because structured error codes in the
Domain.Order.InvalidTransitionformat are used consistently across log searches, Grafana dashboard filters, and API responses. - Good, because the
IHasErrorCodeinterface unifies domain/application/adapter errors under the same format ({Layer}.{Context}.{Name}), enabling cross-layer error handling pipelines. - Bad, because there is maintenance cost in initially designing the 27 DomainErrorType variants + ApplicationErrorType + AdapterErrorType hierarchy and extending it when new error patterns emerge.
Enum-Based Error Types
Section titled “Enum-Based Error Types”- Good, because enum members like
DomainError.NotFoundprevent typos, making them safer than strings. - Bad, because enum members cannot carry properties, so context information like
FromStateandToStateforInvalidTransitioncannot be conveyed alongside the error, requiring additional classes. - Bad, because even separating
DomainErrorandApplicationErrorenums, they implicitly convert tointin method signatures, making layer-level type distinction practically weak. - Bad, because adding new error types to existing enums affects all
switchstatements referencing the enum, violating the Open-Closed Principle.
String Constants
Section titled “String Constants”- Good, because
const string NotFound = "NotFound";is the simplest definition, requiring no separate type design. - Bad, because when
ErrorCodes.NotFoundand"NotFound"literals are mixed, typos in places not using the constant are not caught at compile time. - Bad, because including layer/context information in strings requires relying on naming conventions like
"Domain.Order.NotFound", which cannot be enforced. - Bad, because changing an error code string means IDE “Rename” refactoring does not work, requiring manual full-text search across the entire codebase.
Exception Class Hierarchy
Section titled “Exception Class Hierarchy”- Good, because hierarchies like
OrderNotFoundException : DomainExceptionalign with the exception handling pattern familiar to .NET developers. - Bad, because
try-catch-based control flow is fundamentally incompatible withFin<T>’sMap/Bindpipeline, mixing two error handling paradigms in the codebase. - Bad, because .NET exceptions have high stack trace capture costs, causing unnecessary performance degradation when used for “expected failures” like business rule violations that occur frequently.
- Bad, because ADR-0002 decided to represent failures with
Fin<T>instead of exceptions, so defining error types as exception classes directly conflicts with that existing architecture decision.
Single ErrorType (No Layer Distinction)
Section titled “Single ErrorType (No Layer Distinction)”- Good, because all errors belong to a single
ErrorTypehierarchy, keeping the structure simple with low learning cost. - Bad, because the error type alone cannot distinguish whether
NotFoundmeans “order not in DB” (domain) or “external API returned 404” (adapter), mixing domain and infrastructure issues in monitoring. - Bad, because per-layer HTTP status code mapping like “return 400 for domain errors, 502 for adapter errors” is impossible with error type branching alone.
Related Information
Section titled “Related Information”- Related spec:
spec/04-error-system - Related API:
DomainError.For<T>()factory - Related ADR: ADR-0002: Represent Failures with Fin Types Instead of Exceptions