Advanced
  • DDD
  • Software Architecture
  • Java

Domain-Driven Design: tactical design fundamentals

The tactical patterns of DDD, from concept to code: when the investment pays off and when a CRUD is enough.

15 min read
Domain-Driven Design: tactical design fundamentals

The biggest issue with software developers is that no one forces us to continuously learn the problem we are trying to solve.

Eric Evans

Eric Evans nails the problem DDD tries to solve. As developers we tend to focus on the technical solution (the framework, the language, the architectural pattern) and rarely on a deep understanding of the actual problem. Domain-Driven Design flips that priority: understand the domain first, and let it drive the technical decisions.

This is the first in a series of posts I am putting together on Domain-Driven Design. The focus here is the tactical design: the concrete patterns that show up in code. The next ones will go deeper on specific parts (Aggregates and Domain Events come to mind first), surface antipatterns that show up in practice, and at some point work through real refactorings.

The code snippets are in Java, but the focus is on the concepts. Anyone familiar with object-oriented programming should be able to follow along. The same patterns apply in C#, TypeScript, Kotlin, Python, or any class-based language.

What DDD is

DDD is an approach to software design proposed by Eric Evans in 2003, in Domain-Driven Design: Tackling Complexity in the Heart of Software. The central idea is that the code should reflect the language and concepts of the business.

Two pillars are non-negotiable:

  1. Ubiquitous Language: a vocabulary shared between developers and domain experts. If the business calls it "order", the code is named Order. If the business says "confirm an order", the method is confirm, not updateStatus. Sounds obvious, yet most codebases drift away from this within a few months.
  2. Continuous collaboration: developers don't get a requirements doc and disappear to code. They keep talking to people who understand the domain, often, to keep the model honest.

When DDD pays off

DDD is not a silver bullet. It shines when the domain is complex and the business has rules that evolve over time. In simple systems (pure CRUD, straight-through integrations), the modeling cost outweighs the benefit.

Three scenarios where the investment pays:

  • Real business complexity: rules that go beyond validating fields and saving to the database.
  • Longevity: systems that need to evolve for years without turning into a big ball of mud.
  • Collaborative teams: developers with actual access to domain experts.

If the system is mostly a CRUD where the value is storing and retrieving data, DDD will look like overengineering and probably is.

Strategic vs. tactical design

DDD splits along two dimensions:

  • Strategic is the macro level: how the domain is divided into pieces, how those pieces communicate, who owns what. It is the map of the territory.
  • Tactical is the micro level: inside each piece, how to model the domain in code. Which objects to use, how to protect business rules, how to keep things consistent.

Both matter, but this post focuses on the tactical side. That is where the code actually changes.

Bounded Contexts

Before getting to the tactical patterns, one strategic concept is unavoidable: Bounded Context.

A Bounded Context is an explicit boundary inside which a domain model is defined and applied. Within that boundary, every term has a precise meaning. The classic example: "Order" in Sales has price, items, and discount. "Order" in Logistics has shipping address, weight, and carrier. Same name, completely different models.

Three implications:

  • Each context has its own Ubiquitous Language.
  • Models do not leak across contexts, so changing one does not break the other.
  • Teams can evolve contexts independently.

In practice a Bounded Context can map to a module inside a monolith or to a microservice. The conceptual boundary is the same; deployment is what differs.

The tactical patterns

Four core concepts model the domain: Value Object, Entity, Domain Event, and Aggregate. I'll define each one and show real code (extracted from an e-commerce project).

Value Object

A Value Object is defined by its attributes, with no identity of its own. Two Value Objects with the same values are equal. R$ 100,00 is R$ 100,00. It does not matter "which" hundred-real bill it is, the value is the same.

Two foundational traits: it is immutable (any "change" produces a new object) and it encapsulates validation and behavior. A Money knows how to add itself, multiply itself, and never exists with a negative value.

public record Money(BigDecimal value, Currency currency) {
 
    public static final Currency DEFAULT_CURRENCY = Currency.getInstance("BRL");
 
    public Money {
        Objects.requireNonNull(value, "Money amount cannot be null");
        Objects.requireNonNull(currency, "Currency cannot be null");
 
        if (value.compareTo(BigDecimal.ZERO) < 0) {
            throw new InvalidMoneyAmountException(value);
        }
    }
 
    public static Money of(BigDecimal amount) {
        return new Money(amount, DEFAULT_CURRENCY);
    }
 
    public Money plus(Money other) {
        assertSameCurrency(other);
        return new Money(this.value.add(other.value), this.currency);
    }
 
    public Money times(int multiplier) {
        return new Money(this.value.multiply(BigDecimal.valueOf(multiplier)), currency);
    }
}

A few things to notice:

  • It is a Java record, which gives immutability and value-based equals/hashCode for free.
  • The compact constructor validates: an invalid Money never exists in the system.
  • plus and times return new objects. money.plus(other) does not mutate the original.
  • There is no id. Money is not an entity, it is a value.

Two more short examples:

public record Quantity(int value) {
 
    public Quantity {
        if (value <= 0) {
            throw new InvalidQuantityException(value);
        }
    }
 
    public Quantity plus(Quantity other) {
        return new Quantity(this.value + other.value);
    }
}
public record OrderId(String value) {
 
    public OrderId {
        if (value == null) {
            throw new InvalidOrderIdException();
        }
    }
 
    public static OrderId generate() {
        return new OrderId(UUID.randomUUID().toString());
    }
}

Quantity carries the rule "cannot be zero or negative" inside the object itself, instead of scattering it across ifs in services. OrderId is a typed identifier: instead of passing String everywhere and risking confusion between orderId and customerId, the compiler protects you.

Entity

An Entity has a unique identity that persists over time, even when its attributes change. A customer who changes address is still the same customer. Two customers with the same name but different IDs are distinct customers. The identity is what defines the entity, not the attributes.

public class Product {
 
    private final ProductId productId;
    private final ProductType type;
    private final Money unitPrice;
    private final SubscriptionCategory requiredSubscriptionCategory;
 
    // constructor omitted
 
    public Money calculateSubtotal(Quantity quantity) {
        return unitPrice.times(quantity.value());
    }
 
    public void validatePurchaseEligibility(SubscriptionCategory customerSubscription) {
        if (requiresSubscription()
                && customerSubscription.level() < requiredSubscriptionCategory.level()) {
            throw new InsufficientSubscriptionException();
        }
    }
 
    private boolean requiresSubscription() {
        return requiredSubscriptionCategory != SubscriptionCategory.FREE;
    }
}

Product has ProductId as its identity and uses Value Objects (Money, Quantity) as fields. The main point, though, is behavior: calculateSubtotal knows how to calculate the subtotal for a given quantity, and Money plus Quantity collaborate (unitPrice.times(quantity.value())). Each object does its part.

validatePurchaseEligibility is even more interesting: the product itself can tell whether a customer is allowed to buy it, based on subscription level. The requiresSubscription check is a private internal detail. Whoever uses Product only calls validatePurchaseEligibility, without needing to know how the rule works on the inside.

Entities are not data bags. They encapsulate behavior and domain rules.

Domain Event

A Domain Event represents something relevant that happened in the domain. Always named in the past tense: OrderSubmitted, PaymentApproved, OrderFailed.

The key point is decoupling. When an order is submitted, the aggregate records an OrderSubmitted. Anything that needs to react (sending an email, updating stock, notifying logistics) subscribes to the event. The aggregate does not need to know who consumes it.

public class OrderSubmitted implements DomainEvent {
 
    private final OrderId orderId;
    private final CustomerId customerId;
    private final Money totalAmount;
    private final Instant occurredAt;
 
    public OrderSubmitted(Order order) {
        this.orderId = order.id();
        this.customerId = order.customerId();
        this.totalAmount = order.totalAmount();
        this.occurredAt = Instant.now();
    }
 
    public String eventName() {
        return "order.submitted";
    }
}

The event is immutable (a fact that happened does not change) and carries only the data needed to identify what occurred. It receives Order in the constructor and pulls out only what it needs. Whoever consumes the event neither needs nor should have access to the full aggregate.

One important point: other Bounded Contexts can listen to this event. Logistics can react to OrderSubmitted to start preparing shipment. In practice this is implemented with messaging (Kafka, RabbitMQ), but the Domain Event itself is a domain concept, not an infrastructure one. The event exists independent of how it travels. That is why DDD pairs so well with event-driven architectures: the domain already produces the events naturally.

Here is the contract. The domain exposes a minimal publisher interface, agnostic of the underlying transport:

// domain/event/DomainEventPublisher.java
public interface DomainEventPublisher {
    void publish(DomainEvent event);
    void publish(Collection<DomainEvent> events);
}

The listener follows an equally simple contract, parameterized by the event type it cares about:

// domain/event/DomainEventListener.java
public interface DomainEventListener<E extends DomainEvent> {
    void on(E event);
}

Each concrete listener implements this interface for a specific event type and does something when it arrives: send a confirmation email, update a read model, publish an integration event to another bounded context, and so on. In practice the listener lives in the infrastructure layer: it knows the transport that triggers it (a Spring annotation, a Kafka consumer), it acts as an inbound adapter (the same role as an HTTP controller), and it ideally delegates the orchestration to a use case in the application layer. I will dig into that adapter/use-case split in a dedicated follow-up post.

This is the classic Observer pattern: the publisher keeps a list of observers and notifies all of them when something happens. In an in-process system the implementation can live entirely in memory (Spring ships ApplicationEventPublisher for exactly this). When you need to cross processes, the domain contract stays the same, but the publisher implementation serializes the event onto a broker (Kafka, RabbitMQ, SNS) and the listener becomes a consumer on the other side. Same pub/sub concept, different transport.

The split is what makes that swap painless: the publisher is an interface in the domain, the concrete implementation lives in infrastructure. Neither the aggregate nor the listener has to know which transport is in play.

Aggregate

Probably the most important concept in tactical design. An Aggregate is a cluster of objects treated as a unit. It has a root (the Order, in our case) that is the entry point, and everything inside the aggregate is only accessible through that root. Outsiders never manipulate items directly.

The root's responsibility is to guarantee the domain invariants. If the rule is "an order can only be submitted when it has items", the root is what enforces it. When something relevant happens, the aggregate emits the corresponding Domain Event.

public class Order extends AggregateRoot<OrderId> {
 
    private final OrderId id;
    private final CustomerId customerId;
    private final List<OrderItem> items = new ArrayList<>();
    private OrderStatus status;
 
    public static Order create(CustomerId customerId) {
        return new Order(OrderId.generate(), customerId);
    }
 
    public void addItem(Product product, Quantity quantity,
                        SubscriptionCategory customerSubscription) {
        product.validatePurchaseEligibility(customerSubscription);
        Money subtotal = product.calculateSubtotal(quantity);
        items.add(new OrderItem(product, quantity, subtotal));
    }
 
    public void submit() {
        if (items.isEmpty()) {
            throw new OrderValidationException("Order has no items");
        }
        transitionTo(OrderStatus.SUBMITTED);
        addDomainEvent(new OrderSubmitted(this));
    }
 
    private void transitionTo(OrderStatus newStatus) {
        this.status = status.transitionTo(newStatus);
    }
 
    public Money totalAmount() {
        return items.stream()
                .map(OrderItem::subtotal)
                .reduce(Money.ZERO, Money::plus);
    }
}

Look at how every concept connects:

  • create is a simple factory method, no side effects, the order starts empty with status CREATED.
  • addItem orchestrates: Product validates eligibility, calculates the subtotal, and the Order only adds the item.
  • submit is the business moment: it validates the invariant (any items?), transitions the status, and emits OrderSubmitted. The domain action and its event live together, in the same place.
  • transitionTo protects against invalid transitions. If someone attempts a transition that is not allowed, the domain throws. Not the service, not the controller; the domain itself protects its rules.
  • totalAmount is collaboration between Value Objects (Money.plus).

Notice also that nobody on the outside writes order.getItems().get(0).setQuantity(...). Everything goes through Order. There is a formal name for this, the Law of Demeter, but the idea is simple: an object should not reach into another object's structure to manipulate what is inside. The aggregate is that boundary.

Supporting patterns

The four patterns above model the domain. Three more show up around them as tools: Repository, Domain Service, and Factory.

Repository

The abstraction for loading and persisting aggregates. The interface lives in the domain, the implementation lives in the infrastructure. The domain code does not know that a database exists. It just asks for "the Order with this ID".

// domain/order/OrderRepository.java
public interface OrderRepository {
    Optional<Order> find(OrderId orderId);
    void save(Order order);
    boolean hasCustomerPurchasedProduct(CustomerId customerId, ProductId productId);
    PageResult<OrderSummary> listByCustomerId(CustomerId customerId, PageQuery pageQuery);
}
 
// infrastructure/persistence/jpa/JpaOrderRepository.java
public class JpaOrderRepository implements OrderRepository {
 
    private final SpringOrderJpaRepository springRepo;
    private final OrderItemMetadataBuilder metadataBuilder;
 
    @Override
    public void save(Order order) {
        springRepo.save(OrderJpaMapper.toJpa(order, metadataBuilder));
    }
 
    @Override
    public Optional<Order> find(OrderId orderId) {
        return springRepo.findById(UUID.fromString(orderId.value()))
                .map(OrderJpaMapper::toDomain);
    }
}

The methods speak the language of the domain. There is no selectById, insert, update. There is find, save, hasCustomerPurchasedProduct. The last one is a good example: instead of exposing a ready-made query, the repository offers a domain question.

What does it return? The aggregate. The whole Order, items and all. Not a DTO, not a database row. The domain always thinks in terms of aggregates. The JPA implementation lives in infrastructure, with a mapper isolating the translation. That inversion is what lets the domain stay in the center while the infrastructure stays at the edge.

Domain Service

For behavior that does not belong to any single entity. The classic example is a transfer between two bank accounts. It is not the action of account A, nor of account B. It is an operation that involves both.

public class MoneyTransferService {
 
    public void transfer(Account source, Account target, Money amount) {
        if (!source.currency().equals(target.currency())) {
            throw new CurrencyMismatchException(source.currency(), target.currency());
        }
        source.withdraw(amount);
        target.deposit(amount);
    }
}
 
public class Account extends AggregateRoot<AccountId> {
 
    public void withdraw(Money amount) {
        if (balance.isLessThan(amount)) {
            throw new InsufficientFundsException(id, balance, amount);
        }
        this.balance = balance.minus(amount);
    }
 
    public void deposit(Money amount) {
        this.balance = balance.plus(amount);
    }
}

Look at what MoneyTransferService does, and especially what it does not do. It does not decide whether account A has enough balance. That is an Account invariant and lives inside withdraw. If account A is short, withdraw throws and the transfer aborts. The service coordinates; each aggregate stays in charge of its own rules.

One thing the service does is validate that the currencies match. Why here and not on the account? Because that is a rule of the transfer operation, not of an individual account. Two accounts in different currencies are a valid state in the system. What is not valid is transferring between them without conversion.

Rule of thumb: if the rule fits inside an entity or aggregate, put it there. Domain Service is for rules that genuinely fit nowhere else. Abusing it makes the domain anemic, with services doing everything and entities turning into bags of getters.

Factory

A classic creation pattern that hides the construction of complex objects behind an interface. In DDD it matters because it keeps the "how this aggregate or entity is assembled" rule close to the aggregate itself, without polluting the constructor with lookups, cross-field validations, or polymorphism logic. The Order.create(...) from earlier is exactly that, a factory disguised as a static method. The cases where extracting a dedicated Factory class actually pays off (creation that depends on external data, polymorphism in the result, complex validations) are a topic for a follow-up post.

Where the domain lives in the architecture

A domain modeled with DDD has to be isolated from infrastructure concerns like databases, frameworks, and external APIs. The domain cannot depend on anything external. If the domain imports Spring, JPA, or anything from infrastructure, something is wrong.

Architectures like Ports & Adapters (Hexagonal) and Clean Architecture exist exactly for this: they put the domain in the center and have all dependencies pointing inward. Three layers:

LayerContents
domainValue Objects, Entities, Aggregates, Domain Events, Domain Services, Repositories (interfaces), Factories
applicationUse cases that orchestrate the domain
infrastructureDatabase, APIs, queues, frameworks, including the concrete implementations of the Repositories

DDD and these architectures travel together. One protects the other.

Putting it all together: a Use Case

To wrap up, here is how everything connects in a real use case:

public class CreateAndSubmitOrderUseCase {
 
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final CustomerRepository customerRepository;
    private final DomainEventPublisher eventPublisher;
 
    public void execute(CustomerId customerId, List<OrderItemRequest> itemRequests) {
        Customer customer = customerRepository.findById(customerId)
                .orElseThrow(() -> new CustomerNotFoundException(customerId));
        Order order = Order.create(customerId);
 
        for (OrderItemRequest request : itemRequests) {
            Product product = productRepository.findById(request.productId())
                    .orElseThrow(() -> new ProductNotFoundException(request.productId()));
            order.addItem(product, request.quantity(), customer.subscriptionLevel());
        }
 
        order.submit();
        orderRepository.save(order);
        eventPublisher.publish(order.pullDomainEvents());
    }
}

This use case fetches the customer, creates the order via the factory method, iterates over the items calling addItem (which internally validates eligibility and calculates the subtotal), and finally calls submit (which validates invariants, transitions status, and records OrderSubmitted inside the aggregate).

Notice how thin the use case is. Not a single if. Validates nothing. Calculates nothing. It only orchestrates: fetch data, delegate to the domain, save, publish events.

Those last two lines deserve attention. submit only adds the event to the aggregate's internal list. It hasn't gone anywhere yet. The use case is what pulls it out and dispatches, with pullDomainEvents() draining and eventPublisher.publish() delivering to whoever is listening (Kafka, an event store, an in-memory listener, whatever fits). Persistence and publication are distinct, explicit steps. In real projects this often becomes a template method on a base class, but I kept the mechanics visible here.

All the business logic lives in the domain objects: Money, Quantity, Product, Order. The use case is just the glue. Repositories and DomainEventPublisher are interfaces (ports). The implementation with JPA, Kafka, or whatever stays in the infrastructure. The domain neither knows nor cares.

What's next

That is the vocabulary. The next posts will go deeper on specific parts and show them in real code, including the classic antipattern of domain logic scattered across services, the refactoring to use the patterns we just covered, and the honest discussion of when not to use DDD, because sometimes the right answer really is to write a CRUD and move on.


Got a story, opinion, or pushback? A case of DDD that became overengineering, a system that turned into a big ball of mud from lack of structure? Ping me on LinkedIn, let's chat!