Architecture Decision Record
ADR-129: The Tactical Aggregate Contract
Status
Accepted (2026-10-01).
Context
Every entity in the four repos is written the same way: a sealed class over a framework base, a
private constructor, a static Create(...) returning Result<T>, private setters, named mutation
methods, child lookups through the root and invariants held in a static class. Agents and people copy
that shape from the nearest entity, which is why it is consistent today, but the contract itself is
recorded only in fragments. ADR-013 makes factories return Result<T>,
ADR-068 puts value-object invariants in a static
*Invariants class, and ADR-126 says an aggregate is a
current-state row. None of them states the whole entity contract, and none says which parts a build
enforces and which parts only exist because the next entity copied the last one.
A pattern that forty files copy needs one place that says what the pattern is, where its boundary sits, and which half a fitness test holds. This record is that place, in the same way ADR-125 is for raw SQL.
Decision
Entities follow one tactical contract. The construction, encapsulation and placement parts are
fitness-gated by EntityConventionTestsBase; the child-access part and the use of Result.Combine are framework members and a convention
every repo follows, not a test.
- One hierarchy, three abstract levels.
BaseEntity<TIdentifierType>(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/BaseEntity.cs:34) carries identity;AuditableBaseEntity<TIdentifierType>(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:13) adds soft delete, the audit fields and the row version, all with private setters (:20-53):IsDeletedis set by the domain methodsDelete()andUndelete()(:67-80,:89-104), while the audit fields are stamped at save time; andAuditableAggregateRootEntity<TIdentifierType>(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:13) adds the domain event list (:16-50) and the child-access members below. The fitness rules recognize an aggregate root by a base type whose name starts withAuditableAggregateRootEntity, and an entity by any of the three bases (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/RuleHelpers.cs:109-119). - The gate is one shared base with eight facts.
EntityConventionTestsBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Domain/EntityConventionTestsBase.cs:10) declares the facts at:14-36; each body lives once inMMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Domain/ArchitectureRules.Entities.cs. - The suite refuses to pass vacuously. The Domain layer must expose at least one aggregate root
(
ArchitectureRules.Entities.cs:8-16), so a broken filter fails instead of checking nothing. - Construction is factory-only. Every aggregate root needs a public static
Createwith at least one overload returningResult<TSelf>(ArchitectureRules.Entities.cs:19-41), and no public instance constructor (:167-177). TheResult<T>rule also reaches every concrete type in Domain and Shared that exposes aCreate(:53-80); a type with noCreateis unaffected, and other construction sugar such asMoney.Zero()is out of scope because only theCreatename is governed (:49-51). - Entities are sealed. Any concrete module-domain type inheriting one of the three bases must be
sealed (
ArchitectureRules.Entities.cs:103-112); the rule checks concrete classes only (:105-107), so an abstract module-domain class over an entity base is not caught. - Entities live in Domain. No concrete type inheriting an entity base may sit in an Application or
Infrastructure layer assembly (
ArchitectureRules.Entities.cs:180-191). - No public setters; mutation goes through named domain methods. Every public instance property
of a module-domain entity must have no setter, an
init-only setter, or a non-public one (ArchitectureRules.Entities.cs:149-164, compliance stated at:129-136). Navigation properties are included rather than exempted, so a collection is replaced through aSetXxxmethod (:133-136). Aninitsetter is detected by itsIsExternalInitmodreq and counts as compliant (RuleHelpers.cs:129-143). Fields are not inspected (ArchitectureRules.Entities.cs:144-145). - DTOs and requests stay out of the model. A type ending in
DTOorRequest, or implementingIBaseDTO, fails in Domain; a DTO fails in Infrastructure, while a*Requestis allowed there as an outbound HTTP payload (ArchitectureRules.Entities.cs:193-217). - Child access goes through the root.
GetChildOrNotFound<TChild, TChildId>is a protected static member of the aggregate base that returns the active child by id or aNotFoundfailure carrying the caller as source and the child type as target, skipping soft-deleted children (AuditableAggregateRootEntity.cs:105-122).SetItems<TChildEntity>is the protected collection replacement: it copies the incoming items, calls the overridableValidateSetItemshook (a no-op by default) and only then replaces the backing list (:60-76, hook at:87-92). Three more protected static helpers build on the lookup:RemoveChildOrNotFound(lookup then the child'sDelete(),:158),RestoreChild(reactivates a soft-deleted child,:214) andDeleteChildren(soft-deletes every active child for a cascadingDelete(),:275). All five are protected, so only the aggregate calls them directly; aggregates still exposeinternalSetXxxwrappers overSetItemsfor manual navigation loading (for exampleMMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Domain/Products/Product.cs:413-414), which Application-layer navigation populators call (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/ProductNavigationPopulator.cs:28). No fitness rule requires the use of any of these helpers. - Invariants are static classes composed with
Result.Combine.Result.Combine(params ReadOnlySpan<Result>)(MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Result.cs:124) throws on an empty argument list (:126-129) and otherwise returns success or one failure holding every error from every failed input (:131-144). Its arguments are evaluated before the call, so every invariant runs and every violation is reported; it does not short-circuit. That an*Invariantsclass is static is fitness-gated (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Governance/ArchitectureRules.Naming.cs:78-88, run asInvariantClasses_ShouldBe_Staticat.../Bases/Governance/NamingConventionTestsBase.cs:31, subclassed by MMCA.ADC, MMCA.Store and MMCA.Helpdesk); that one exists and that it usesResult.Combineis convention. - The reference shape is Helpdesk's
Ticket. It is sealed over the aggregate base (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Domain/Tickets/Ticket.cs:26), has a private constructor (:52) andCreatereturningResult<Ticket>(:66), composesTicketInvariantsthroughResult.CombineinCreateandUpdateDetails(:68,:118), and finds a comment throughGetChildOrNotFound(:144).TicketInvariantsis the static class (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Domain/Tickets/TicketInvariants.cs:16,Result.Combineat:27,:33,:40). The ADC and Store aggregates use the same members, for exampleSpeaker(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:392,:452,:472) andProduct(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Domain/Products/Product.cs:407,:455,:474). - Adoption: the three application repos subclass the full base unchanged. MMCA.ADC
(
MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Domain/EntityConventionTests.cs:3-6), MMCA.Store (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Domain/EntityConventionTests.cs:3-6) and MMCA.Helpdesk (MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.cs:89-92) each supply only their architecture map. - MMCA.Common does not subclass
EntityConventionTestsBase. It has no business modules, and the sealed, setter and module-scoped constructor rules scope to module domains, so those three would be vacuous there (ArchitectureRules.Entities.cs:141-144,:86). The layer-placement and DTO/request rules do not scope to module domains (ArchitectureRules.Entities.cs:180-217), but Common does not call them either. It subclasses the smallerAggregateConventionTestsBaseinstead (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Domain/AggregateConventionTests.cs:9), which runs the vacuity,Createfactory and Domain-wideResult<T>facts plus a no-public-constructor rule scoped to the whole Domain layer rather than module domains (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Domain/AggregateConventionTestsBase.cs:14-24, rule atArchitectureRules.Entities.cs:90-100). The framework's own types are therefore not checked for sealing, setters, layer placement or DTO/request placement by this suite.
Rationale
- One contract, one record. The pieces were each correct in their own ADR, but an agent writing a new entity needs the whole shape at once, and a reviewer needs to know which deviations a build catches. Naming the gated and ungated halves separately is the point of the record.
- The factory is where invariants run. A
CreatereturningResult<T>lets construction fail as a value (ADR-013), and the no-public-constructor rule removes the path around it. - A public setter makes every invariant optional. The factory can only guarantee the state it
builds; a public setter lets any caller reach a state no invariant saw, and putting mutation behind
a named method is also what keeps the state change and its domain event together
(
ArchitectureRules.Entities.cs:120-126). - Sealing keeps the boundary closed. Inheriting a concrete aggregate would let a subclass bypass the factory and the named methods; only the abstract framework bases are meant to be extended.
- Combine reports everything. Because
Result.Combineaggregates every failed input, a caller sees all broken invariants in one response rather than fixing them one round trip at a time. - Executable beats documented. The gated parts follow the fitness-function style of ADR-015: the failure message names the fix, so nobody has to remember the rule.
Trade-offs
- Part of the contract is not enforced. Child access through
GetChildOrNotFound,SetItemsand the remove, restore and cascade-delete helpers, the existence of an*Invariantsclass and the use ofResult.Combinehold by copying, not by a test; an abstract module-domain class over an entity base also escapes the sealed rule. An aggregate that reaches into a child list directly or validates inline still builds. - Eager evaluation has a cost. Every argument to
Result.Combineruns before the call, so an invariant that depends on an earlier one passing must guard itself or run in a separate step. - The setter rule sees accessibility, not intent. A public setter called only from inside the
aggregate still fails (
ArchitectureRules.Entities.cs:139-141), and a public mutable field is left to the analyzers rather than this rule (:144-145). - Layer placement covers two layers. The rule scans Application and Infrastructure assemblies only
(
ArchitectureRules.Entities.cs:182); an entity declared in an API or UI assembly is not caught by it. - The
Createcheck is by name. A type with noCreatemethod is outside theResult<T>rule entirely (ArchitectureRules.Entities.cs:67-70), so a factory under another name is unchecked. - The framework's own model is gated more lightly. MMCA.Common runs the smaller base, so sealing, setter, layer-placement and DTO/request rules do not apply to its own concrete Domain types.
Related
ADR-013 (the Result<T> factories this contract requires),
ADR-068 (value-object factories and the static
*Invariants class this contract reuses for entities),
ADR-126 (an aggregate is a current-state row that raises domain
events, which is the row this contract shapes),
ADR-015 (the fitness-function style and the shared
*TestsBase package all four repos subclass),
ADR-125 (the precedent for a fitness-gated convention getting its own
record). Framework version and package figures live in MMCA.Common/FACTS.md.