Domain-Driven Design in Practice
DDD applied rather than described: choosing aggregate boundaries, value objects that enforce invariants, repositories, hexagonal architecture, and running an event storming session.
On this page
Domain-driven design is often reduced to a folder layout. The parts that actually change outcomes are the modelling decisions: where the consistency boundaries are, which concepts deserve their own type, and where the domain ends and infrastructure begins.
Key Takeaways
- An aggregate is a consistency boundary — one transaction, one aggregate.
- Reference other aggregates by identifier, never by object reference.
- Value objects make invalid states unrepresentable and remove a whole class of bug.
- Hexagonal architecture points every dependency at the domain, never out of it.
- Apply DDD to the core domain only; supporting subdomains can be CRUD.
Value objects
The cheapest DDD technique with the highest return: give meaningful concepts their own type.
public record Money(long minorUnits, Currency currency) implements Comparable<Money> {
public Money {
// Validate in the constructor. An invalid Money cannot exist.
Objects.requireNonNull(currency, "currency is required");
if (minorUnits < 0) throw new IllegalArgumentException("amount cannot be negative");
}
public static Money of(long minorUnits, String currencyCode) {
return new Money(minorUnits, Currency.getInstance(currencyCode));
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(Math.addExact(minorUnits, other.minorUnits), currency);
}
public Money times(int quantity) {
return new Money(Math.multiplyExact(minorUnits, quantity), currency);
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
// The bug this prevents — adding EUR to USD — is invisible with
// BigDecimal and expensive when it reaches an invoice.
throw new CurrencyMismatchException(currency, other.currency);
}
}
@Override public int compareTo(Money other) {
requireSameCurrency(other);
return Long.compare(minorUnits, other.minorUnits);
}
}Compare void applyDiscount(BigDecimal amount) with void applyDiscount(Money amount). The first
accepts a percentage, a total, a quantity or a currency-mismatched figure. The second accepts only a
valid monetary amount in a known currency. The type does the checking that documentation and code
review otherwise have to.
The same applies to identifiers. OrderId and CustomerId as distinct record types make it
impossible to pass one where the other belongs — a mistake that String parameters permit silently
and that unit tests rarely catch.
Aggregates
@Entity
public class Order {
@EmbeddedId
private OrderId id;
// The identifier, not the entity. This is the single most important line
// in the class: it keeps the boundary real and makes the order extractable
// to another service later.
private CustomerId customerId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
/** The only way to create an Order. A no-arg public constructor would let
callers build an invalid one. */
public static Order place(CustomerId customerId, List<OrderLineRequest> requested) {
if (requested.isEmpty()) throw new EmptyOrderException();
if (requested.size() > 100) throw new TooManyLinesException(requested.size());
Order order = new Order();
order.id = OrderId.generate();
order.customerId = customerId;
order.status = OrderStatus.PLACED;
requested.forEach(order::addLine);
return order;
}
/** Behaviour, not a setter. The rule lives with the data it protects. */
public void cancel(String reason) {
if (status == OrderStatus.SHIPPED) {
throw new OrderAlreadyShippedException(id);
}
this.status = OrderStatus.CANCELLED;
}
public Money total() {
return lines.stream().map(OrderLine::subtotal)
.reduce(Money.zero(currency()), Money::plus);
}
}The absence of setters is deliberate. Every state change goes through a method that names a business
operation and enforces the rules for it. order.setStatus(SHIPPED) lets any caller skip the
validation; order.ship(trackingNumber) cannot.
The sizing rule: an aggregate should be as small as its invariants permit. "The order total must equal the sum of its lines" requires lines inside the boundary. "The customer's lifetime value must include this order" does not — that is a projection, updated eventually.
Hexagonal architecture
domain/ Order, Money, OrderPlaced, OrderRepository (interface)
— depends on NOTHING
application/ PlaceOrderUseCase — orchestrates, depends on domain only
adapters/in/ OrderController, OrderMessageListener
adapters/out/ JpaOrderRepository, KafkaEventPublisher, StripePaymentAdapter// Defined BY the domain, in domain language. No JPA, no Spring.
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
List<Order> findPendingOlderThan(Instant cutoff);
}@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpa; // the Spring Data interface
@Override
public Optional<Order> findById(OrderId id) {
return jpa.findById(id.value()).map(mapper::toDomain);
}
}The dependency direction is the whole point. The domain declares what it needs; infrastructure implements it. That means the domain can be unit-tested with no Spring context and no database, and swapping PostgreSQL for something else touches one package.
Be pragmatic about the cost. A full ports-and-adapters layout with separate persistence entities and mappers is genuinely more code. It is worth it for a complex core domain and unnecessary for a supporting subdomain — using JPA entities directly there is a reasonable trade, not a failure.
Event storming
The technique that finds boundaries faster than any diagram. Get domain experts and engineers in a room with a long wall.
Write every significant business event on an orange note, past tense — OrderPlaced,
PaymentCaptured, StockReserved. Arrange them left to right in time. Add blue notes for the
commands that cause each event, and yellow for the actors who issue them. Mark disagreements and open
questions with a bright colour and keep going.
Boundaries emerge as clusters where the vocabulary changes. When "order" means a basket on one side of a gap and a fulfilment instruction on the other, you have found a bounded context boundary — and the fact that one word means two things is the clearest signal there is.
The most valuable output is usually not the diagram but the disagreement. Two experts using the same word differently, discovered in a workshop, is a bug prevented; discovered in production, it is a data migration.
Where to apply it
Classify each part of the system first. The core domain is what your company is actually good at — invest here, model it properly, staff it well. Supporting subdomains are necessary but not differentiating — build them simply or buy them. Generic subdomains like authentication or invoicing are commodities — buy or adopt open source.
Applying full DDD everywhere is how teams end up with elaborate aggregates around a settings table. Applying it nowhere is how the pricing engine becomes unmaintainable. The classification is what tells you which is which.
What to take away
Start with value objects — they cost little and eliminate whole categories of bug. Draw aggregate boundaries around invariants, reference other aggregates by identifier only, and put behaviour on the aggregate rather than setters. Point dependencies inward at a framework-free domain, and reserve the full ceremony for the core domain where it pays.
Frequently Asked Questions
How big should an aggregate be?
Should aggregates reference each other directly?
Is DDD worth it for a CRUD application?
Related tutorials
- CQRS & Event SourcingSeparating reads from writes: CQRS without event sourcing, event stores and aggregate replay, building projections, snapshots, and an honest account of when not to use either.
- Transaction Management Deep DiveTransactions beyond the annotation: every propagation mode and when it applies, isolation levels and the anomalies they prevent, transaction-bound events, and why XA lost to sagas.
- Design Patterns for Java BackendsThe GoF patterns as Spring actually implements them, the ones worth writing yourself, and modern Java alternatives using records, sealed types and pattern matching.
- API Security & the OWASP API Top 10The API-specific vulnerability classes and their Spring fixes: broken object-level authorization, mass assignment, unrestricted consumption, SSRF, and API inventory management.