Tactical DDD
Tactical DDD provides the building blocks for implementing rich domain models inside bounded contexts. Where strategic DDD draws the boundaries, tactical DDD decides what lives inside them.
The core move is putting behavior next to data. The classic enterprise pattern of “data classes + service classes” — what Eric Evans named the anemic domain model — gets in the way as systems grow.
Anemic vs. Rich Domain Models
A typical anemic order:
public class Order {
private String id;
private String customerId;
private List<OrderItem> items;
private OrderStatus status;
private BigDecimal totalAmount;
// getters and setters...
}
@Service
public class OrderService {
public void processOrder(Order order) {
if (order.getStatus() != OrderStatus.PENDING)
throw new IllegalStateException("Only pending orders can be processed");
// calculate total here, set status here, invariants enforced from outside
}
}
The same thing, made rich:
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
private Money totalAmount;
public void addItem(Product product, int quantity) {
if (status != OrderStatus.PENDING)
throw new IllegalStateException("Cannot modify confirmed order");
items.add(new OrderItem(product, quantity));
recalculateTotal();
}
public void confirm() {
if (items.isEmpty())
throw new IllegalStateException("Cannot confirm empty order");
if (status != OrderStatus.PENDING)
throw new IllegalStateException("Order already processed");
this.status = OrderStatus.CONFIRMED;
}
}
Business rules are now visible in the entity. State transitions are validated at the source. The order can’t be put into an invalid state from the outside.
Value Objects: Rich Data Types
Value objects group related properties into cohesive, meaningful objects with encapsulated behavior. They replace primitive obsession (strings and numbers everywhere) with domain types.
Method signatures change shape:
// Primitive obsession
calculateShipping(BigDecimal amount, String currency,
String street, String city, String zip, String country)
// With value objects
calculateShipping(Money price, Address destination)
Use value objects to:
- Replace primitives that represent domain concepts (email, phone, money).
- Group fields that travel together (address components, coordinates).
- Encapsulate domain-specific behavior (calculations, validations, formatting).
Domain Services: Logic That Doesn’t Belong on an Entity
Some business logic doesn’t naturally sit on a single entity. Domain services hold the operations that span multiple entities or represent concepts that aren’t a single object.
@DomainService
public class OrderPricingService {
private final DiscountCalculator discountCalculator;
private final TaxCalculator taxCalculator;
public OrderTotal calculateTotal(Order order, Customer customer) {
Money subtotal = order.getSubtotal();
Money discount = discountCalculator.calculate(order, customer);
Money tax = taxCalculator.calculate(subtotal.subtract(discount), customer.getLocation());
return new OrderTotal(subtotal, discount, tax);
}
}
When does logic belong on an entity vs. a domain service?
Aggregates: Consistency Boundaries
An aggregate is a cluster of related entities and value objects treated as a single unit for state changes. Within a bounded context, aggregates define consistency boundaries — everything inside an aggregate must remain consistent, and the aggregate is the only path through which external code can modify the cluster.
public class Order { // Aggregate root
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items; // internal entities
private OrderStatus status;
public void changeItemQuantity(ProductId productId, int newQuantity) {
OrderItem item = findItem(productId);
if (item == null)
throw new IllegalArgumentException("Item not found in order");
if (status != OrderStatus.PENDING)
throw new IllegalStateException("Cannot modify confirmed order");
item.changeQuantity(newQuantity);
recalculateTotal();
// Business rule: orders > $1,000 need approval
if (totalAmount.isGreaterThan(Money.of("1000")))
this.status = OrderStatus.PENDING_APPROVAL;
}
}
Choosing aggregate boundaries is one of the most consequential decisions in DDD:
- Too big. Aggregates that span many entities create concurrency bottlenecks and slow performance.
- Too small. Cross-aggregate consistency requires distributed coordination.
The right size is “as small as possible, while still maintaining true business invariants together.”
Migrating From Anemic to Rich
A practical sequence for retrofitting tactical DDD onto an existing codebase:
Common Pushback
Recap
- Tactical DDD puts behavior next to data — replacing anemic models with rich ones.
- Value objects replace primitive obsession with domain types that encapsulate validation and behavior.
- Domain services hold logic that spans multiple entities or doesn’t fit on one.
- Aggregates define consistency boundaries; external code goes through the root.
- Migrating from anemic to rich is incremental: start with one concept, expand outward.