ADR-0007: Domain - ID-Only Cross-Aggregate References
Context and Problem
Section titled “Context and Problem”Suppose the Order Aggregate holds a direct reference to a Customer object (public Customer Customer { get; }). When querying an order, EF Core loads Customer as well, and then cascades to load Address, LoyaltyProgram, and other objects referenced by Customer. What was meant to be reading a single order results in a 4-table join.
The problem goes beyond performance. When saving an order, EF Core’s change tracking includes the Customer object, potentially capturing unintended customer information changes in the same transaction. If two users concurrently modify different orders for the same customer, concurrency conflicts arise through the Customer object reference. Furthermore, since Order has a direct compile-time dependency on the Customer type, the Aggregate boundary cannot be cleanly split when extracting the customer service into a separate microservice in the future.
Considered Options
Section titled “Considered Options”- ID (value type) only references + domain events
- Direct object references
- Lazy Loading
- Saga pattern
Decision
Section titled “Decision”Chosen option: “ID (value type) only references + domain events”, to physically enforce Aggregate transaction boundaries and structurally prevent boundary violations. Since Order holds only a CustomerId value type instead of a Customer object, cascading loads, unintended change tracking, and concurrency conflicts become structurally impossible.
When cross-Aggregate consistency is needed (e.g., accumulating customer loyalty points after order creation), it is handled through domain events with eventual consistency. Strongly-typed IDs (OrderId, CustomerId) are distinct from primitive types like string or Ulid, so accidentally assigning a ProductId to an Order triggers a compile error.
Consequences
Section titled “Consequences”- Good, because saving an
Orderdoes not includeCustomerchange tracking, physically isolating the transaction boundary to the Aggregate unit. - Good, because passing a
ProductIdwhere aCustomerIdis expected triggers a compile error, blocking incorrect ID assignment before runtime. - Good, because loading an
Orderdoes not trigger joins toCustomer,Address, and other related tables, confining queries to single-table scope. - Good, because inter-Aggregate dependencies are limited to ID values, enabling clean boundary separation when extracting the customer service into a separate microservice without code changes.
- Bad, because “displaying order and customer information together” requires querying the order by
OrderIdthen making a separate query withCustomerId, or constructing a Read Model. - Bad, because adopting eventual consistency instead of strong consistency requires a business judgment on whether temporary inconsistencies during event processing delays are acceptable.
Confirmation
Section titled “Confirmation”- Verify through architecture rule tests that Aggregate Roots do not directly reference entities of other Aggregates.
- Verify during code reviews that cross-Aggregate references consist solely of ID value types.
Pros and Cons of the Options
Section titled “Pros and Cons of the Options”ID (Value Type) Only References + Domain Events
Section titled “ID (Value Type) Only References + Domain Events”- Good, because each Aggregate only locks its own table, minimizing the scope of concurrency conflicts.
- Good, because strongly-typed IDs like
OrderIdandCustomerIdblock primitive type confusion at compile time. - Good, because inter-Aggregate communication occurs through domain events, enabling loose coupling and independent deployment.
- Bad, because cross-Aggregate reads like “display customer name on order list” require separate queries or denormalized Read Models, increasing read complexity.
Direct Object References
Section titled “Direct Object References”- Good, because navigation properties like
order.Customer.Nameprovide intuitive access to related data. - Bad, because when saving
Order, EF Core tracksCustomerchanges as well, potentially including unintended data modifications in the same transaction, and concurrent modifications of different orders cause concurrency conflicts on theCustomerrow. - Bad, because cascading loads like
Order->Customer->Address->Regionresult in multi-table joins for a simple order query. - Bad, because
Orderhas a compile-time dependency on theCustomertype, preventing code separation at the Aggregate boundary when extracting the customer domain into a separate service.
Lazy Loading
Section titled “Lazy Loading”- Good, because related Aggregates are not loaded at initial load time, with no immediate performance cost.
- Bad, because iterating over 100 orders and accessing each
order.Customertriggers 100 individual SELECT queries, exhibiting the N+1 problem. - Bad, because Lazy Loading proxies inject EF Core dependency into domain models, compromising domain layer purity.
- Bad, because lazily loaded
Customerobjects are still change-tracked in the same DbContext, leaving the transaction boundary violation problem unresolved.
Saga Pattern
Section titled “Saga Pattern”- Good, because long-running business processes spanning different databases can be managed with compensating transactions.
- Bad, because applying Saga to the problem of decoupling Aggregate references within the same database introduces excessive complexity — message brokers, Saga orchestrators, and other infrastructure.
- Bad, because compensating logic and state machines must be designed and tested for each step, significantly increasing implementation cost compared to the ID-only reference + domain event approach.
Related Information
Section titled “Related Information”- Related commit:
71272343 - Related guide:
Docs.Site/src/content/docs/guides/domain/06a-aggregate-design - Reference: Vaughn Vernon, Implementing Domain-Driven Design — Chapter 10, Aggregates