DEV Community

Said Olano
Said Olano

Posted on

🧭 Domain-Driven Design in Practice: Bounded Contexts, Aggregates, and Java Records

Why Domain-Driven Design Still Matters

Most software projects do not fail because of a lack of technology. They fail because the software does not match how the business actually works. Developers build tables and services around data structures, product owners describe features in their own vocabulary, and over time the code and the business drift apart. A "customer" means one thing to sales, another to billing, and a third to support, and the codebase quietly picks one of them without anyone deciding to.

Domain-Driven Design (DDD), introduced by Eric Evans in 2003, offers a different starting point. It treats the business domain as the primary source of design decisions. The model lives in the code, the language of the code matches the language of the business, and the boundaries of the system follow the boundaries of the domain rather than the boundaries of the database or the team chart.

DDD is not a framework or a library. It is a set of strategic and tactical practices. This article covers the ideas that deliver the most value in everyday Java and Spring Boot work, shows how they look in code, and closes with a practical adoption path that does not require rewriting your entire system.

Strategic Design: Finding the Right Boundaries

Strategic design answers the question of where one model ends and another begins. Its central concepts are the ubiquitous language, bounded contexts, and context maps.

Ubiquitous language is a shared vocabulary used by developers, domain experts, and product owners. If the business says "policy holder" and the code says "user," the mismatch will eventually produce bugs. Ubiquitous language is maintained through conversation, glossaries, and the names of classes and methods.

Bounded contexts are explicit boundaries inside which a particular model is consistent. The word "account" in a banking system might mean a customer's login in identity management, a deposit product in core banking, and a receivable in collections. Trying to force one model across all three creates a system that is hard to change. Giving each meaning its own bounded context lets each model be precise.

Context maps describe how bounded contexts relate to each other. Common relationships include partnership, customer-supplier, conformist, anticorruption layer, and open host service. Drawing the map is often the first valuable DDD exercise, because it makes implicit dependencies visible.

A useful rule of thumb: if two teams use the same word and mean different things, you have found a bounded context boundary.

Tactical Design: Modeling Inside a Context

Once boundaries are clear, tactical design shapes the model inside each context. The main building blocks are entities, value objects, aggregates, repositories, domain services, and domain events.

Value objects represent concepts defined entirely by their attributes. They are immutable and compare by value. In Java, records are a natural fit.

import java.math.BigDecimal;
import java.util.Currency;

public record Money(BigDecimal amount, Currency currency) {

    public Money {
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("amount and currency are required");
        }
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            amount = amount.setScale(currency.getDefaultFractionDigits(), java.math.RoundingMode.HALF_EVEN);
        }
    }

    public Money add(Money other) {
        requireSameCurrency(other);
        return new Money(amount.add(other.amount), currency);
    }

    public Money subtract(Money other) {
        requireSameCurrency(other);
        if (amount.compareTo(other.amount) < 0) {
            throw new IllegalArgumentException("Insufficient amount for subtraction");
        }
        return new Money(amount.subtract(other.amount), currency);
    }

    private void requireSameCurrency(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch: " + currency + " vs " + other.currency);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice what this type does not do. It does not accept a plain double for the amount, and it does not allow mixing currencies silently. Those rules belong to the domain, and they live in the type rather than in a service method that every caller must remember to call.

Entities have identity that persists over time. Aggregates are clusters of entities and value objects that are treated as a single unit for consistency. The aggregate root is the only entry point, and all changes to the aggregate go through it. This is the most important tactical rule in DDD: invariants are enforced by the aggregate root, not by the caller.

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

public class Order {

    private final UUID id;
    private final String customerId;
    private final List<OrderLine> lines = new ArrayList<>();
    private OrderStatus status = OrderStatus.DRAFT;
    private static final int MAX_LINES = 50;

    public Order(UUID id, String customerId) {
        this.id = id;
        this.customerId = customerId;
    }

    public void addLine(String productId, int quantity, Money unitPrice) {
        ensureEditable();
        if (lines.size() >= MAX_LINES) {
            throw new IllegalStateException("An order cannot exceed " + MAX_LINES + " lines");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        lines.add(new OrderLine(productId, quantity, unitPrice));
    }

    public void submit() {
        if (lines.isEmpty()) {
            throw new IllegalStateException("Cannot submit an empty order");
        }
        ensureEditable();
        this.status = OrderStatus.SUBMITTED;
    }

    public Money total() {
        return lines.stream()
                .map(OrderLine::lineTotal)
                .reduce(Money::add)
                .orElseThrow(() -> new IllegalStateException("Order has no lines"));
    }

    private void ensureEditable() {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("Order is " + status + " and cannot be modified");
        }
    }

    public UUID id() { return id; }
    public String customerId() { return customerId; }
    public OrderStatus status() { return status; }
    public List<OrderLine> lines() { return List.copyOf(lines); }
}
Enter fullscreen mode Exit fullscreen mode
import java.util.UUID;

public record OrderLine(String productId, int quantity, Money unitPrice) {

    public OrderLine {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
    }

    public Money lineTotal() {
        return new Money(
                unitPrice.amount().multiply(java.math.BigDecimal.valueOf(quantity)),
                unitPrice.currency());
    }
}
Enter fullscreen mode Exit fullscreen mode
public enum OrderStatus {
    DRAFT,
    SUBMITTED,
    FULFILLED,
    CANCELLED
}
Enter fullscreen mode Exit fullscreen mode

The key design decision here is that Order protects its own invariants. A caller cannot add lines to a submitted order, cannot submit an empty order, and cannot exceed the line limit, no matter which service or controller invokes it. The rules are expressed in the language of the domain, and they are tested in one place.

A common mistake is to create an anemic model: a class with getters and setters, and a service that performs all the logic. That approach moves the rules out of the model and scatters them across the application. An aggregate with behavior is harder to write at first, and far easier to maintain.

Repositories and Aggregate Boundaries

A repository provides the illusion of an in-memory collection of aggregates. Its interface should speak the domain language and return whole aggregates, not partial data. Persistence details belong in the implementation.

import java.util.Optional;
import java.util.UUID;

public interface OrderRepository {

    Optional<Order> findById(UUID id);

    void save(Order order);
}
Enter fullscreen mode Exit fullscreen mode

Aggregate boundaries should be small. A common error is to model the entire customer history, including all orders, inside the Customer aggregate. That makes every change contend for the same consistency boundary and leads to slow, fragile transactions. A better approach is to reference other aggregates by identifier, as Order does with customerId, and to coordinate across aggregates with domain events or application services.

Domain Events and Context Integration

Domain events record something meaningful that happened in the domain. They are the mechanism that lets one bounded context react to another without tight coupling. An order being submitted, a payment being captured, or a shipment being dispatched are all natural events.

import java.time.Instant;
import java.util.UUID;

public record OrderSubmitted(UUID orderId, String customerId, Money total, Instant occurredAt) {}
Enter fullscreen mode Exit fullscreen mode

The aggregate can record events as part of its behavior, and an application service publishes them after the transaction commits.

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

public class Order {
    // ... previous fields and methods

    private final List<OrderSubmitted> domainEvents = new ArrayList<>();

    public void submit() {
        if (lines.isEmpty()) {
            throw new IllegalStateException("Cannot submit an empty order");
        }
        ensureEditable();
        this.status = OrderStatus.SUBMITTED;
        domainEvents.add(new OrderSubmitted(id, customerId, total(), Instant.now()));
    }

    public List<OrderSubmitted> pullDomainEvents() {
        List<OrderSubmitted> events = List.copyOf(domainEvents);
        domainEvents.clear();
        return events;
    }
}
Enter fullscreen mode Exit fullscreen mode

An application service coordinates the use case, loads the aggregate, invokes its behavior, saves it, and publishes the resulting events.

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;

@Service
public class OrderApplicationService {

    private final OrderRepository orders;
    private final ApplicationEventPublisher events;

    public OrderApplicationService(OrderRepository orders, ApplicationEventPublisher events) {
        this.orders = orders;
        this.events = events;
    }

    @Transactional
    public void submit(UUID orderId) {
        Order order = orders.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));
        order.submit();
        orders.save(order);
        order.pullDomainEvents().forEach(events::publishEvent);
    }
}
Enter fullscreen mode Exit fullscreen mode

Publishing events inside the transaction is simple and works for in-process consumers. For cross-service integration, a transactional outbox is the safer pattern: the event is written to an outbox table in the same transaction as the aggregate, and a separate process publishes it to the message broker. This guarantees that a committed order is never silently missing its downstream notification.

Anticorruption Layers Between Contexts

When your bounded context consumes a model from another context, especially a legacy system, you should translate that model at the boundary. An anticorruption layer prevents foreign concepts from leaking into your domain.

Suppose a legacy billing system returns customer accounts with cryptic codes. Instead of letting "CUST-TYP-07" spread through your code, you translate it.

public enum CustomerSegment {
    RETAIL,
    SMALL_BUSINESS,
    ENTERPRISE;

    public static CustomerSegment fromLegacyCode(String code) {
        return switch (code) {
            case "CUST-TYP-01", "CUST-TYP-02" -> RETAIL;
            case "CUST-TYP-05" -> SMALL_BUSINESS;
            case "CUST-TYP-07" -> ENTERPRISE;
            default -> throw new IllegalArgumentException("Unknown legacy customer type: " + code);
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
import org.springframework.stereotype.Component;
import java.util.UUID;

@Component
public class BillingCustomerTranslator {

    private final LegacyBillingClient legacyClient;

    public BillingCustomerTranslator(LegacyBillingClient legacyClient) {
        this.legacyClient = legacyClient;
    }

    public CustomerSegment segmentFor(String legacyCustomerId) {
        LegacyCustomerRecord record = legacyClient.fetchCustomer(legacyCustomerId);
        return CustomerSegment.fromLegacyCode(record.typeCode());
    }
}
Enter fullscreen mode Exit fullscreen mode

The rest of the system deals only with CustomerSegment, a concept the domain understands. If the legacy system is replaced, only the translator changes.

Common Pitfalls

Teams adopting DDD tend to run into the same problems.

  • Modeling the database instead of the domain. Starting with tables and mapping them to classes produces anemic models. Start with the conversations about the business, then decide how to persist.
  • Giant aggregates. Large aggregates create contention and slow transactions. Keep them as small as the invariants allow.
  • One model for everything. Forcing a single "Customer" class across all contexts creates a bloated class that no one fully understands.
  • Skipping the ubiquitous language. If developers and domain experts use different words, the model will drift from the business.
  • Applying tactical patterns everywhere. Not every part of a system is complex. Simple CRUD areas, such as a reference data catalog, do not need aggregates and events. Apply DDD where the domain is complex and valuable.

Best Practices Checklist

Use this list to evaluate or start applying DDD in your team:

  • Hold regular sessions with domain experts and capture the vocabulary in a shared glossary.
  • Draw a context map and mark where the same word has different meanings.
  • Model value concepts with immutable types, such as records, and validate them at construction.
  • Keep aggregates small and enforce invariants inside the aggregate root.
  • Reference other aggregates by identifier rather than by object graph.
  • Use domain events for cross-aggregate and cross-context reactions, and use an outbox for reliable publishing.
  • Put an anticorruption layer in front of every legacy or external model you consume.
  • Classify subdomains as core, supporting, or generic, and invest most in the core.

A Practical Adoption Path

You do not need to rewrite a system to benefit from DDD. A realistic path for a team with an existing Spring Boot application looks like this.

In the first month, run event storming sessions with the business to discover the main domain events and the people who own them. Produce a first draft of the context map.

In the second month, choose one bounded context with clear value and visible pain. Introduce a glossary, rename the most confusing classes to match the language, and move business rules from services into aggregates.

In the third month, add an anticorruption layer around one legacy dependency and publish domain events through an outbox for the new context.

In the fourth month, review the results with the team. Measure the reduction in defects and in the time it takes to change a business rule. Decide where to extend the approach next.

Conclusion and Key Takeaways

Domain-Driven Design is a way of making software reflect the business it serves. Its strategic tools help you draw boundaries that match real differences in meaning. Its tactical tools help you protect business rules in code that a new developer can read and change safely.

The key takeaways are:

  • Use ubiquitous language so that code and conversation describe the same thing.
  • Define bounded contexts wherever the same word means different things.
  • Model value objects and aggregates that enforce their own invariants.
  • Keep aggregates small and connect them with domain events.
  • Protect your model from legacy systems with anticorruption layers.

Start with one conversation with a domain expert, one glossary, and one aggregate that protects a real rule. Those three steps will show you more about DDD than any diagram, and they create the foundation for everything else.

Top comments (0)