Domain-Driven Design Tactical Patterns
Tactical DDD is the set of object-oriented design patterns Eric Evans introduced in Domain-Driven Design: Tackling Complexity in the Heart of Software (2003) — known as the Blue Book — for structuring the interior of a bounded context. These are the building blocks of a domain model: Entity, Value Object, Aggregate, Repository, Domain Service, Domain Event, and Factory. Each block has a precise definition, a clear set of responsibilities, and a clear set of non-responsibilities — and getting the boundaries right is the central skill of tactical DDD. The vocabulary distinguishes “tactical” from “strategic”: strategic DDD concerns which models exist and how they relate (bounded contexts, context maps, ubiquitous language); tactical DDD concerns how each model is implemented in code. The split was Evans’s framing in 2003; it became the conventional structure of subsequent DDD literature, including Vaughn Vernon’s Implementing Domain-Driven Design (2013, the Red Book) and Vlad Khononov’s Learning Domain-Driven Design (2021). This note covers each of the seven building blocks with definitions and worked examples; the canonical Aggregate Boundary question that recurs in interviews and design reviews; the relationship between Repositories and ORM frameworks (Spring Data, EF Core); the Domain Event mechanism that connects tactical DDD to Event Sourcing Pattern and Event-Driven Architecture; the comparison with anemic-domain-model code that uses similar names without the substance; and the pitfalls (anemic models, oversized aggregates, generic repositories, dumping-ground domain services).
1. When to Use, When Not To
Tactical DDD is most appropriate in domain-heavy systems — software where the rules, calculations, workflows, and policies the system enforces are the source of value, and where those rules are non-trivial enough to justify modelling them as first-class objects. Insurance underwriting (rules about coverage eligibility, premium calculation, exclusion clauses), banking core systems (debit/credit balancing, end-of-day settlement, regulatory reporting), supply-chain optimisation (inventory allocation, demand forecasting, route planning), healthcare claim adjudication (coverage rules, prior-authorisation policies), and complex e-commerce (multi-stage order workflows, multi-currency pricing, returns and refunds) are the textbook candidates. In these systems, the cost of not using tactical DDD is high: business rules end up scattered across procedural service classes, the domain model is anemic (entities as DTOs), and the system becomes resistant to change because the rules are not localised. Evans’s 2003 framing of “tackling complexity in the heart of software” captures the use case: tactical DDD is for systems whose heart is complex.
The patterns also fit when the team has direct collaboration with domain experts. Tactical DDD’s value is partly extracted from the ubiquitous language — the shared vocabulary between developers, product owners, and domain experts — which the building blocks express in code. An Order.Cancel(reason) method is recognisable to a domain expert; an OrderService.UpdateStatus(orderId, "cancelled") method is not. When developers and domain experts can talk through the model in the domain’s vocabulary, the building blocks earn their cost. When developers work in isolation from domain experts, the language drifts and the building blocks become arbitrary.
A third strong indicator is long-lived systems with anticipated domain evolution. Business rules change: regulations update, market conditions shift, product offerings evolve. A tactical-DDD model that has localised the rules onto the right entities and aggregates absorbs these changes more easily than a procedural service layer. The cost of changing a rule in a behaviour-rich Order class is bounded; the cost of changing a rule scattered across five service classes is not.
Tactical DDD is not a good fit for data-shape-dominated systems — pure CRUD applications, ETL pipelines, configuration management, ML-serving glue. The patterns assume there is a domain to model; without one, the patterns add ceremony without benefit. A system whose entire purpose is “expose this database table over HTTP” should not have a TableEntryAggregate; the patterns should match the system’s complexity. Khononov (2021, ch. 9) makes this explicit: apply tactical DDD to bounded contexts whose complexity justifies it; apply simpler patterns elsewhere.
A subtler counter-indication is team unfamiliarity. Tactical DDD’s vocabulary takes time to internalise; a team that adopts the names without the discipline produces tactical-DDD-shaped code that is anemic underneath. The signature failure mode: the team has Entity, Value Object, Aggregate, Repository, and Domain Service classes, but the entities have only fields and getters/setters, the value objects are mutable, the aggregates have no transactional consistency rules, the repositories are generic CRUD wrappers, and the domain services contain all the business logic procedurally. This is the Anemic Domain Model (Fowler 2003) wearing DDD clothing, and it is the most common failure of DDD adoption. Spotting it requires looking into the entities and asking whether they have behaviour; if they do not, the team has built a DDD shell around procedural code.
2. Structure
flowchart TB subgraph Aggregate["Aggregate (Order)"] Root["Aggregate Root: Order"] Inner["Internal Entity:<br/>OrderLine"] VO["Value Objects:<br/>Money, Address,<br/>Quantity"] Events["Domain Events:<br/>OrderPlaced,<br/>OrderCancelled"] end Repo["Repository:<br/>IOrderRepository"] DSvc["Domain Service:<br/>PricingService"] Factory["Factory:<br/>OrderFactory"] Repo -->|"loads/saves"| Root DSvc -->|"reads"| Aggregate DSvc -->|"reads other"| OtherAgg["Customer aggregate"] Factory -->|"creates"| Aggregate Root -->|"contains"| Inner Root -->|"holds"| VO Inner -->|"holds"| VO Root -->|"emits"| Events
What this diagram shows. The seven tactical building blocks and their relationships. At the centre is an Aggregate — a cluster of entities and value objects treated as a single transactional consistency boundary. The Aggregate has a single Aggregate Root (Order); only the root is referenced from outside the aggregate. Internal entities (OrderLine) are reachable only through the root. Value Objects (Money, Address, Quantity) are immutable, identity-less, and held by entities. The aggregate emits Domain Events (OrderPlaced, OrderCancelled) that record significant state changes. The Repository is a collection-like interface for loading and saving the aggregate as a whole — the aggregate root is the only entry point, and the repository never returns internal entities directly. The Domain Service (PricingService) is for operations that span aggregates — calculating an order’s price might involve reading the Customer aggregate (for loyalty discounts) and the Catalog aggregate (for prices), neither of which is the natural home for the calculation. The Factory (OrderFactory) encapsulates complex creation logic: when constructing an Order from a customer’s cart requires looking up prices, applying promotions, and validating inventory, the construction logic does not fit on the constructor and lives in a Factory.
The aggregate boundary is the load-bearing concept. Operations within an aggregate are atomic and consistent (the aggregate is a single transaction); operations across aggregates are eventually consistent (cross-aggregate coordination uses sagas, domain events, or eventual consistency mechanisms). Vernon’s Effective Aggregate Design series (2011) is the canonical treatment of where the boundary should sit; the rule of thumb is “design small aggregates” and “reference other aggregates by identity, not by object reference.”
The diagram suggests the canonical tactical layout: aggregates at the centre, repositories as the collection-like access path, factories for creation, domain services for cross-aggregate logic, and domain events as the cross-aggregate communication mechanism. Each pattern has its own role; the discipline is to use each pattern only for its purpose.
3. The Building Blocks
3.1 Entity
An Entity is an object whose identity persists across state changes. A Customer is an entity: even if the customer changes their name, address, and email, it is still the same customer, identified by their customer ID. Two customers with identical fields are still two distinct customers if their IDs differ.
The defining property is identity. Entities have an explicit identifier — a CustomerId value object containing a UUID, a LeadId containing a Guid, an OrderId containing a UUID. Equality between entities is defined by ID, not by field-by-field comparison. Two Customer instances with the same CustomerId are equal even if their balance differs (one is “stale” and one is “fresh”).
Entities have behaviour in the form of methods that enforce invariants and emit domain events. A Customer.Activate() method changes the status to Active and emits CustomerActivated. A Customer.UpdateEmail(newEmail) validates the new email and emits CustomerEmailUpdated. The methods are the entity’s vocabulary in the ubiquitous language; the field state is the grammar the methods exercise.
Anti-pattern alert: an entity with only fields and getters/setters is anemic (Fowler 2003) and indicates a failure of tactical DDD. The fix is to push behaviour onto the entity.
3.2 Value Object
A Value Object is an object defined entirely by its attributes, with no identity, and immutable. Money(100, USD) is a value object: any other Money(100, USD) is interchangeable with it. An Address("123 Main St", "Springfield", "IL", "62701") is a value object: two addresses with identical fields are the same address. DateRange(start, end), EmailAddress("a@b.com"), Latitude(42.0), Quantity(3, units) — all are value objects.
The two defining properties are (1) equality by attributes (no identity field) and (2) immutability (changing the value produces a new instance, not a mutation). Immutability is what makes value objects safe to share — they cannot be mutated by surprise — and is what makes them composable as building blocks in entities.
Value objects often carry behaviour in the form of operations that produce new value objects. Money.Add(other) returns a new Money instance; Money.Multiply(factor) does the same. DateRange.Overlaps(other) returns a boolean. EmailAddress.Domain() returns the domain part. The methods are domain operations on the value, not state mutations.
The single most common error is making value objects mutable. A “Money” class with a setter for amount is not a value object; it is a mutable struct. The whole calculus of value objects depends on immutability; without it the safety guarantees disappear.
3.3 Aggregate
An Aggregate is a cluster of entities and value objects that is treated as a single unit for the purposes of data consistency. Every aggregate has one Aggregate Root — the entity that is the only externally-referenceable member of the aggregate. References from outside the aggregate point only at the root; internal entities and value objects are reached only through the root.
The aggregate is the transactional consistency boundary. Within a single transaction, the entire aggregate is loaded, mutated, and saved as a unit. Cross-aggregate operations span multiple transactions and rely on eventual consistency (typically via domain events or sagas).
Vernon’s Effective Aggregate Design (2011) lays out four rules:
- Model true invariants in consistency boundaries. An aggregate exists because some invariant holds across its members; if no such invariant exists, the cluster is not an aggregate but just objects that happen to be related.
- Design small aggregates. Most aggregates should contain just the root and a small number of value objects. Large aggregates with many entities are an anti-pattern; they cause concurrency contention, slow loading, and complex consistency logic.
- Reference other aggregates by identity. A
Orderaggregate referencesCustomerbyCustomerId, not by holding aCustomerobject. This prevents cross-aggregate object graphs that violate the aggregate boundary and allows aggregates to be loaded independently. - Use eventual consistency outside the boundary. Cross-aggregate operations are not atomic; they are eventually consistent via domain events. A
OrderPlacedevent triggers an eventual update to theCustomer.LifetimeOrderCountfield, but not within the same transaction.
The aggregate boundary question is the single most-asked tactical-DDD question in design reviews and interviews: “Should this be one aggregate or two?” The answer hinges on whether there is a true invariant requiring atomic consistency across the proposed boundary. If yes, one aggregate. If no, two aggregates connected by domain events.
3.4 Repository
A Repository is a collection-like interface for accessing aggregates. The repository’s API speaks in terms of aggregate roots: OrderRepository.findById(OrderId), OrderRepository.save(Order), OrderRepository.findActiveOrdersForCustomer(CustomerId). The repository never returns internal entities of the aggregate directly — only roots — preserving the boundary.
Evans’s framing in 2003 is collection-like: the repository should feel like an in-memory collection of aggregates. repository.add(order), repository.find(id), repository.remove(order). The persistence backend (SQL, NoSQL, in-memory) is an implementation detail.
Spring Data Repository (https://docs.spring.io/spring-data/commons/reference/) is DDD-influenced — its naming and conventions echo Evans’s repository pattern — but the inherited methods (findAll(Pageable), findAll(Sort)) are Spring concepts, not aggregates. Strictly DDD-pure repositories define their own interfaces with domain-meaningful methods (findOrdersAwaitingShipment, findCustomersInArrears); pragmatic Spring codebases often extend JpaRepository and accept the small purity violation.
A common error is to use repositories as generic CRUD wrappers rather than collection-like access for aggregates. A CustomerRepository with methods findByName, findByEmail, findByPhone, findByAge, findBySubscriptionTier is a query interface, not a repository. The fix is to either (a) accept that the repository has a query side and split it from the aggregate-access side (CQRS-style), or (b) consolidate query methods into a small set that capture true business operations rather than ad-hoc filtering.
3.5 Domain Service
A Domain Service is an operation that does not naturally fit on a single entity or value object. The canonical example is a money transfer between two Account aggregates: the operation is symmetric (neither account is “the primary”), it spans two aggregates, and putting transfer(other, amount) on Account makes one account a passive recipient. A TransferService.Transfer(source, destination, amount) is the principled home.
Domain services are stateless — they hold no field state; they are pure operations over aggregates. They live in the same conceptual space as entities (the domain layer) and use only domain types in their signatures.
A common error is to use Domain Services as a dumping ground for behaviour that should be on entities. The discipline: every method on a Domain Service should be re-evaluable against the question “could this be a method on an entity or value object?” If yes, move it. If genuinely no — the operation truly spans aggregates and has no natural home on any one of them — the Domain Service is the right place.
3.6 Domain Event
A Domain Event is a record of something significant that happened in the domain. OrderPlaced(orderId, customerId, total, placedAt), PaymentCaptured(paymentId, orderId, amount, capturedAt), LeadAssigned(leadId, salespersonId, assignedAt). Domain events are immutable, named in past tense (the action has happened), and carry the data needed to react to the event.
Vernon’s Implementing DDD (2013, ch. 8) is the canonical treatment. Events are emitted by aggregates as part of their state-changing methods: Order.Place(items) mutates the aggregate state and emits OrderPlaced. The Application Service collects pending events from the aggregate after a successful save and publishes them via an IEventPublisher interface to whatever transport (in-process bus, Kafka, RabbitMQ) is configured.
Domain events connect tactical DDD to several broader patterns:
- Event Sourcing Pattern treats domain events as the primary representation of aggregate state — the aggregate’s state is derived by replaying its event history rather than by direct persistence.
- Event-Driven Architecture uses domain events as the cross-service communication mechanism — when one bounded context needs to react to changes in another, it subscribes to the relevant domain events.
- Saga Pattern orchestrates multi-aggregate workflows by listening to domain events and dispatching commands in response.
The discipline: domain events are records of what happened, not commands telling something to happen. A CancelOrder is a command (an instruction); an OrderCancelled is an event (a record). Confusing the two leads to event-sourced systems that look procedural.
3.7 Factory
A Factory encapsulates complex aggregate creation logic. When constructing an aggregate requires more than the constructor can express — looking up data from other aggregates, applying defaulting rules, assigning identifiers, validating cross-field invariants — the construction logic lives in a Factory rather than a constructor. OrderFactory.CreateFromCart(cart, customer, pricing, inventory) might involve looking up product details, applying promotional pricing, validating stock, and constructing the resulting Order.
Factories can be implemented as static methods on the aggregate root (the factory method pattern), as separate classes (OrderFactory is a class), or as builder objects. Evans’s 2003 framing was ecumenical; the discipline is just that complex creation should not pollute the aggregate’s constructor.
A common error is to apply Factories to every aggregate, even simple ones. Most aggregates have simple constructors that suffice; Factories add value only when creation genuinely requires orchestration. The signal that a Factory is warranted: the constructor would otherwise need parameters representing other aggregates, repository access, or complex defaulting logic.
4. The Aggregate Boundary Question
The single most-asked tactical-DDD question is “Where does the aggregate boundary go?” Vernon’s three-part Effective Aggregate Design (2011) is the canonical treatment, and the question recurs in every senior-level system-design interview and every real-world DDD design review. The question matters because the aggregate boundary determines the transactional consistency boundary, the concurrency-control unit, the loading granularity, and the replication unit. Getting it wrong has cascading consequences: oversized aggregates suffer from concurrency contention and slow loads; undersized aggregates fail to enforce real invariants and require complex cross-aggregate coordination.
The decisive question: is there a true business invariant that requires atomic consistency across the proposed boundary? If yes, the cluster is an aggregate. If no, it is multiple aggregates connected by eventual consistency.
Worked example. Consider an e-commerce system with Order, OrderLine, Customer, Product. The candidate aggregates:
Option A — Order with OrderLines as one aggregate; Customer and Product separate. The invariant: an Order’s total must equal the sum of its OrderLine totals (line × quantity). This invariant is real and atomic — adding an OrderLine must update the Order’s total within the same transaction. Therefore, OrderLines are internal entities of the Order aggregate, reachable only through the Order root. Customer and Product are referenced by ID — CustomerId and ProductId value objects on the OrderLine — but are separate aggregates because no invariant ties them to the Order’s atomicity.
Option B — Order, OrderLine, Customer, Product as one giant aggregate. No real invariant requires the Customer’s profile to be consistent with the Order in the same transaction. Loading the Customer every time you load an Order wastes resources. This is an oversized aggregate; it should be split.
Option C — Order and OrderLine as separate aggregates. The invariant about the Order’s total versus its lines breaks: an OrderLine can be added in one transaction without updating the Order’s total in the same transaction. The system has lost the atomic consistency the invariant requires. This is an undersized aggregate; it should be merged.
Option A is correct because it matches the invariant structure. Vernon’s rules in action:
- True invariant in the boundary: Order total = sum of OrderLine totals. ✓
- Small aggregate: Order + OrderLines is small (one root, a few children). ✓
- Reference by identity: Customer and Product are referenced by
CustomerIdandProductId, not by object reference. ✓ - Eventual consistency outside: when the Customer’s lifetime order count needs to update, it does so eventually via the
OrderPlaceddomain event. ✓
The test for whether an aggregate is correctly bounded is to ask: “If I add or modify the cluster atomically, what invariants am I enforcing? Are those invariants real, or am I just being conservative?” Real invariants justify the boundary; conservatism does not.
5. Request Flow
sequenceDiagram autonumber participant Web as REST Controller participant App as PlaceOrderAppService<br/>(Application Layer) participant Repo as IOrderRepository participant Cust as ICustomerRepository participant Pricing as PricingService<br/>(Domain Service) participant Order as Order aggregate participant Bus as IDomainEventBus Web->>App: PlaceOrder(cmd) App->>App: BeginTransaction() App->>Cust: GetById(cmd.customerId) Cust-->>App: Customer App->>Pricing: PriceCart(cart, customer) Pricing-->>App: PricedItems App->>Order: Order.Place(customerId, pricedItems, address) Order-->>App: order with OrderPlaced event App->>Repo: Save(order) App->>Bus: Publish(OrderPlaced) App->>App: CommitTransaction() App-->>Web: OrderId
Walkthrough. A REST controller (1) invokes the Application Service. The Application Service (2) opens a transaction, (3–4) loads the Customer aggregate to feed into the pricing logic, (5–6) invokes the Domain Service PricingService which calculates prices using both the Customer and the cart contents (a cross-aggregate operation that does not naturally fit on either aggregate), (7–8) constructs the Order aggregate using a static factory method Order.Place that enforces invariants and emits the OrderPlaced domain event, (9) saves the Order via its Repository, (10) publishes the domain event via the bus, and (11) commits the transaction. The result returns to the controller (12).
The flow exemplifies several tactical-DDD principles: aggregates are loaded and saved as units (Order is one transactional unit; Customer is another); cross-aggregate coordination goes through Domain Services and Domain Events (PricingService spans Customer and cart; OrderPlaced will eventually update the Customer’s lifetime count); the Application Service is thin orchestration without business rules (the rules are in the Order aggregate’s Place method and in the PricingService).
6. Variants
The tactical patterns have several common variations.
Aggregates as Event-Sourced. When Event Sourcing Pattern is layered onto tactical DDD, the aggregate’s state is derived by replaying its event history. The Repository becomes an IEventStore that stores and retrieves event streams; the aggregate is loaded by replaying its events; mutations append new events; the aggregate’s state is in-memory only between load and save. The Axon Framework (https://docs.axoniq.io/reference-guide/) is the canonical Java implementation; EventFlow is a common .NET implementation.
CQRS with Tactical Patterns on the Write Side. Command Query Responsibility Segregation separates writes (commands) from reads (queries). On the write side, tactical DDD applies fully: aggregates, repositories, domain services. On the read side, queries hit denormalised view models directly without going through aggregates — the read path bypasses tactical DDD because there are no invariants to enforce on reads. The combination is common in larger DDD codebases.
Pattern-Per-Bounded-Context. Different bounded contexts within the same system may use different tactical patterns. A complex Sales context might use full DDD with aggregates and domain events; a simpler Catalog context might use plain Active Record (Fowler 2002, Patterns of Enterprise Application Architecture); a reporting context might use plain SQL queries with no model. Khononov (2021) calls this matching the pattern to the context’s complexity, and treats it as a key skill.
Functional Tactical DDD. In functional languages (F#, Scala, Haskell), the tactical patterns adapt: entities become records with methods returning new entities (immutability throughout); value objects are natural in the language; aggregates are functions (state, command) → (newState, events); repositories are functions returning streams. Scott Wlaschin’s Domain Modeling Made Functional (2018, Pragmatic Bookshelf) is the canonical treatment.
7. Real-World Examples
Spring Data Repository. Spring Data’s Repository abstraction (https://docs.spring.io/spring-data/commons/reference/) is explicitly DDD-influenced. The naming, the collection-like API, and the focus on aggregates trace to Evans’s 2003 patterns. Spring Data’s pragmatic compromise is that the inherited methods (findAll(Pageable), findById) are not domain-pure, but the discipline of treating each repository as an aggregate-access path is preserved.
Axon Framework. The Java framework Axon (https://docs.axoniq.io/reference-guide/) is DDD-native: aggregates are first-class, with annotations distinguishing aggregate roots, command handlers, event handlers, and event-sourcing constructs. The framework enforces aggregate-boundary discipline at the API level. Many Java DDD codebases use Axon as their primary DDD scaffolding.
.NET DDD codebases. Microsoft’s .NET Microservices: Architecture for Containerized .NET Applications (https://github.com/dotnet-architecture/eShopOnContainers) implements its Ordering microservice with explicit DDD tactical patterns: an Order aggregate root, OrderItem internal entity, Address value object, IOrderRepository, domain events emitted on state changes, MediatR-driven application services. The accompanying e-book is one of the most cited DDD-in-.NET references.
SAP S/4HANA. SAP’s flagship enterprise resource planning (ERP) system uses DDD-influenced building blocks in its modern architecture, with aggregates aligned to business-object boundaries and domain services for cross-aggregate coordination. The internal architecture is not fully public, but SAP’s developer documentation refers to DDD vocabulary explicitly in its 2020s-era materials.
Vernon’s case studies. Vaughn Vernon’s Implementing Domain-Driven Design (2013) walks through a realistic SaaS application (the iddd_samples on https://github.com/VaughnVernon/IDDD_Samples) with full tactical-DDD implementation. The codebase is the most-cited teaching example in DDD literature.
Banking core systems. Many banking core platforms — including those at HSBC, JP Morgan Chase, and Goldman Sachs (per various engineering blog posts and conference talks) — use DDD-style tactical patterns for ledger and trading domains where the rules are complex and the cost of incorrect implementation is regulatory.
8. Tradeoffs
The benefits of tactical DDD are precise. Behaviour localisation: rules and invariants are attached to the entities that own them, not scattered across procedural service classes. Ubiquitous language: the code expresses domain concepts in vocabulary that domain experts recognise, making collaboration richer. Aggregate atomicity: invariants are enforced atomically within aggregates, simplifying reasoning about consistency. Domain-event integration: changes are recorded as events, naturally extending into Event-Driven Architecture and Event Sourcing Pattern.
The costs are also real. Upfront cost: tactical DDD requires more thought per feature than procedural service-layer code; designing the aggregates, identifying value objects, choosing where domain services apply takes time. Boundary pain: the aggregate-boundary question is genuinely hard; teams disagree, get it wrong, and refactor. ORM friction: object-relational mappers (JPA, EF Core) sometimes resist DDD-style aggregates — lazy loading, entity-graph configuration, and identity-map semantics interact awkwardly with aggregate-boundary rules. Onboarding: the vocabulary takes weeks to internalise; an engineer arriving from a procedural Spring background needs orientation.
A second-order cost is the risk of cargo-cult adoption. A team that adopts tactical-DDD vocabulary without internalising the principles produces an Anemic Domain Model wearing DDD clothing; the architectural promises are unfulfilled. The signal to look for: do the entities have behaviour, or only fields?
The honest framing: tactical DDD is front-loaded — high upfront cost, payback over years through change-resilience and design clarity. For domain-heavy long-lived systems, the trade is worthwhile; for simple or short-lived systems, the overhead is unjustified.
9. Migration Path
Migrating to tactical DDD from procedural service-layer code is feature-by-feature.
Stage 1 — Identify a domain-rich feature. Pick one feature whose business rules are non-trivial (typically not simple CRUD). Audit the existing code to see where the rules live: probably in service classes operating on flat entity data.
Stage 2 — Convert the entity from anemic to behavioural. Add methods to the entity that absorb the rules. Order.Cancel(reason) instead of OrderService.cancel(order, reason). Customer.UpdateEmail(newEmail) instead of CustomerService.updateEmail(customer, newEmail). The methods enforce invariants and emit domain events.
Stage 3 — Identify and define value objects. Spot the primitive types being passed around (strings for emails, decimals for money, pairs of strings for addresses) and replace them with value objects (EmailAddress, Money, Address). The replacements force validation and behaviour into the value objects and remove primitive-obsession.
Stage 4 — Establish aggregate boundaries. For each entity cluster, ask the aggregate-boundary question: what invariants require atomic consistency? Promote the cluster to an aggregate with a clear root. Rewrite repositories to work with aggregate roots only.
Stage 5 — Introduce domain events. For every state change worth advertising, emit a domain event. Add an event-publishing infrastructure (in-process bus or distributed bus). Wire reactions to domain events for cross-aggregate coordination.
Stage 6 — Extract domain services. For cross-aggregate operations that are not naturally on any single aggregate, extract Domain Services. Audit them periodically to ensure they have not become dumping grounds.
Stage 7 — Apply ArchUnit / NetArchTest enforcement. Lint rules ensure aggregates are not bypassed (no direct access to internal entities), domain services do not have state, and value objects are immutable. Without enforcement the discipline drifts.
The migration runs for quarters; what matters is that each step is shippable. The single most important early signal is that entities have non-trivial methods (not just getters and setters); if Stage 2 succeeds and entities are behavioural, the rest of the migration tends to flow.
10. Pitfalls
-
Anemic Domain Model. The most common failure: entities have only fields and getters/setters; all behaviour is in service classes. Fowler (2003) named this anti-pattern; it is the opposite of tactical DDD’s intent. The fix is to push behaviour onto entities. The diagnostic: look at the entity classes and ask “do they have methods that enforce rules or emit events?” If no, the model is anemic.
-
Oversized Aggregates. A team is conservative about consistency and includes too many entities in one aggregate. The result: every transaction loads a large object graph; concurrency contention spikes (multiple users competing to update the same aggregate); aggregate save becomes slow. Vernon’s Effective Aggregate Design rule “design small aggregates” exists to prevent this. The fix is to identify which invariants truly require atomicity and split aggregates that do not share invariants.
-
Undersized Aggregates. The opposite error: a team splits aggregates that should be one, breaking real invariants. The system has lost atomic enforcement and now requires complex cross-aggregate coordination (sagas, eventual consistency) for what was a simple transactional update. The fix is to identify the broken invariants and merge aggregates that share them.
-
Generic Repository as CRUD Wrapper. A team defines
IRepository<T>withAdd,Update,Delete,FindById,FindAll,FindWhere(predicate)and uses it for every aggregate. The repository has become a generic ORM façade, encouraging CRUD-shaped persistence access that ignores aggregate boundaries. The fix is to define repository interfaces per aggregate with domain-meaningful methods; if the repository’s method set is justAdd,Update,Delete, the team has lost the collection-like-access intent. -
Domain Service as Dumping Ground. Engineers who do not know where to put a piece of logic dump it in a Domain Service. Over time the Domain Services ring fills with miscellaneous behaviours that should be on entities. The fix is periodic audits: for every method on a Domain Service, ask “could this be a method on an entity or value object?” If yes, move it.
-
Confusing Domain Events with Commands. A Domain Event is a record of what happened; a Command is an instruction to do something. Confusing the two produces event-sourced systems that look procedural — events named in imperative (“ProcessOrder”) rather than past tense (“OrderProcessed”). The discipline: events are always past tense; commands are always imperative.
-
Mutable Value Objects. A team writes a “Money” class with a setter for amount. The class is no longer a value object; it is a mutable struct. The whole calculus of value-object safety depends on immutability. The fix is rigorous: value objects have private setters or no setters; operations return new instances.
-
Ignoring the Bounded Context. Tactical DDD applies within a bounded context (see Domain-Driven Design Strategic Patterns). A team that applies tactical DDD globally — one giant model spanning Sales, Inventory, Shipping, Returns — fights the model. The fix is to identify bounded contexts first (strategic DDD) and apply tactical DDD per context.
11. Comparison With Sibling Architectures
Tactical DDD vs Anemic Domain Model. The Anemic Domain Model is structurally similar to tactical DDD — entities, services, repositories — but lacks the behaviour-on-entities discipline. Anemic models are procedural code wearing OO clothing; tactical DDD is genuinely OO with rules attached to the entities they govern. The distinction is whether the entity classes have non-trivial methods; it is observable in code.
Tactical DDD vs Active Record. Active Record (Fowler 2002, PoEAA) attaches database access to the entity itself: Customer.save(), Customer.find_by_email(email). Tactical DDD separates persistence (Repository) from the entity. Active Record is simpler for trivial CRUD; tactical DDD scales better to complex domains with aggregates and invariants.
Tactical DDD vs Transaction Script. Transaction Script (Fowler 2002) procedurally encodes business operations in service methods that read flat data and write it back. Tactical DDD localises the operations on entities and aggregates. Transaction Script is appropriate for systems with simple rules; tactical DDD scales better with complexity.
Tactical DDD vs Hexagonal/Clean/Onion. Tactical DDD populates the inside of Hexagonal Architecture, Clean Architecture, or Onion Architecture; the architectures provide the dependency-direction discipline, tactical DDD provides the building blocks for the domain core. The combinations are common: a Hexagonal application with tactical-DDD-shaped entities and aggregates inside the hexagon; a Clean application with DDD aggregates in the Entities ring; an Onion application with DDD aggregates in the Domain Model.
Tactical DDD vs Strategic DDD. Strategic DDD (Domain-Driven Design Strategic Patterns) concerns which models exist, how they relate, and what bounded contexts the system has. Tactical DDD concerns how each model is implemented. Both are necessary; strategic DDD comes first (you cannot do tactical DDD well without bounded contexts), but tactical DDD is what gets written in code.
12. Common Interview Discussion Points
“Define an Aggregate.” A strong answer: “A cluster of entities and value objects treated as a single transactional consistency boundary, with one Aggregate Root that is the only externally-referenceable member.” A weak answer: “A group of related entities.”
“Where would you put the aggregate boundary in this system?” Interviewers love this question because it filters for genuine experience. Strong candidates apply Vernon’s rules (true invariants, small aggregates, reference by identity, eventual consistency outside) and produce an answer with explicit justification. Weak candidates answer “I would put everything related into one aggregate” without thinking about transaction scope.
“What’s the difference between an Entity and a Value Object?” A precise answer cites identity (entities have it; value objects do not) and immutability (value objects are immutable; entities have lifecycle). A weaker answer cites only “entities are bigger” or “value objects are simpler.”
“How does a Domain Service differ from an Application Service?” Domain Services contain domain rules that span aggregates; Application Services contain use-case orchestration (transaction management, external coordination, request translation). Confusing the two is a frequent error.
“How do Domain Events relate to Event Sourcing?” A strong answer distinguishes the two: Domain Events are records of what happened; Event Sourcing uses Domain Events as the primary representation of state. A system can use Domain Events without being Event-Sourced; an Event-Sourced system necessarily uses Domain Events.
“How do Repositories interact with ORMs?” A nuanced answer covers the Spring Data Repository pattern, the pragmatic compromise of inheriting from JpaRepository, and the tension between domain-pure repositories and ORM-supplied generic methods.
“Have you seen tactical DDD fail?” Strong candidates cite specific anti-patterns from §10: the Anemic Domain Model, oversized aggregates, generic repositories, Domain Service dumping grounds. Weak candidates have not worked in real DDD codebases.
“Walk me through the building blocks.” A strong candidate names all seven (Entity, Value Object, Aggregate, Repository, Domain Service, Domain Event, Factory) with one-line definitions and one example each.
The signal value: tactical-DDD vocabulary suggests the candidate has worked in domain-heavy systems with deliberate modelling. Strong familiarity with Vernon’s Effective Aggregate Design rules and the aggregate-boundary question signals senior experience. The Anemic Domain Model is the most common failure mode; willingness to discuss it openly indicates mature engineering culture.
13. Worked Example — E-commerce Order Aggregate
The example develops the e-commerce Order aggregate fully, illustrating each tactical pattern.
13.1 Value Objects
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount == null || currency == null) throw new IllegalArgumentException();
amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
}
public Money add(Money other) {
require(currency.equals(other.currency), "currency mismatch");
return new Money(amount.add(other.amount), currency);
}
public Money multiply(int factor) {
return new Money(amount.multiply(BigDecimal.valueOf(factor)), currency);
}
public boolean isGreaterThan(Money other) {
require(currency.equals(other.currency), "currency mismatch");
return amount.compareTo(other.amount) > 0;
}
public static Money zero(Currency c) { return new Money(BigDecimal.ZERO, c); }
}
public record OrderId(UUID value) {
public static OrderId generate() { return new OrderId(UUID.randomUUID()); }
}
public record CustomerId(UUID value) {}
public record ProductId(UUID value) {}
public record Quantity(int value) {
public Quantity {
if (value <= 0) throw new IllegalArgumentException("quantity must be positive");
}
}Each value object is immutable, validates at construction, and carries domain operations.
13.2 Internal Entity — OrderLine
public final class OrderLine {
private final OrderLineId id;
private final ProductId productId;
private final Quantity quantity;
private final Money unitPrice;
public OrderLine(ProductId productId, Quantity quantity, Money unitPrice) {
this.id = new OrderLineId(UUID.randomUUID());
this.productId = productId; this.quantity = quantity; this.unitPrice = unitPrice;
}
public Money lineTotal() { return unitPrice.multiply(quantity.value()); }
public OrderLineId id() { return id; }
public ProductId productId() { return productId; }
public Quantity quantity() { return quantity; }
}OrderLine is an internal entity of the Order aggregate; it has identity (unique within the order) but is reachable only through the Order root.
13.3 Aggregate Root — Order
public final class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines = new ArrayList<>();
private final ShippingAddress shippingAddress;
private OrderStatus status;
private Money total;
private final List<DomainEvent> pendingEvents = new ArrayList<>();
private Order(OrderId id, CustomerId customerId, ShippingAddress address) {
this.id = id; this.customerId = customerId; this.shippingAddress = address;
this.status = OrderStatus.DRAFT;
this.total = Money.zero(Currency.getInstance("USD"));
}
public static Order place(CustomerId customerId, List<OrderLine> lines, ShippingAddress address) {
if (lines == null || lines.isEmpty())
throw new DomainException("order must have at least one line");
Order o = new Order(OrderId.generate(), customerId, address);
lines.forEach(o.lines::add);
o.recomputeTotal();
o.status = OrderStatus.PLACED;
o.pendingEvents.add(new OrderPlacedEvent(o.id, o.customerId, o.total, Instant.now()));
return o;
}
public void cancel(CancellationReason reason) {
if (status == OrderStatus.SHIPPED)
throw new DomainException("cannot cancel a shipped order");
if (status == OrderStatus.CANCELLED)
throw new DomainException("order is already cancelled");
status = OrderStatus.CANCELLED;
pendingEvents.add(new OrderCancelledEvent(id, reason, Instant.now()));
}
private void recomputeTotal() {
total = lines.stream().map(OrderLine::lineTotal).reduce(Money.zero(total.currency()), Money::add);
}
public List<DomainEvent> pendingEvents() { return List.copyOf(pendingEvents); }
public void clearEvents() { pendingEvents.clear(); }
public OrderId id() { return id; }
public Money total() { return total; }
public OrderStatus status() { return status; }
}The Order enforces invariants (at least one line; cannot cancel shipped orders; total = sum of line totals); accumulates domain events; maintains internal entities (OrderLine list) reachable only through itself.
13.4 Repository
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findActiveOrdersForCustomer(CustomerId customerId);
}The repository works with the aggregate root only; OrderLines are not directly accessible from outside the aggregate.
13.5 Domain Service
public final class PricingService {
public List<OrderLine> priceCart(Cart cart, Customer customer, ProductCatalog catalog) {
BigDecimal loyaltyDiscount = customer.loyaltyTier().discountFactor();
return cart.items().stream().map(item -> {
Money basePrice = catalog.lookupPrice(item.productId());
Money discounted = basePrice.applyDiscount(loyaltyDiscount);
return new OrderLine(item.productId(), item.quantity(), discounted);
}).toList();
}
}Pricing spans Customer (for loyalty) and ProductCatalog (for prices); it does not naturally fit on either, so it is a Domain Service.
13.6 Factory and Application Service
public final class PlaceOrderApplicationService {
private final OrderRepository orders;
private final CustomerRepository customers;
private final ProductCatalog catalog;
private final PricingService pricing;
private final DomainEventPublisher events;
public OrderId placeOrder(PlaceOrderCommand cmd) {
Customer customer = customers.findById(cmd.customerId())
.orElseThrow(() -> new CustomerNotFoundException(cmd.customerId()));
List<OrderLine> lines = pricing.priceCart(cmd.cart(), customer, catalog);
Order order = Order.place(customer.id(), lines, cmd.shippingAddress());
orders.save(order);
order.pendingEvents().forEach(events::publish);
order.clearEvents();
return order.id();
}
}The Application Service orchestrates: load Customer (separate aggregate, by ID), invoke Domain Service for pricing, construct Order aggregate via its Order.place static factory method, save, publish events. The single transactional boundary is around the Order save; the Customer is referenced by ID only and is not modified in this transaction (its lifetime-order-count update happens eventually via the OrderPlaced event).
13.7 The Pay-Off
The Order aggregate enforces its invariants atomically; cross-aggregate concerns are handled by domain events; the aggregate is small (one root, a few lines, value objects); the repository is collection-like; the domain service is focused on cross-aggregate logic; the factory method Order.place encapsulates creation. Unit tests instantiate the aggregate, exercise its methods, and assert on emitted events — fast, deterministic, exhaustive. The model is readable in domain vocabulary; rules are localised; changes are bounded.
14. Anti-Patterns Catalogue Beyond §10
The pitfalls in §10 are individually-occurring failures; the patterns in this section are clusters that frequently appear together when tactical DDD is mis-adopted. Diagnosing one pattern in a codebase usually reveals the others, and the remediation is rarely a single fix but a sustained re-engagement with the underlying principles.
A handful of recurring anti-patterns cluster together when tactical DDD is mis-adopted, supplementing the pitfalls in §10. The DTO-Aggregate Confusion substitutes flat data records for aggregates and treats getters as the API; the Service-Heavy Domain puts every operation in a Domain Service rather than on the entity that owns the rule; the Repository Generic Trap defines IRepository<T> for every aggregate uniformly, making CRUD the lowest-common-denominator API; the Eager Domain Event Bus publishes events synchronously inside aggregate methods, breaking the commit-then-publish discipline and producing events that fire even when transactions roll back. Each anti-pattern is observable in code review: anemic entity classes, oversized service classes, generic repository interfaces, and bus.publish calls inside aggregate state-changing methods. The remediation is uniform — restore the tactical-DDD discipline by pushing behaviour onto entities, splitting service classes, customising repositories per aggregate, and deferring event publication to the application service that owns the transaction.
15. Open Questions
- How small is “small” for an aggregate? Vernon’s rule of thumb is “as small as possible while preserving invariants,” but the practical inflection point varies by domain and by performance requirements.
- When ORM frameworks (JPA, EF Core) resist DDD-style aggregates (lazy loading, proxy semantics, identity-map quirks), how much purity should be sacrificed? The pragmatic answer is “as much as needed to ship,” but the principled answer is harder.
- Should value objects be shared across bounded contexts, or should each bounded context have its own? The strict-DDD answer is “each context has its own”; the pragmatic answer is “common types like Money can be shared.”
- How does tactical DDD compose with reactive/non-blocking I/O? Reactive types (Mono, Flux, Flow) tend to leak into aggregate methods; the pure-OO discipline of tactical DDD wants synchronous semantics, but production reactive systems blur the line.
- Is the Factory pattern still useful in 2026, or has the rise of immutable records and language-level
with-expressions made most factories unnecessary?
15. See Also
- Domain-Driven Design Strategic Patterns — bounded contexts and context maps; the strategic complement to tactical
- Hexagonal Architecture — the dependency-direction discipline that hosts a tactical-DDD core
- Clean Architecture — alternative dependency-direction discipline; tactical DDD populates the Entity and Use Case rings
- Onion Architecture — alternative concentric layering; tactical DDD populates the Domain Model and Domain Services rings
- Event Sourcing Pattern — uses Domain Events as the primary representation of state
- Event-Driven Architecture — Domain Events as the cross-context communication mechanism
- Command Query Responsibility Segregation — frequently composed with tactical DDD on the write side
- Saga Pattern — orchestrates multi-aggregate workflows via Domain Events
- Anti-Corruption Layer Pattern — the boundary between bounded contexts where tactical-DDD vocabularies meet
- Architecture Decision Records — adoption of tactical DDD is an ADR-worthy decision
- Microservices Architecture — typically one bounded context per microservice; tactical DDD inside each
- Conway’s Law — bounded contexts and team boundaries align; tactical DDD is per-team
- System Architectures MOC
- SWE Interview Preparation MOC