Tactical DDD

10 min read · Updated 2026-04-25

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

Anemic
Data + setters, no behavior
Java bean with getters and setters. All business logic lives in service classes that call set...() and call validate() everywhere.
Rich
Data + behavior, invariants enforced
Order.confirm() instead of order.setStatus(CONFIRMED). The domain object owns its rules. Service code coordinates; entity code enforces.

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)
Type safety
Pass CustomerId where OrderId is expected? Compile error, not a 3 a.m. bug.
Self-documenting
Method signatures express intent without comments.
Validation at construction
Email("not-an-email") throws — bad data never reaches downstream.
Immutability
New instance for changes. No accidental mutation.
Testability
Isolated business logic, easy unit tests.
Domain vocabulary
Code uses business words, not platform primitives.

Use value objects to:

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?

Entity
Single-entity invariants and identity
Logic that protects this object's state and lifecycle. order.confirm(), product.discontinue(), account.deposit(amount).
Domain service
Cross-entity, cross-context, or external
Multi-aggregate operations, external integrations, complex calculations spanning multiple contexts, frequently changing policies.

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:

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:

1. Start small
Pick a well-understood domain concept with several related service methods.
2. Identify rules
Extract validation, state transitions, and calculations from service classes.
3. Introduce value objects
Replace primitives with domain types where it adds clarity.
4. Encapsulate operations
Replace setters with intent-revealing methods (order.confirm() not setStatus).
5. Test the model
Rich domain logic is easier to unit-test in isolation.
6. Iterate
Don't try to retrofit the whole codebase. Convert as you touch each context.

Common Pushback

Recap