What this is: a deep, interview-ready tour of DDD that leads with invariants (what they really are, how you enforce them, and why they secretly decide your whole design) and then walks the full strategic + tactical map — bounded contexts, aggregates, entities, value objects, domain events, repositories, and how it all composes with CQRS/ES/sagas. Code is shown in Kotlin and Java, side by side, with Spring Boot.
The one mental model (everything below derives from this)#
An invariant is a business truth that must always hold. An aggregate is the boundary inside which you can guarantee that truth atomically. So invariants are not a detail — they are the organizing principle of the whole design: they decide what an aggregate is, what a single transaction may touch, and exactly where you are forced into eventual consistency.
If you internalize one sentence for interviews, make it this: "I let the true invariants draw my aggregate boundaries, and everything a rule can't be transactionally consistent about becomes an eventual-consistency problem." That single line connects DDD's tactical patterns (aggregates, value objects), its strategic patterns (bounded contexts), and modern distributed-systems concerns (sagas, outbox, CQRS) — which is why it reads as senior.
DDD's founding claim (Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, 2003) is that the hard part of most business software is the domain, not the technology, so the highest-leverage thing you can do is build a shared, precise model of the business in the code itself. Invariants are where that model stops being documentation and starts being enforced.
1. What an invariant actually is#
An invariant is a predicate (a condition) that must be true for the whole lifetime of an object — it holds before and after every operation, so that the object is never observable in a state that violates it. It is a statement of internal consistency, a truth of the model.
The word people confuse it with is validation. They are different jobs:
| Invariant | Validation | |
|---|---|---|
| Question it answers | "Is this object internally consistent / a legal state of the model?" | "Is this incoming input acceptable right now?" |
| When | Always — enforced at every transition | At a boundary, on input |
| On violation | The object is corrupt; this is a bug, throw | Expected; collect errors, show the user |
| Context | Intrinsic to the model | Can depend on use case, user, channel |
| Example | An order's total always equals the sum of its lines | "This coupon code is expired" / "email is required on this form" |
Also distinguish these from pre/postconditions (Bertrand Meyer's Design by Contract): a method's precondition is what the caller must guarantee before calling; the class invariant is what holds between method calls. In DDD terms, the aggregate's invariant is the class invariant — every public method must preserve it.
Concrete invariants you'd state in a domain:
Order.total == sum(line.subtotal)— a derived-consistency invariant.- A
PLACEDorder has at least one line item. Account.balance >= -overdraftLimit— a boundary invariant.DateRange.start <= DateRange.end— an attribute-relationship invariant.- A
Shipment.deliveredAtis never before itsshippedAt.
2. The scopes of invariants (the taxonomy interviewers want)#
The single most useful thing to be able to do out loud is classify an invariant by the scope it spans, because scope dictates the enforcement mechanism.
(a) Attribute-level invariants — a single value is well-formed: Money.amount has the right scale, EmailAddress matches a format, Quantity > 0. Enforce in a Value Object constructor. This is the cheapest, most reusable enforcement — you make the illegal state unrepresentable.
(b) Entity-level invariants — fields of one entity must be mutually consistent (deliveredAt >= shippedAt). Enforced in that entity's methods.
(c) Aggregate-level invariants — rules that span several entities/value objects that must stay consistent together (sum of order lines <= credit limit; the order-total rule). This is the central case in DDD, and it is enforced by the aggregate root, atomically, in one transaction.
(d) Cross-aggregate / cross-context invariants — rules that span multiple aggregates or bounded contexts: "a customer may have at most 5 active orders" (each order is its own aggregate), "username is unique across all users", "the sum of all reservations never exceeds inventory." These cannot be enforced in a single transaction at scale — they push you to eventual consistency, unique constraints, reservation patterns, or sagas (Part I §7).
The senior insight (Vernon's Rule 1): the set of invariants that must be immediately, transactionally consistent is exactly what defines an aggregate boundary. You don't pick aggregates by "what nouns feel like they belong together" — you pick them by "what must be true together in one atomic step."
3. The aggregate as a consistency boundary#
An aggregate is a cluster of associated objects (entities + value objects) treated as a single unit for data changes. One entity is the aggregate root; it is the only member outside code may hold a reference to, and it is the guardian of the aggregate's invariants.
Two consistency regimes fall directly out of this:
- Inside the boundary → transactional (immediate) consistency. One aggregate is modified in one transaction, and its invariants hold at commit. The aggregate is also your unit of concurrency control (one
@Version) and, in most designs, your unit of persistence/locking. - Outside the boundary → eventual consistency. Changes that must ripple to other aggregates happen through domain events, asynchronously, and the system is allowed to be briefly inconsistent.
The operational rule of thumb: modify one aggregate per transaction. Reaching across two aggregates in one transaction invites lock contention, deadlocks, distributed-transaction pain, and it usually means your boundaries are wrong.
4. Enforcing invariants — the patterns (with code)#
The overarching principle is the always-valid domain model: an object cannot be constructed or mutated into an invalid state. Four concrete techniques:
- Push attribute invariants into Value Objects — make illegal states unrepresentable.
- Encapsulate — no public setters on the aggregate; the only way to change state is a behavior method that re-checks invariants.
- Factories / factory methods — creation goes through a method that can reject invalid combinations.
- A single choke point — the aggregate root; external code never mutates internal entities directly.
Attribute invariant in a value object — Money:
Kotlin
import java.math.BigDecimal
import java.util.Currency
data class Money(val amount: BigDecimal, val currency: Currency) : Comparable<Money> {
init {
// Attribute invariants live here — an invalid Money simply cannot exist.
require(amount.scale() <= currency.defaultFractionDigits) {
"amount $amount has too many decimals for $currency"
}
}
operator fun plus(other: Money): Money {
require(currency == other.currency) { "cannot add $currency to ${other.currency}" }
return Money(amount + other.amount, currency)
}
override fun compareTo(other: Money): Int {
require(currency == other.currency) { "cannot compare $currency with ${other.currency}" }
return amount.compareTo(other.amount)
}
companion object {
fun zero(currency: Currency) = Money(BigDecimal.ZERO, currency)
}
}
Java (a record gives you value-equality + immutability for free; the compact constructor is the guard):
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Objects;
public record Money(BigDecimal amount, Currency currency) implements Comparable<Money> {
public Money { // compact constructor = invariant choke point
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException("too many decimals for " + currency);
}
}
public Money add(Money other) {
if (!currency.equals(other.currency))
throw new IllegalArgumentException("currency mismatch");
return new Money(amount.add(other.amount), currency);
}
public int compareTo(Money other) {
if (!currency.equals(other.currency))
throw new IllegalArgumentException("currency mismatch");
return amount.compareTo(other.amount);
}
public static Money zero(Currency currency) {
return new Money(BigDecimal.ZERO, currency);
}
}
Aggregate-level invariant — an Order root that guards "total ≤ credit limit", legal status transitions, and "no empty placed order". Note there are no public setters; every mutator ends by re-checking invariants, and the collection of lines is exposed only as an unmodifiable view.
Kotlin
import jakarta.persistence.*
import java.util.UUID
enum class OrderStatus { DRAFT, PLACED, CANCELLED }
class Order private constructor(
val id: UUID,
var status: OrderStatus,
val creditLimit: Money,
) {
private val lines: MutableList<OrderLine> = mutableListOf()
// the aggregate is the unit of optimistic concurrency
var version: Long = 0
private set
val orderLines: List<OrderLine> get() = lines.toList() // no leaking the mutable list
fun addLine(sku: String, qty: Int, unitPrice: Money) {
check(status == OrderStatus.DRAFT) { "can only add lines to a DRAFT order" }
require(qty > 0) { "quantity must be positive" }
lines.add(OrderLine(UUID.randomUUID(), sku, qty, unitPrice))
enforceInvariants()
}
fun place() {
check(status == OrderStatus.DRAFT) { "only a DRAFT order can be placed" }
check(lines.isNotEmpty()) { "cannot place an empty order" }
enforceInvariants()
status = OrderStatus.PLACED
}
fun total(): Money =
lines.map { it.subtotal() }.fold(Money.zero(creditLimit.currency)) { a, b -> a + b }
private fun enforceInvariants() {
check(total() <= creditLimit) { "order total ${total()} exceeds credit limit $creditLimit" }
}
companion object {
fun draft(id: UUID, creditLimit: Money) = Order(id, OrderStatus.DRAFT, creditLimit)
}
}
Java
import jakarta.persistence.*;
import java.util.*;
public class Order {
private UUID id;
private OrderStatus status;
private Money creditLimit;
private final List<OrderLine> lines = new ArrayList<>();
// one version per aggregate root
private long version;
protected Order() { } // JPA only
private Order(UUID id, Money creditLimit) {
this.id = id;
this.status = OrderStatus.DRAFT;
this.creditLimit = creditLimit;
}
public static Order draft(UUID id, Money creditLimit) {
return new Order(id, creditLimit);
}
public void addLine(String sku, int qty, Money unitPrice) {
if (status != OrderStatus.DRAFT)
throw new IllegalStateException("can only add lines to a DRAFT order");
if (qty <= 0) throw new IllegalArgumentException("quantity must be positive");
lines.add(new OrderLine(UUID.randomUUID(), sku, qty, unitPrice));
enforceInvariants();
}
public void place() {
if (status != OrderStatus.DRAFT)
throw new IllegalStateException("only a DRAFT order can be placed");
if (lines.isEmpty())
throw new IllegalStateException("cannot place an empty order");
enforceInvariants();
this.status = OrderStatus.PLACED;
}
public Money total() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(Money.zero(creditLimit.currency()), Money::add);
}
private void enforceInvariants() {
if (total().compareTo(creditLimit) > 0)
throw new IllegalStateException("order total exceeds credit limit");
}
public List<OrderLine> getOrderLines() { return List.copyOf(lines); }
}
The point to say out loud: the root is the only place these rules live, so there is exactly one guardian and no code path can bypass it. That is the whole game.
5. Invariants under concurrency (the part juniors forget)#
An always-valid single-threaded model still breaks under concurrency. Two requests load the same Order, both pass total() <= creditLimit locally, both save — and the combined result violates the invariant (a classic lost update / write skew). The invariant held in each transaction and is violated in the database.
The aggregate boundary is what makes this tractable: the aggregate root is the unit of concurrency control. Put a version on the root and use optimistic locking — the second writer's commit fails and it retries against fresh state.
Kotlin — optimistic lock + retry at the application service
class OrderApplicationService(private val orders: OrderRepository) {
fun addLine(orderId: UUID, sku: String, qty: Int, price: Money) {
val order = orders.findByIdOrNull(orderId) ?: throw OrderNotFound(orderId)
order.addLine(sku, qty, price) // invariant enforced in-aggregate
// save is implicit on tx commit for a managed entity; version check happens here
}
}
Java
public class OrderApplicationService {
private final OrderRepository orders;
public OrderApplicationService(OrderRepository orders) { this.orders = orders; }
public void addLine(UUID orderId, String sku, int qty, Money price) {
Order order = orders.findById(orderId).orElseThrow(() -> new OrderNotFound(orderId));
order.addLine(sku, qty, price);
}
}
Key trade-off to name: optimistic locking (via @Version) is the default for aggregates — no held locks, cheap under low contention, you pay only on the rare conflict with a retry. Pessimistic locking (@Lock(PESSIMISTIC_WRITE) / SELECT … FOR UPDATE) is for genuinely hot aggregates where conflicts are frequent, at the cost of held locks and reduced throughput. Either way, notice the second reason to keep aggregates small: a large aggregate is a wide lock and a contention magnet.
6. Always-valid vs. deferred validation (a debate you should be able to referee)#
Two schools:
- Always-valid (Greg Young, echoed by Vernon): an aggregate is never in an invalid state; construction and every method preserve invariants; there is no separate "validate" step. Invariant violations throw.
- Deferred / "validate-then-save": allow a partially-built or draft state, accumulate errors, and validate at a boundary (common in form-heavy CRUD and with Bean Validation
@Valid).
The senior resolution is that these solve different problems and you use both, at different layers:
- Use input validation (
jakarta.validation,@Validon the DTO at the controller) to reject malformed requests and return a friendly, aggregated list of field errors. This is a boundary concern. - Use the always-valid domain model for true business invariants — enforced in the aggregate, throwing on violation, because reaching an invalid domain state is a bug, not a user error.
Trap to avoid: putting business invariants in Bean Validation annotations on your entity. Annotations can't express multi-field, multi-entity, stateful rules like "total ≤ credit limit only after this line is added" — and they run at the wrong layer.
7. Invariants that span aggregates & set-based invariants (the genuinely hard ones)#
The moment a rule spans aggregates — "email unique across all users", "≤ 5 active orders per customer", "sum of reservations ≤ inventory" — you cannot load "all users" or "all orders" into one aggregate and check atomically. Ranked options, with the trade-off you should state for each:
- Database constraint (unique index,
EXCLUDE/check constraint). Strongly consistent, trivial, battle-tested. Cost: a domain rule now lives in the schema (a small, widely-accepted leak). Default choice for uniqueness. - Domain service that queries first, constraint as backstop. A
UserRegistrationdomain service checksexistsByEmailfor a friendly error, but the unique index is what actually guarantees it under a race. Never rely on the check alone — read-then-write is a race. - Reservation / first-writer-wins pattern. Insert a reservation row keyed by the unique value; the loser gets a conflict. Useful when you need a hold before the entity fully exists.
- Eventual consistency + compensation (saga). Allow a brief violation, detect it asynchronously, and correct/compensate. Only valid when the business can tolerate the window.
- Redesign the boundary. Sometimes "these must be consistent together" is telling you they belong in one aggregate after all.
Domain service + backstop, in Spring:
Kotlin
class UserRegistration(private val users: UserRepository) {
fun register(email: EmailAddress, name: String): UserId {
// friendly pre-check (not a guarantee under concurrency)
if (users.existsByEmail(email)) throw EmailAlreadyUsed(email)
return try {
users.save(User.register(email, name)).id
} catch (e: DataIntegrityViolationException) {
throw EmailAlreadyUsed(email) // the UNIQUE index is the real guarantee
}
}
}
Java
public class UserRegistration {
private final UserRepository users;
public UserRegistration(UserRepository users) { this.users = users; }
public UserId register(EmailAddress email, String name) {
if (users.existsByEmail(email)) throw new EmailAlreadyUsed(email);
try {
return users.save(User.register(email, name)).getId();
} catch (DataIntegrityViolationException e) {
throw new EmailAlreadyUsed(email); // UNIQUE index is the true backstop
}
}
}
The line that sounds most senior here: "Not every rule a stakeholder states is a true invariant that must be transactionally consistent. I ask: what does a brief violation actually cost? If the answer is 'nothing for a few seconds,' it's an eventual-consistency problem, not an aggregate-boundary problem — and forcing it to be immediate would cost me scalability."
8. Where invariants must not leak — the anemic domain model#
If your entities are just field bags with getters/setters and all the rules live in service classes, you have an anemic domain model (named and criticized by Martin Fowler, 2003). The problem is precisely about invariants: with no single guardian, the same rule gets re-implemented in multiple services, some code path forgets it, and objects drift into invalid states. A rich model puts behavior — and therefore invariant enforcement — on the aggregate, so there is one authoritative place a rule can be true. (Anemic isn't always wrong — for thin CRUD it's fine — but don't call it DDD, and don't use it where invariants are non-trivial.)
Tactical patterns (aggregates, entities) are what juniors quote. Strategic design is what separates senior from staff, because it's about drawing boundaries at the system level so that models stay coherent and teams stay decoupled. Evans' own regret is that people read the tactical chapters and skipped these.
9. Ubiquitous Language#
A ubiquitous language is a single, rigorous vocabulary — shared by developers and domain experts — that appears identically in conversation, documentation, and the code (class names, methods, events). The test: a domain expert can read your method names aloud and agree they describe the business. When the business says "a policy lapses," you have policy.lapse(), not policy.setStatus("INACTIVE").
Why it matters for invariants: precise language is how you discover invariants. "What does it mean for an order to be placed?" surfaces "it must have at least one line and be within credit" — those are your invariants, extracted by pinning the language down.
10. Bounded Context — the most important strategic pattern#
A bounded context is an explicit boundary within which a particular model — and its ubiquitous language — is internally consistent and unambiguous. The same word means different things in different contexts, and that's fine because the boundary contains the meaning:
- "Customer" in Sales = a lead with preferences and a pipeline stage.
- "Customer" in Billing = a legal entity with a tax ID and payment terms.
- "Customer" in Support = a person with tickets and an entitlement level.
Trying to build one "Customer" class that serves all three is the classic mistake — you get a bloated god-object with fields that are null half the time and invariants that contradict each other. Three contexts, three Customer models, connected by a shared identity, is the DDD answer. A bounded context typically maps to a microservice boundary, a module, or at least a package — it's the natural seam for team ownership and independent deployment.
The link to invariants: a bounded context owns its invariants. An invariant only has to hold within the context that owns that model. Billing's "a customer has valid payment terms" is meaningless in Support. This is why cross-context rules are eventual-consistency problems — no single context can transactionally enforce something about a model it doesn't own.
11. Subdomains: Core, Supporting, Generic (problem space vs. solution space)#
A crucial distinction interviewers probe:
- A subdomain is a part of the problem space — a slice of the actual business.
- A bounded context is a part of the solution space — a boundary you design in software.
You aim for a clean 1:1 mapping (one bounded context per subdomain), but legacy systems often violate it, and naming that mismatch is a senior observation.
Three kinds of subdomain, which drive build-vs-buy and where to spend your best engineers:
| Subdomain type | What it is | Strategy |
|---|---|---|
| Core domain | Your competitive advantage, the reason the business wins | Build it in-house, invest your strongest people, apply full DDD rigor |
| Supporting | Necessary, business-specific, but not a differentiator | Build simply or outsource; less rigor |
| Generic | A solved problem everyone needs (auth, email, payments) | Buy/adopt an off-the-shelf solution; never hand-roll |
The staff-level move: "I spend modeling effort proportional to strategic value — deep DDD in the core domain, an off-the-shelf generic solution for auth, and a thin anemic model for the supporting CRUD. Applying full DDD everywhere is waste."
12. Context Mapping — how contexts (and teams) relate#
Once you have multiple contexts, you must define their integration and team relationships. This is context mapping, and it's simultaneously a technical and an organizational (Conway's Law) statement. The patterns, with Upstream (provider) / Downstream (consumer) direction:
| Pattern | Direction | What it means | When |
|---|---|---|---|
| Partnership | peer | Two teams succeed/fail together, coordinate planning & releases | Deeply interdependent contexts, aligned goals |
| Shared Kernel | peer | A small shared subset of the model/code both own jointly | High coordination cost is acceptable to avoid duplication |
| Customer–Supplier | U → D | Downstream's needs get a real voice in upstream's backlog | Upstream is willing to accommodate |
| Conformist | U → D | Downstream just adopts upstream's model, no translation | Upstream won't change and translation isn't worth it |
| Anticorruption Layer (ACL) | U → D | Downstream builds a translation layer to protect its model | Upstream's model is messy/legacy and you must isolate from it |
| Open Host Service (OHS) | U → D | Upstream publishes a well-defined API/protocol for many consumers | One provider, many consumers |
| Published Language | U → D | A shared, documented interchange format (schema/events), often with OHS | Formalizing the integration contract |
| Separate Ways | none | No integration; duplicate the capability | Integration cost > value |
| Big Ball of Mud | — | A tangled context with no clear model; recognize and wall it off | Legacy reality |
The two you must be fluent in for interviews are ACL and OHS/Published Language. The Anticorruption Layer is the DDD name for "don't let their ugly model infect mine" — you translate the upstream's DTOs into your own clean domain model at the edge (this is exactly where a legacy-integration or strangler-fig migration lives). Say it like: "I put an anticorruption layer between our new ordering context and the legacy ERP so the ERP's data model never leaks into our domain — the ACL is the only place that knows the ERP's quirks."
These are the "design patterns" of DDD. All of them exist to serve the strategic goal (a coherent model) and, especially, to make invariant enforcement have a home.
13. Entities — identity over attributes#
An entity is an object defined by a continuous identity and lifecycle, not by its attributes. Two customers named "Sam Lee" are different people; a customer who changes their name is the same customer. So equality is by ID, not by value — and mutability is expected.
Kotlin
class Customer( val id: CustomerId) {
// identity-based equality — two Customers are equal iff same id
override fun equals(other: Any?) = other is Customer && other.id == id
override fun hashCode() = id.hashCode()
}
Java
public class Customer {
private CustomerId id;
public boolean equals(Object o) {
return (o instanceof Customer c) && id.equals(c.id);
}
public int hashCode() { return id.hashCode(); }
}
Interview trap: do not let Lombok @Data/@EqualsAndHashCode generate value-equality over all fields on a JPA entity — it breaks identity semantics and interacts badly with lazy proxies and Hibernate collections. Equality is id-based, full stop.
14. Value Objects — equality by value, immutable, side-effect-free#
A value object has no identity; it's defined entirely by its attributes, is immutable, and two VOs with equal attributes are equal (Money(10, EUR) == Money(10, EUR)). VOs are where attribute invariants live (§4), and they should offer side-effect-free functions that return new VOs rather than mutating (money.add(x) returns a new Money).
Why they're a senior's favorite tool: they make illegal states unrepresentable and shrink the aggregate's job. EmailAddress, Money, DateRange, Quantity, Address, CustomerId should all be value objects, not String/BigDecimal/UUID primitives (avoiding "primitive obsession"). In JPA they map cleanly as @Embeddable / record components or Hibernate @Embedded. (See the Money implementation in §4.)
15. Aggregates & aggregate roots — and Vernon's four rules#
The aggregate was covered as a consistency boundary in Part I; here is the design checklist. Vaughn Vernon's four rules of aggregate design (Effective Aggregate Design, 2011; Implementing DDD, 2013) are the single most quotable framework in a DDD interview:
- Model true invariants in consistency boundaries. The invariants that must be immediately consistent define what goes inside one aggregate. (This is Part I in one sentence.)
- Design small aggregates. Prefer a root + a few value objects. Big aggregates = big transactions, lock contention, memory bloat, and lost-update risk. Bias toward small.
- Reference other aggregates by identity, not by object reference. An
Orderholds aCustomerId, not aCustomer. This keeps aggregates small, keeps transactions from silently spanning boundaries, and makes each aggregate independently loadable/persistable (and shardable). - Update other aggregates with eventual consistency. When acting on aggregate A must affect aggregate B, publish a domain event and let B react in a separate transaction. Only pull B into A's transaction if a true invariant genuinely spans both (and then reconsider whether they're really one aggregate).
Referencing by identity — rule 3 in code:
Kotlin
class Order private constructor(
val id: OrderId,
val customerId: CustomerId, // NOT a Customer object — a reference by identity
// ...
)
Java
public class Order {
private OrderId id;
private CustomerId customerId; // reference by identity, not @ManyToOne Customer
// ...
}
If you catch yourself writing @ManyToOne Customer inside Order, you've just merged two aggregates and opened the door to accidental cross-aggregate transactions. That's the anti-pattern rule 3 prevents.
16. Domain Events — how aggregates talk without coupling#
A domain event is an immutable record that something the business cares about happened, named in the past tense (OrderPlaced, PaymentReceived, PolicyLapsed). Events are how one aggregate causes effects in others without violating rules 3–4: A commits, publishes OrderPlaced, and B reacts in its own transaction. This is the seam where DDD meets event-driven architecture, the outbox pattern, sagas, and CQRS read-model updates.
Spring Data gives you a first-class mechanism: extend AbstractAggregateRoot, registerEvent(...) inside a behavior method, and the events are published when the repository saves the aggregate.
Kotlin
data class OrderPlaced(val orderId: OrderId, val total: Money) // past tense, immutable
class Order private constructor(/* ... */) : AbstractAggregateRoot<Order>() {
fun place() {
check(status == OrderStatus.DRAFT) { "only a DRAFT order can be placed" }
check(lines.isNotEmpty()) { "cannot place an empty order" }
status = OrderStatus.PLACED
registerEvent(OrderPlaced(id, total())) // published on save
}
}
class WhenOrderPlaced(private val inventory: InventoryService) {
// react AFTER the order tx commits — separate transaction, eventual consistency
fun on(event: OrderPlaced) = inventory.reserveFor(event.orderId)
}
Java
public record OrderPlaced(OrderId orderId, Money total) { }
public class Order extends AbstractAggregateRoot<Order> {
public void place() {
if (status != OrderStatus.DRAFT) throw new IllegalStateException("must be DRAFT");
if (lines.isEmpty()) throw new IllegalStateException("cannot place empty order");
this.status = OrderStatus.PLACED;
registerEvent(new OrderPlaced(id, total())); // published when saved
}
}
class WhenOrderPlaced {
private final InventoryService inventory;
WhenOrderPlaced(InventoryService inventory) { this.inventory = inventory; }
void on(OrderPlaced event) { inventory.reserveFor(event.orderId()); }
}
Two senior notes: (1) AFTER_COMMIT matters — you don't want side effects firing if the order transaction rolls back. (2) For cross-service reliability you don't publish straight to Kafka in this listener (a crash between commit and publish loses the event) — you write the event to an outbox table in the same transaction and relay it via CDC. That's the bridge to your outbox/CDC guides.
17. Domain Services vs. Application Services (a distinction people fumble)#
Not all behavior belongs on an entity or value object. When an operation is a genuine domain concept that doesn't naturally live on a single aggregate — often because it spans several — it goes in a domain service (stateless, named in domain language, e.g. TransferService.transfer(from, to, amount), PricingService). But keep it distinct from an application service:
| Domain Service | Application Service | |
|---|---|---|
| Layer | Domain | Application (use-case orchestration) |
| Contains | Business logic that spans aggregates / doesn't fit one entity | No business rules — orchestration only |
| Responsibilities | Enforce domain rules across aggregates | Transactions, load/save via repositories, security, mapping DTOs, publishing |
| Example | FundsTransferService decides transfer legality | TransferAppService opens a tx, loads accounts, calls the domain service, saves |
The trap: business rules leaking into application services is how you accidentally build an anemic model. The application service should read almost like a script — load, delegate to the domain, save — with the actual rules living in aggregates and domain services.
18. Repositories — one per aggregate root#
A repository is a collection-like abstraction for retrieving and persisting aggregate roots — and the rule is one repository per aggregate root, not per table/entity. You load a whole Order (with its lines), never an OrderLine on its own, because the line has no life outside its aggregate. Repositories are also where the persistence boundary matches the consistency boundary.
Kotlin
interface OrderRepository : JpaRepository<Order, OrderId> {
fun existsByCustomerIdAndStatus(customerId: CustomerId, status: OrderStatus): Boolean
}
Java
public interface OrderRepository extends JpaRepository<Order, OrderId> {
boolean existsByCustomerIdAndStatus(CustomerId customerId, OrderStatus status);
}
Senior nuance: a repository interface is a domain concept (it speaks the ubiquitous language: findOverdueOrders()), but its implementation is infrastructure — which is exactly the port/adapter split in hexagonal architecture (Part IV). Also resist adding a save(orderLine) — if you feel that urge, your aggregate boundary is probably wrong.
19. Factories — encapsulating complex, valid creation#
When constructing a valid aggregate or VO is non-trivial (invariants across several inputs, assembling child entities), a factory (a factory method on the aggregate, a dedicated factory class, or a domain service) encapsulates that so the object is born valid — the creation-time twin of the always-valid principle. In the examples above, Order.draft(...) and User.register(...) are factory methods. Use a separate factory class only when creation logic is heavy enough to deserve its own home.
20. Modules — packaging that speaks the language#
Modules (packages) should be named in the ubiquitous language and drawn along bounded-context / aggregate lines (e.g. com.acme.ordering, com.acme.billing), not by technical layer (controllers, services, repositories) across the whole app. Package-by-feature/context keeps a context's model together and its internals hideable — the compile-time echo of a bounded context.
21. Where the domain sits: Hexagonal / Onion / Clean#
DDD doesn't mandate an architecture, but it pairs naturally with hexagonal (ports & adapters), onion, and clean architecture — all variations on one rule: the dependency arrow points inward, toward the domain. The domain model at the center depends on nothing; infrastructure (JPA, Kafka, REST) depends on the domain via ports (interfaces the domain owns) and adapters (implementations at the edge).
- Domain layer — entities, value objects, aggregates, domain events, domain services, and repository interfaces (ports). No framework imports ideally.
- Application layer — application services orchestrating use cases and transactions.
- Infrastructure/adapters — repository implementations, REST controllers, messaging, the ACL to other contexts.
That OrderRepository interface living in the domain while its Spring Data implementation lives at the edge is the ports-and-adapters split. The payoff: the domain (where invariants live) is testable in isolation and insulated from framework churn.
22. DDD + CQRS, Event Sourcing, Sagas, Outbox (how your other guides connect)#
DDD is the modeling core that these patterns extend — worth stitching together explicitly in an interview:
- CQRS — the aggregate is a great write model (it enforces invariants) but an awkward read model (UIs want denormalized, cross-aggregate views). Split them: write through aggregates, read from purpose-built projections. DDD gives CQRS its command side.
- Event Sourcing — instead of storing current state, store the stream of domain events; the aggregate rebuilds state (and re-checks invariants) by replaying them. Aggregate = consistency boundary = event stream boundary. (Natural fit with domain events from §16.)
- Sagas / process managers — the concrete realization of "eventual consistency across aggregates" (rule 4). A saga listens to domain events and issues commands to other aggregates, with compensating actions instead of a distributed transaction. This is how cross-aggregate invariants (§7 option 4) get maintained.
- Outbox + CDC — the reliable delivery mechanism for domain events across service/context boundaries, so "publish after commit" can't lose messages (§16 note 2).
The throughline: aggregates decide the transactional boundary; domain events cross it; sagas coordinate across it; CQRS reads around it; outbox/CDC delivers reliably. Being able to draw that one picture is a staff-level answer.
Problem: the "god aggregate." Loading a Customer drags in every order, invoice, and address; transactions are huge and contended.
- Root cause: the aggregate was drawn by containment ("a customer has orders") instead of by invariants.
- Fix: apply rule 1 — only cluster what must be transactionally consistent. Split
Orderout as its own aggregate;Customerholds no orders;OrderreferencesCustomerId(rule 3).
Problem: an operation must update two aggregates atomically and you're reaching for a distributed transaction.
- Root cause: treating a rule that can tolerate a delay as if it were a hard, immediate invariant.
- Fix: ask "what does a brief violation cost?" If tolerable → eventual consistency via a domain event + handler/saga (rule 4). If truly intolerable → the two probably belong in one aggregate.
Problem: anemic model — entities are getter/setter bags, rules live in services.
- Root cause: procedural habits; persistence-first modeling.
- Fix: move behavior and invariant checks onto the aggregate; make the application service a thin orchestrator (§17).
Problem: one Customer/Product class shared across sales, billing, and support, full of nullable fields.
- Root cause: no bounded contexts — one model forced to mean everything.
- Fix: a model per bounded context, joined by shared identity; translate at the edges with an ACL (§10, §12).
Problem: lost update silently violates an invariant under load.
- Root cause: invariant checked in-memory with no concurrency control at commit.
- Fix:
@Versionoptimistic locking on the aggregate root + retry onOptimisticLockingFailureException; pessimistic lock only for hot aggregates (§5).
Problem: order.getLines().add(...) from a controller bypasses every rule.
- Root cause: the aggregate leaked its mutable internal collection.
- Fix: expose only an unmodifiable view (
List.copyOf/toList()); mutation happens only through intent methods likeaddLine(...)that re-check invariants (§4).
Problem: business invariants encoded as @NotNull/@Min Bean Validation on the entity, and they don't hold.
- Root cause: conflating boundary input validation with domain invariants.
- Fix: Bean Validation at the DTO/controller for input shape; always-valid enforcement in the aggregate for business rules (§6).
Problem: full DDD tactical patterns applied to trivial CRUD, and the team is drowning in ceremony.
- Root cause: ignoring the subdomain distinction.
- Fix: deep DDD only in the core subdomain; simple/anemic or off-the-shelf for supporting/generic (§11).
- Lead with invariants and boundaries, not building blocks. Juniors list "entities, value objects, repositories." Seniors say: "I find the true invariants, let them define aggregate boundaries, keep aggregates small, reference other aggregates by ID, and use events for everything across a boundary." Same patterns, but framed by the why.
- Always distinguish immediate vs. eventual consistency, and tie it to the business cost of a violation. This one habit signals distributed-systems maturity more than any pattern name.
- Name the four aggregate rules (Vernon) — model true invariants in a boundary, small aggregates, reference by identity, eventual consistency outside. It's the most efficient way to sound like you've actually read the literature.
- Separate problem space from solution space — subdomains (core/supporting/generic) vs. bounded contexts. Then connect strategy to staffing: full rigor in the core, buy the generic.
- Use ubiquitous language deliberately — mention that you name methods and events after what domain experts say, and that pinning language down is how you discover invariants.
- Know the ACL cold — it's the pattern that comes up in every legacy-integration and microservices-migration discussion.
- Show the composition — be ready to draw aggregate → domain event → outbox/CDC → saga → CQRS read model as one coherent story. That's the staff-level payoff.
Common wrong answers / traps to avoid saying:
- "DDD means using entities and repositories" (that's tactical trivia; the value is strategic).
- "Aggregates group related objects" (no — they group objects that share invariants and a transaction).
- "We keep everything consistent with transactions" (doesn't scale; reveals no grasp of eventual consistency).
- "One big Customer model reused everywhere is DRY" (it's the bounded-context anti-pattern; some duplication across contexts is correct).
- Conflating domain and application services, or putting rules in the latter.
- Treating validation and invariants as the same thing.
Cheat-sheet#
Invariant scope → enforcement mechanism
| Scope | Example | Enforce with |
|---|---|---|
| Attribute | Money scale, Email format, qty > 0 | Value object constructor |
| Entity | deliveredAt >= shippedAt | Entity method |
| Aggregate | total ≤ credit limit; placed order non-empty | Aggregate root method + one transaction |
| Cross-aggregate (immediate) | none — reconsider the boundary | Merge into one aggregate, or accept it isn't immediate |
| Cross-aggregate (tolerable delay) | ≤ 5 active orders/customer | Domain event + handler/saga, compensations |
| Set-based uniqueness | unique email | DB unique constraint (+ domain-service pre-check) |
| Concurrency | no lost-update violation | @Version optimistic lock + retry |
Concept quick-reference
| Term | One-liner |
|---|---|
| Invariant | A business truth that must always hold; if violated, the object is corrupt |
| Aggregate | Cluster with one root, enforcing invariants atomically = a consistency boundary |
| Aggregate root | The only externally-referenced member; guardian of the invariants |
| Entity | Identity + lifecycle; equality by ID; mutable |
| Value object | No identity; immutable; equality by value; home of attribute invariants |
| Domain event | Immutable "something happened" (past tense); crosses aggregate boundaries |
| Domain service | Stateless domain logic spanning aggregates |
| Application service | Thin use-case orchestration; transactions, no business rules |
| Repository | Collection abstraction, one per aggregate root |
| Factory | Encapsulates complex, always-valid creation |
| Ubiquitous language | One rigorous vocabulary shared by code + experts |
| Bounded context | Boundary within which one model/language is consistent (≈ a service) |
| Subdomain | A slice of the problem space: core / supporting / generic |
| Context map | The technical + team relationships between contexts (ACL, OHS, Conformist…) |
| Anticorruption layer | Translation layer protecting your model from an upstream's |
Vernon's four aggregate rules: ① model true invariants in consistency boundaries ② design small aggregates ③ reference other aggregates by identity ④ use eventual consistency outside the boundary.
Self-test (say the answers out loud)#
- Define "invariant" and distinguish it from "validation." Give one example of each for an
Order. - Why do the invariants that must be immediately consistent determine your aggregate boundaries?
- Classify these by scope and name the enforcement mechanism:
qty > 0; order total ≤ credit limit; unique email; ≤ 5 active orders per customer. - An always-valid single-threaded aggregate still violates its invariant in production under load. How, and what's the fix?
- State Vernon's four aggregate rules. Why "reference by identity"?
- A stakeholder says "this rule must never be violated, even for a second." What questions do you ask before agreeing to enforce it transactionally?
- Difference between a bounded context and a subdomain? Which one is "problem space"?
- When would you build in-house vs. buy, in terms of subdomain types?
- Sales, Billing, and Support each need "Customer." One shared class or three? Defend it.
- What's an anticorruption layer and when do you reach for one?
- Domain service vs. application service — where does each rule live, and what's the smell that you've mixed them up?
- Why one repository per aggregate root and not per table?
- Why is
@ManyToOne CustomerinsideOrdera design smell? - How do domain events + outbox + saga + CQRS compose around an aggregate? Draw it.
- When should you not apply full DDD, and why is that itself a senior judgment?
Sources & lineage#
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003) — the origin; ubiquitous language, bounded contexts, aggregates, the tactical patterns.
- Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013) — the practical "red book"; and the Effective Aggregate Design essay series (2011) that states the four aggregate rules used throughout Part I and §15.
- Vaughn Vernon, Domain-Driven Design Distilled (2016) — the short strategic overview.
- Martin Fowler, "AnemicDomainModel" (martinfowler.com, 2003) — the anti-pattern in §8.
- Alistair Cockburn — Hexagonal (Ports & Adapters) architecture, the basis of §21.
- Framework specifics (
@Version,AbstractAggregateRoot,@TransactionalEventListener, Spring Data repositories) are Spring Boot 3 / Jakarta Persistence (jakarta.*) idioms; verify annotation availability against your exact Spring/Hibernate versions.
Pairs with your other InterviewPrep guides: aggregates → the saga and event-sourcing guides; domain events → Kafka/EDA, outbox, and CDC; write/read split → CQRS.