Architecture Decision Record
ADR-068: Value Objects as Validated Domain Primitives
Status
Accepted (2026-08-07). Revised 2026-08-31 (Money's non-validating construction paths named, Currency's
converter counted among the serialization annotations, Address added to the adopted set, and the Store/ADC
anchors refreshed). Revised 2026-09-03: Event.OrganizerContactEmail became Email?, the third ADC
adoption site, so ADC's Domain now holds no primitive email property and matches Store on that count;
the record also notes why the validator, the length invariant and the column shape were all left
alone, following the Speaker precedent.
Revised 2026-09-25 (the shipped OwnsAddress helper named beside OwnsMoney: Store's Customer.Address
maps through it; the Store, ADC and EntityTypeBuilderExtensions anchors refreshed). As of
2026-10-01 Store follows that call with a second OwnsOne block that overrides the helper's unicode
facet, and the with-expression path around the Money sugar is named (see Revision below).
Context
A domain model has two kinds of small type: the identity of a thing, and a value the thing
carries. ADR-048 recorded the identity half: identifiers stay primitives named through a global-using
alias, and the strongly-typed wrapper struct was considered and rejected because an identifier crosses
EF keys, JSON payloads, OpenAPI schemas, proto messages and URLs constantly, where a wrapper buys
converters at every hop and no invariant in return. ADR-115 later added the wrapper struct as an
opt-in framework capability (its JSON converter factory is registered in AddAPI at
Source/Presentation/MMCA.Common.API/DependencyInjection.cs:65) while keeping the aliases the
default, so ADR-048's verdict still governs identifiers unless a team opts in.
The value half was never recorded, even though the framework ships a full set of them in
Source/Core/MMCA.Common.Shared/ValueObjects/ and both production apps consume them. Left as bare
primitives, an email is a string that every caller re-validates, a price is a decimal whose
currency lives in a second column that nothing keeps in step, and a range is two dates with the
ordering rule restated per aggregate. These are exactly the cases a wrapper earns its cost: the type
exists to make the invariant unforgeable, not to rename a primitive.
So the workspace makes the opposite call for values that it makes for identifiers, and the asymmetry is the decision worth recording.
Decision
Model a domain value that carries an invariant as an immutable record value object with a
Result-returning factory; keep identifiers primitive (ADR-048).
- One abstract record base, and structural equality is the whole of it.
ValueObjectis a memberlesspublic abstract record(Source/Core/MMCA.Common.Shared/ValueObjects/ValueObject.cs:8): noGetEqualityComponents()override, no hand-writtenEquals/GetHashCode, because the record compiler generates equality over the declared properties. Seven sealed types derive from it:Address(Address.cs:16),Currency(Currency.cs:14),DateRange(DateRange.cs:9),DateTimeRange(DateTimeRange.cs:10),Email(Email.cs:16),Money(Money.cs:21) andPhoneNumber(PhoneNumber.cs:16). - The constructor is private; the factory returns
Result<T>.Email.Create(Email.cs:30),PhoneNumber.Create(PhoneNumber.cs:30),Address.Create(Address.cs:69),Money.Create(Money.cs:67),DateRange.Create(DateRange.cs:30) andDateTimeRange.Create(DateTimeRange.cs:31) are the validating entrance, and forEmail,PhoneNumber,Address,DateRangeandDateTimeRangethey are also the only public entrance at all;Currencyis a closed set resolved byCurrency.FromCode(Currency.cs:41). This is ADR-013 applied below the aggregate: an invalid value is a failedResult, never an exception and never a constructed-but-wrong instance.Moneyis the exception, deliberately: it also exposes construction sugar that composes already-valid parts and so skipsCreateentirely,Money.Zero()(Money.cs:142),Money.Zero(Currency)(:147),operator *(:96) andMultiply(:124). The fitness rule below governs theCreatename only and says so in its own contract (ArchitectureRules.Entities.cs:49-51), so nothing stops a value object from shipping such a path. The sugar itself takes no raw string, and theNonesentinel isinternal(Currency.cs:23), but that does not makeCurrencysealed against unchecked values:Currency.Code(Currency.cs:34) andMoney.Amount/Money.Currency(Money.cs:31,35) are publicinitaccessors, shipped in the public API with the record clone method (Source/Core/MMCA.Common.Shared/PublicAPI.Shipped.txt:550,556,558,732,738), so an externalwithexpression (Currency.Usd with { Code = "XYZ" },Money.Zero() with { Amount = 5m }) produces a currencyFromCodenever checked or a non-zeroNone-currencyMoneywithout going throughCreate. - That factory shape is fitness-enforced, not conventional.
ArchitectureRules.DomainFactoriesReturnResult(Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Domain/ArchitectureRules.Entities.cs:53-79) walks every concrete class in the Domain and Shared layers and fails the build when a public staticCreateexists with no overload returningResult<TSelf>, generalizing the aggregate-root rule to value objects (ADR-015). - Shared constraints live in a static
*Invariantsclass beside the type.EmailInvariants.EnsureEmailIsValid(EmailInvariants.cs:23),PhoneNumberInvariants.EnsurePhoneNumberIsValid(PhoneNumberInvariants.cs:26) andAddressInvariants.EnsureAddressLine1IsValid(AddressInvariants.cs:50, composed throughResult.CombineatAddress.cs:77-78) hold the checks, and the same classes own the length constants that EF configurations and FluentValidation validators reuse instead of restating:EmailInvariants.MaxLength(EmailInvariants.cs:14), the phoneMinLength/MaxLengthpair (PhoneNumberInvariants.cs:14,17), and the six address field lengths (AddressInvariants.cs:12-27). The split is applied where the constraint is shared, not universally:Money,Currency,DateRangeandDateTimeRangekeep their checks inline in the factory (Money.cs:71-72,Currency.cs:43-49,DateRange.cs:31-35,DateTimeRange.cs:32-36). - Two EF mapping shapes, chosen by whether adoption is a schema change. A multi-field value maps as
an owned type: the shipped
OwnsMoneyhelper (Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeBuilderExtensions.cs:58) flattensMoneyinto a decimal amount column plus a three-character non-unicode ISO 4217 code column (:69-83) and sets the navigation's requiredness from one parameter (:85). Its siblingOwnsAddress(:125, shipped in v1.192.0,MMCA.Common/CHANGELOG.md:1098) flattensAddressinto six non-unicode columns whose lengths come fromAddressInvariantsand of which onlyAddressLine1is required (:134-166), names them from an optional prefix (AddressLine1,AddressCity, and so on by default,:127, joined at:185-188) and defaults the navigation to optional (:128, applied at:168). A single-string value maps throughHasConversioninstead, so the backing column stays a plain string column and adopting the value object on a property that used to be astringis not a migration:EmailValueConverterandNullableEmailValueConverter(Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EmailValueConverter.cs:33,60),PhoneNumberValueConverterandNullablePhoneNumberValueConverter(PhoneNumberValueConverter.cs:33,61). Column facets stay at the call site. Currency.Noneis a sentinel, and materialization never yields null. The sentinel isinternal static readonly Currency None = new(string.Empty)(Currency.cs:23);Money.Zero()carries it (Money.cs:142),Money.Createrejects it so an external caller must always name a real currency (Money.cs:71-72), and addition treats it as the identity element so a zero seed can accumulate into any currency (Money.cs:131-137). Because the write leg can therefore persist an empty code,OwnsMoney's read leg falls back to the sentinel rather than a null-forgiving.Value!(EntityTypeBuilderExtensions.cs:26,78, contract documented at:38-45), and the fallback is regression-covered (Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Configuration/OwnsMoneyTests.cs:106).- Every serialization boundary is declared explicitly.
Money,Email,PhoneNumberandAddressare[DataContract]with ordered[DataMember]members (Money.cs:20,30,34,Email.cs:15,19,PhoneNumber.cs:15,19,Address.cs:15,19-40) and each carries a private[JsonConstructor]round-trip constructor (Money.cs:51,Email.cs:22,PhoneNumber.cs:22,Address.cs:42) so a materializer rebuilds the value without reopening the factory;AddAPIregisters both the JSON converter and the XMLDataContractSerializerformatters (Source/Presentation/MMCA.Common.API/DependencyInjection.cs:54,67).Currencyinstead serializes as its bare code through a converter attached to the type itself (Currency.cs:13,73) with a matching API-layer converter (Source/Presentation/MMCA.Common.API/JsonConverters/CurrencyJsonConverter.cs:12), so non-MVC paths (cache, outbox, integration events, typed clients) fail the same way model binding does.DateRangeandDateTimeRangecarry no serialization annotations. - gRPC is mapped by hand, not inferred.
Moneycrosses a service boundary as a purpose-builtMoneyV1message with a string amount (proto has no decimal) and a currency code (MMCA.Store/Source/Services/MMCA.Store.Catalog.Contracts/Protos/product_variants.proto:115,117,120), translated byMoneyFromWire(MMCA.Store/Source/Services/MMCA.Store.Catalog.Contracts/ProductVariantServiceGrpcAdapter.cs:190) andMoneyToWire(:221).MoneyFromWirehonors the empty-code sentinel only when the amount is also zero (:199-204) and returns null for a malformed entry, which the calling loop skips rather than failing the whole batch (:121-125). - Adoption is real but partial. Store maps
ProductVariant.Price(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Domain/Products/ProductVariant.cs:26) withOwnsMoney(.../Catalog.Infrastructure/Persistence/EntityConfiguration/ProductVariantConfiguration.cs:39), andOrder.Total(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Domain/Orders/Order.cs:58) is seeded withMoney.Zero()in the private constructor (:171) and accumulated throughMoney.Add(:220, assigned back at:224), mappedrequired: false(OrderConfiguration.cs:39) alongsideOrderLine.UnitPrice(OrderLineConfiguration.cs:28). Store Identity typesCustomer.Addressas the frameworkAddress(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Customers/Customer.cs:40) and mapsCustomer.EmailthroughEmailValueConverter(CustomerConfiguration.cs:33) withCustomer.Addressthrough the shippedOwnsAddresshelper under its default prefix (CustomerConfiguration.cs:42), followed by a secondOwnsOneblock on the same navigation that marks all six columnsIsUnicode()and so overrides the helper's non-unicode facet while keeping its names, lengths and requiredness (:47-55), andUser.Emailthe same way asCustomer.Email(UserConfiguration.cs:24). ADC typesUser.EmailasEmail(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:52, validated throughEmail.Createat:207and passed to the constructor at:221, with the same pair repeated on the social-login path at:255,266) with the same converter (.../Identity.Infrastructure/.../UserConfiguration.cs:21) and two optional Conference emails throughNullableEmailValueConverter: a speaker's (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:31, mapped at.../Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerConfiguration.cs:43) and an event's organizer contact (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:59, validated throughEmail.Createon both the create and update paths (:186-193,:262-269) and assigned at:144,288, mapped at.../Conference.Infrastructure/Persistence/EntityConfiguration/Events/EventConfiguration.cs:60-62). With that third site ADC's Domain carries no primitive email property left, matching Store. Two details are worth recording because they look like leftovers and are not: the column is unchanged (nvarchar(255), still nullable) so the conversion needed no migration, andEventOrganizerContactEmailRules(.../Conference.Application/Events/Validation/EventValidationRules.cs:59, included at:182) plusEventInvariants.OrganizerContactEmailMaxLength(.../Conference.Domain/Events/EventInvariants.cs:38) were both kept, exactly as the Speaker precedent kept its own: the request-side validator still guards the wire string before it reachesEmail.Create, and the length invariant still drives the column width. The DTO maps back out throughEventDTOMapper.NullableEmailToString(.../Conference.Application/Events/DTOs/EventDTOMapper.cs:55), the twin of the speaker mapper's (.../Speakers/DTOs/SpeakerDTOMapper.cs:49).Money,EmailandAddressare the three that got adopted, withCurrencyriding along insideMoney: no code underMMCA.Store/SourceorMMCA.ADC/SourceusesPhoneNumber,DateRangeorDateTimeRange, and MMCA.Helpdesk adopts none of them at all.
Rationale
- The invariant belongs to the type, not to every caller. A
stringemail can be validated in one handler and not the next; anEmailcannot exist unvalidated, because the only entrance is a factory that returns a failure instead (Email.cs:30-41). That is the same invariant-over-discipline posture the framework takes elsewhere (ADR-015), applied one level below the aggregate. - Amount and currency are one value, so they are one type. Two loose columns can drift;
Moneymakes a currency mismatch aResultfailure at the point of arithmetic (Money.cs:107-118) rather than a silently wrong total. - Records give equality for free. Value semantics ("two addresses with the same fields are the same
address") is exactly what a positional-free
recordalready generates, so the base type can be empty and no per-type equality code has to be reviewed for a missed field. - The asymmetry with ADR-048 is the point. An identifier's only rule is uniqueness, and it crosses a serialization, schema or key boundary at nearly every hop, so wrapping it is pure friction. A domain value's rule is the reason the type exists, and it crosses those same boundaries rarely and through mappings worth writing once. Same mechanism, opposite verdict, because the cost/benefit genuinely inverts.
- A sentinel beats null for an absent currency.
Currency.NonekeepsMoney.Zero()usable as an accumulator seed and keeps every read path non-nullable; a null currency inside a materializedMoneyis aNullReferenceExceptionwaiting for the first read, which is precisely the failureOwnsMoney's fallback exists to prevent (EntityTypeBuilderExtensions.cs:38-45). - Ship the mapping, do not repeat it.
OwnsMoney,OwnsAddressand the four converters put the round-trip contract in one reviewed place, so a new entity configuration is one call rather than a copied lambda pair that may or may not carry the sentinel fallback, or a copied six-property block whose lengths may or may not matchAddressInvariants. The helpers extendEntityTypeBuilderonly, so a value nested inside another owned type still needs the block by hand: Store'sVariantDiscount.SpecialPricerepeats theMoneymapping with its own sentinel fallback (.../Catalog.Infrastructure/Persistence/EntityConfiguration/ProductVariantConfiguration.cs:24,60-75, reason stated at:56-59).
Trade-offs
- The pattern is not uniformly applied. Only three of the seven types have a companion
*Invariantsclass; the rest inline their checks. Only two of the four multi-field values have a shipped owned-type helper (OwnsMoney,OwnsAddress); the single-string ones map through converters, andDateRangeandDateTimeRangehave neither. Five of the seven carry a serialization attribute, and not the same one: four are[DataContract]/[DataMember](Money,Email,PhoneNumber,Address) whileCurrencycarries[JsonConverter(typeof(CurrencyJsonConverter))](Currency.cs:13); onlyDateRangeandDateTimeRangeare annotation-free. - Three of the seven have no consumer.
PhoneNumberships with invariants, tests and converters, andDateRangeandDateTimeRangewith tests only (no*Invariantsclass, no converter), but none of the three has production usage, so their behavior is exercised only by the framework's own tests. - Nothing gates that a domain value uses a value object. The
Create-returns-Resultrule is fitness-enforced, but no rule says a new email field must beEmailrather thanstring. MMCA.Helpdesk is the visible consequence: the reference app models everything on primitives. - Read legs trust the column. The non-nullable converters materialize through
.Value!(EmailValueConverter.cs:41), which is sound for anything EF wrote and unsound for a value inserted by a manual script or data fix; the contract is documented rather than defended (EmailValueConverter.cs:24-31). - The currency set is closed in code.
Currency.AllisUSDandEUR(Currency.cs:54-58), so supporting a third currency is a framework change and a release, not configuration. Moneykeeps a throwing operator.operator +throwsInvalidOperationExceptionon a currency mismatch (Money.cs:84-90), which is the one place the value objects step outside the ADR-013 posture;Money.Addis theResult-returning path callers are steered to (Money.cs:107).- gRPC costs a hand-written mapping per value. There is no automatic proto projection, so every value object that has to cross a service boundary needs its own wire message and translation pair, the same class of friction ADR-048 declined to pay for identifiers.
Revision (2026-10-01)
No decision changed; four statements were corrected and anchors refreshed. (1) The Money sugar
paragraph claimed an external caller cannot assemble a Money whose currency was never checked.
Currency.Code (Currency.cs:34) and Money.Amount/Money.Currency (Money.cs:31,35) are public
init accessors in the shipped API (PublicAPI.Shipped.txt:550,556,558,732,738), so a with
expression bypasses FromCode and Create; the Decision now says so. (2) Store's Customer.Address
maps through OwnsAddress (CustomerConfiguration.cs:42) and then a second OwnsOne block
(:47-55) sets all six columns IsUnicode(), so the Status no longer says no consumer hand-writes an
Address block. (3) OwnsMoney extends EntityTypeBuilder only, so Store hand-maps the nested
VariantDiscount.SpecialPrice (ProductVariantConfiguration.cs:56-75); the Rationale records it. (4)
DateRange and DateTimeRange have no *Invariants class and no converter, so the Trade-offs no
longer say they ship with both, and "the two multi-field values" became two of four. Context and
Related now cite ADR-115 (wrapper structs opt-in, aliases default; DependencyInjection.cs:65). Not
yet recorded above and flagged here: a Money.Zero() serializes its currency as ""
(Currency.cs:92) and CurrencyJsonConverter.Read throws on it because FromCode rejects an empty
code (Currency.cs:43-44,83-85), so a None-currency Money does not JSON round-trip. Anchors
refreshed: CHANGELOG.md:1098, DependencyInjection.cs:54,67, CustomerConfiguration.cs:33,
EventValidationRules.cs:59,182, product_variants.proto:115,117,120 and adapter :190,221,199-204,121-125.
Related
ADR-048 (the deliberate opposite call for identifiers: primitives behind aliases, wrapper structs
rejected, because identifiers cross process boundaries constantly and carry no invariant), ADR-115
(the wrapper struct shipped as an opt-in capability, with aliases still the default), ADR-013
(the Result pattern these factories implement below the aggregate level), ADR-015 (the fitness
function that enforces the Create-returns-Result shape on value objects too).