ADR-0009: Domain - Value Object Class/Record Dual Hierarchy
Context and Problem
Section titled “Context and Problem”Suppose you are implementing an Email Value Object. The key concern is wrapping a string value with format validation (@ included, max length, etc.) and determining that two Email instances with the same string are equal — value equality. The OrderStatus Value Object, on the other hand, has an entirely different nature. It represents a finite set of states like Pending, Confirmed, Shipped, and Delivered, where the key concerns are transition rules that allow Pending -> Confirmed but block Shipped -> Pending, and exhaustive pattern matching via switch expressions that ensure every state is handled.
Email needs Equals/GetHashCode overrides and constructor validation, while OrderStatus needs a sealed hierarchy and C# pattern matching. Attempting to satisfy both requirements with a single base type would either force an unnecessary sealed hierarchy on Email or require OrderStatus to use if-else branches instead of pattern matching, sacrificing expressiveness on one side.
Considered Options
Section titled “Considered Options”- Class hierarchy + Record hierarchy in parallel
- Single Class hierarchy
- Single Record hierarchy
- Interface only (no implementation)
Decision
Section titled “Decision”Chosen option: “Class hierarchy + Record hierarchy in parallel”, because value-wrapping VOs like Email and state-set VOs like OrderStatus require fundamentally different language features, so providing a dedicated hierarchy that maximizes each one’s strengths is necessary.
Class Hierarchy (traditional Value Object):
AbstractValueObject->ValueObject->SimpleValueObject<T>/ComparableSimpleValueObject<T>- Equality comparison (
Equals,GetHashCode) is handled in the base class. SimpleValueObject<T>is for single-value wrapping;ComparableSimpleValueObject<T>for comparable value wrapping.
Record Hierarchy (Discriminated Union):
UnionValueObject<TSelf>- Leverages C# record’s structural equality and
withexpressions. - Uses sealed record inheritance to represent finite state sets.
Consequences
Section titled “Consequences”- Good, because value-wrapping VOs like
EmailandMoneyoptimally leverage automaticEquals/GetHashCodehandling from the Class hierarchy, while state VOs likeOrderStatusoptimally leverage pattern matching from the Record hierarchy. - Good, because
SimpleValueObject<T>handlesEquals,GetHashCode,ToString, and comparison operators in the base class, eliminating repetitive equality comparison code across implementations. - Good, because in the
UnionValueObject<TSelf>-based sealed record hierarchy, C#switchexpressions display unhandled cases as compile warnings when a new state is added. - Bad, because the team must decide “is this VO a Class hierarchy or Record hierarchy?” requiring documentation and sharing of selection criteria (“does it wrap a value, or represent a set of states?”).
Confirmation
Section titled “Confirmation”- Verify through architecture rule tests that Value Objects must inherit from one of the two hierarchies.
- Verify during code reviews that Union Value Object sealed record hierarchies represent complete state sets.
Pros and Cons of the Options
Section titled “Pros and Cons of the Options”Class Hierarchy + Record Hierarchy in Parallel
Section titled “Class Hierarchy + Record Hierarchy in Parallel”- Good, because
EmailinheritsSimpleValueObject<string>for equality and validation, whileOrderStatusinheritsUnionValueObject<OrderStatus>for pattern matching, each leveraging optimal C# features. - Good, because the Class hierarchy provides only equality/comparison and the Record hierarchy provides only sealed inheritance/pattern matching, keeping each hierarchy’s responsibilities clear and simple.
- Good, because C#
switchexpression exhaustiveness checks in the Record hierarchy flag unhandled cases at compile time when a new state is added. - Bad, because the selection criterion “Class for value wrapping, Record for state sets” must be documented and consistently applied during code reviews.
Single Class Hierarchy
Section titled “Single Class Hierarchy”- Good, because all VOs inherit
AbstractValueObject, eliminating the need for base type selection decisions. - Bad, because implementing
OrderStatusas a Class losesswitchexpression exhaustiveness checks, and the compiler cannot catch unhandled branches when a new state is added. - Bad, because representing finite state sets via Class inheritance requires manually restricting inheritance without the sealed keyword, and
if-elseorischecks must be used instead of pattern matching.
Single Record Hierarchy
Section titled “Single Record Hierarchy”- Good, because C# record structural equality (auto-generated
Equals,GetHashCode) can be leveraged across all VOs without separate implementation. - Bad, because custom equality logic like rounding
Amountdecimal places before comparison in complex value types likeMoney(Amount, Currency)cannot be expressed with record’s auto-generatedEquals, requiring separate overrides. - Bad, because record
withexpressions (email with { Value = "new@test.com" }) allow value changes that bypass validation, circumventing the immutability contract.
Interface Only (No Implementation)
Section titled “Interface Only (No Implementation)”- Good, because only an
IValueObjectinterface is defined, and implementations can freely choose class or record. - Bad, because common logic needed by all VOs —
Equals,GetHashCode,ToString, validation — must be repeatedly written in each implementation, and implementation omissions lead to runtime bugs. - Bad, because an interface alone cannot enforce the contract that “all VOs are immutable and guarantee value equality,” potentially resulting in some VOs with missing equality or exposed mutable state.
Related Information
Section titled “Related Information”- Related commit:
5c347e54 - Related spec:
spec/02-value-object - Related tutorial:
Docs.Site/src/content/docs/tutorials/functional-valueobject/