Aggregate Design Heuristics
debt(d7/e8/b8/t7)
Closest to 'only careful code review or runtime testing' (d7). While phpstan and deptrac are listed as detection tools, the term itself notes automated detection is 'no'. Deptrac can enforce layer boundaries but cannot judge whether an aggregate is correctly sized or whether invariants are properly scoped. Detecting oversized aggregates or cross-aggregate transactions requires careful code review, domain expertise, and sometimes runtime observation of lock contention. Not quite d9 because experienced reviewers and some static patterns (e.g., multiple repository calls in one service method) can flag issues.
Closest to 'architectural rework' (e9), scored down to e8. The quick_fix sounds simple ('reference other aggregates by ID') but in practice, resizing aggregates after they're established means restructuring entity relationships, rewriting repositories, introducing domain events for eventual consistency between formerly co-transactional entities, updating persistence mappings, and changing how data is queried. Common mistakes like 'Customer as part of Order aggregate' require splitting entities across bounded contexts. This is cross-cutting refactoring that approaches architectural rework, especially in a mature codebase.
Closest to 'defines the system's shape' (b9), scored down to b8. Aggregate boundaries are a foundational architectural decision in DDD systems. They determine transaction boundaries, consistency guarantees, repository structure, event flows, and API shapes. The term applies across all PHP contexts (web, cli, queue-worker), and once aggregates are defined, every new feature, query, and integration is shaped by those boundaries. Changing them later has cascading effects, but it doesn't quite reach b9 because in systems that aren't fully DDD, aggregate design may only govern part of the codebase.
Closest to 'serious trap — contradicts how a similar concept works elsewhere' (t7). The misconception field is clear: developers intuitively believe 'larger aggregates are safer because they enforce more invariants in one transaction,' when in fact large aggregates cause write contention, lock timeouts, and performance problems. This directly contradicts the OOP instinct to group related objects together for safety. The common mistakes reinforce this — using object references instead of IDs, putting two aggregates in one transaction, and sizing for convenience rather than invariants are all natural-seeming choices that are wrong. A competent developer from an ORM/ActiveRecord background will almost certainly guess wrong initially.
Also Known As
TL;DR
Explanation
DDD aggregates enforce consistency boundaries. Key heuristics (Vaughn Vernon): (1) Design small aggregates — large aggregates cause contention; (2) Reference other aggregates by ID, not reference — prevents loading unnecessary objects; (3) One transaction per aggregate — if a use case modifies two aggregates, it probably needs eventual consistency; (4) Design around business invariants — what rules must always be true within this boundary?; (5) Accept eventual consistency between aggregates — use domain events to propagate changes.
Diagram
flowchart TD
subgraph Correct - Small Aggregates
ORD[Order<br/>aggregate root]
LINE[OrderLine]
ORD --> LINE
ORD -->|customerId only - ID ref| CID[CustomerId]
end
subgraph Wrong - Oversized Aggregate
ORD2[Order] --> CUST[Full Customer object]
ORD2 --> PROD[Full Product catalogue]
ORD2 --> INV[Inventory - all stock]
end
style CID fill:#238636,color:#fff
style CUST fill:#f85149,color:#fff
style PROD fill:#f85149,color:#fff
style INV fill:#f85149,color:#fff
Watch Out
Common Misconception
Why It Matters
Common Mistakes
- Aggregates sized for convenience (everything in one place) not invariants.
- Object references between aggregates — use IDs; cross-aggregate references cause loading chains.
- Two aggregates in one transaction — design for eventual consistency between them.
- Customer as part of Order aggregate — Customer has its own life cycle and invariants; reference by ID.
Avoid When
- The aggregate is too large — loading an entire order with all line items and history to change one field is wasteful.
- Aggregates reference other aggregates by object rather than by ID — this creates tight coupling across boundaries.
- Business rules span multiple aggregates — this is a sign the boundary is wrong, not that the rule should be in both.
When To Use
- Enforcing invariants that span multiple objects — an Order and its OrderLines must be consistent together.
- Transactional consistency boundaries in a domain model — one aggregate per transaction.
- Domain-driven design contexts where you need a clear root that controls access to child entities.
- Preventing direct manipulation of child entities from outside the aggregate boundary.
Code Examples
// Oversized aggregate — locks everything:
class Order {
private Customer $customer; // Full Customer object
private Product[] $catalogue; // Full product catalogue
private Inventory $inventory; // All inventory
// Placing one order locks: customer table, product table, inventory
// All concurrent orders for any product contend on inventory
}
// Small aggregate — reference by ID:
class Order {
private OrderId $id;
private CustomerId $customerId; // ID reference only
private OrderLine[] $lines; // Only own entities
private Money $total;
// Invariant: total must equal sum of line amounts
// Only locks: this order's rows — no customer/product contention
}
// When Order is placed, domain event notifies Inventory aggregate separately