Sitemap
devdomain

DevDomain explores software development for insights, guides, and the latest industry news in helping all code-level coders. Delve deep into the newest trends in tech, level up your skills with expert advice, and discuss them with the rest of the community.

9 min readOct 13, 2026

--

Domain-Driven Design in Practice with Java and SpringDomain-Driven Design has a reputation problem. To some it’s a 500-page Eric Evans book they never finished; to others it’s an excuse to add the word “aggregate” to standup. The truth is more useful: DDD is a set of techniques for building software whose structure mirrors the business, so that complexity stays manageable as the system grows.

This article skips the philosophy and gets practical. We’ll build the tactical patterns — entities, value objects, aggregates, repositories, domain services — in real Java 21 / Spring Boot 3.x code, then zoom out to the strategic side: bounded contexts, the ubiquitous language, and anti-corruption layers. We’ll be honest about when DDD is overkill, and the traps that turn “DDD” into expensive ceremony.

The Real Problem DDD Solves

Software complexity comes in two flavors. Technical complexity (concurrency, caching, scaling) we have good tools for. Domain complexity — the tangle of rules about how a loan gets approved, when an order can be cancelled, how a subscription prorates — is what actually sinks most enterprise projects.

The failure mode is familiar: the code and the conversation drift apart. The business says “fulfillment,” the code says OrderProcessorServiceImpl. A rule that "a shipment can't leave without a customs declaration for cross-border orders" is buried in a 600-line service behind three boolean flags. DDD's core bet is that if the code speaks the same language as the domain experts, and is organized around domain concepts rather than technical layers, you can keep the complexity from compounding.

The Ubiquitous Language

Everything in DDD starts here. The ubiquitous language is a shared, precise vocabulary used by developers and domain experts alike — in conversation, in documentation, and in the code itself. If the underwriters say “policy,” “premium,” and “endorsement,” then your classes are Policy, Premium, and Endorsement — not InsuranceRecord, Cost, and Amendment.

This sounds trivial. It is not. The language is built through conversation with domain experts and refined constantly. When a developer renames endorsement to update because it's "simpler," they've introduced a translation gap that every future conversation has to pay for.

The language is bounded — it’s only consistent within a context. “Customer” in the Sales context (a lead with a pipeline stage) is a different thing from “Customer” in the Billing context (an account with payment terms). That observation leads directly to bounded contexts, which we’ll get to.

Tactical Patterns

Value Objects

A value object has no identity — it’s defined entirely by its attributes. Two Money instances of €10 are interchangeable. Value objects should be immutable and self-validating. Java records are made for this:

public record Money(BigDecimal amount, Currency currency) {
    public Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException("Too many decimal places for " + currency);
}
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
public Money multiply(int qty) {
return new Money(amount.multiply(BigDecimal.valueOf(qty)), currency);
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch: " + currency + " vs " + other.currency);
}
}
}

Value objects are where a huge amount of domain logic should live but usually doesn’t. An EmailAddress, a PhoneNumber, a DateRange, a Quantity — each can validate itself and expose meaningful operations, replacing a swamp of primitive Strings and ints. This is the antidote to primitive obsession.

public record DateRange(LocalDate start, LocalDate end) {
public DateRange {
if (end.isBefore(start)) {
throw new IllegalArgumentException("end before start");
}
}
public boolean overlaps(DateRange other) {
return !start.isAfter(other.end) && !other.start.isAfter(end);
}
public long days() { return ChronoUnit.DAYS.between(start, end); }
}

Entities

An entity has identity that persists through changes of state. A Customer whose email changes is still the same customer. Entities are identified by an ID, not by their attributes, and equality is based on that ID:

public class Customer {
    private final CustomerId id;        // identity
private CustomerName name; // value object
private EmailAddress email; // value object
private CustomerStatus status;
public Customer(CustomerId id, CustomerName name, EmailAddress email) {
this.id = Objects.requireNonNull(id);
this.name = Objects.requireNonNull(name);
this.email = Objects.requireNonNull(email);
this.status = CustomerStatus.ACTIVE;
}
public void changeEmail(EmailAddress newEmail) {
if (status == CustomerStatus.CLOSED) {
throw new IllegalStateException("Cannot change email on a closed account");
}
this.email = newEmail;
}
@Override public boolean equals(Object o) {
return o instanceof Customer c && id.equals(c.id);
}
@Override public int hashCode() { return id.hashCode(); }
}

The strongly-typed CustomerId (a record wrapping a UUID) is deliberate — it makes transfer(fromId, toId) impossible to call with the arguments swapped, a class of bug raw UUID parameters invite.

Aggregates — the most important and most misunderstood pattern

An aggregate is a cluster of entities and value objects treated as a single unit for data changes. It has one aggregate root — the only member outside code is allowed to hold a reference to. All modifications go through the root, which enforces the aggregate’s invariants.

Get Marcelo Domingues’s stories in your inbox

Join Medium for free to get updates from this writer.

Consider an Order with OrderLines. The rule "an order's total cannot exceed the customer's credit limit" is an invariant that spans lines. The root enforces it:

public class Order {                          // aggregate root
    private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines = new ArrayList<>();
private OrderStatus status = OrderStatus.DRAFT;
public Order(OrderId id, CustomerId customerId) {
this.id = id;
this.customerId = customerId;
}
public void addLine(ProductId product, Money unitPrice, int quantity) {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify a placed order");
}
lines.add(new OrderLine(product, unitPrice, quantity));
}
public void place(CreditLimit limit) {
if (lines.isEmpty()) throw new IllegalStateException("Empty order");
if (total().isGreaterThan(limit.available())) {
throw new CreditLimitExceededException(id, total());
}
this.status = OrderStatus.PLACED;
}
public Money total() {
return lines.stream().map(OrderLine::subtotal)
.reduce(Money.zero(), Money::add);
}
// No public getter exposing the mutable list:
public List<OrderLine> lines() { return List.copyOf(lines); }
}

Two rules of thumb that prevent most aggregate mistakes:

  1. Reference other aggregates by ID, not by object. Order holds a CustomerId, not a Customer. This keeps aggregates small, transaction boundaries clean, and prevents loading half the database.
  2. One transaction = one aggregate. A single use case should modify one aggregate. Need to update two? Use eventual consistency (a domain event) rather than spanning both in one transaction.

Entity Value Object Aggregate Has identity? Yes No Root does Mutable? Yes (controlled) No Via root only Equality by ID Attributes Root ID Lifespan Tracked Replaced wholesale Tracked as a unit Java fit Class record Class (root) + members

Repositories

A repository provides collection-like access to aggregates. Crucially, one repository per aggregate root — you do not get a repository for OrderLine, because lines only exist within an order. The repository interface belongs to the domain; its implementation lives in infrastructure.

// domain layer — the contract, in domain terms
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
List<Order> findPlacedOrdersFor(CustomerId customerId);
}
// infrastructure layer — Spring Data JPA backed implementation
@Repository
class JpaOrderRepository implements OrderRepository {
    private final SpringDataOrderRepository jpa;
private final OrderMapper mapper;
JpaOrderRepository(SpringDataOrderRepository jpa, OrderMapper mapper) {
this.jpa = jpa; this.mapper = mapper;
}
@Override public Optional<Order> findById(OrderId id) {
return jpa.findById(id.value()).map(mapper::toDomain);
}
@Override public void save(Order order) {
jpa.save(mapper.toEntity(order));
}
@Override public List<Order> findPlacedOrdersFor(CustomerId c) {
return jpa.findByCustomerIdAndStatus(c.value(), "PLACED")
.stream().map(mapper::toDomain).toList();
}
}

Yes, you can let a JPA entity be the domain object and skip the mapper — Spring Data makes it tempting. But the moment Hibernate’s lazy loading, dirty checking, or @Version semantics dictate how you model behavior, the domain is no longer in charge. For rich domains, keep them separate.

Domain Services

Some logic doesn’t belong to any single entity or Value object. A funds transfer involves two accounts; a “find the best shipping rate” decision spans carriers and rules. That’s a domain service — stateless, named in the ubiquitous language, operating on domain objects:

public class MoneyTransferService {   // domain service, pure, no @Service needed
    public void transfer(Account from, Account to, Money amount) {
from.withdraw(amount); // entities still enforce their own invariants
to.deposit(amount);
}
}

Don’t confuse a domain service (business logic that doesn’t fit one entity, framework-free) with an application service (Spring @Service that orchestrates a use case, handles transactions, and calls repositories). The application service is the conductor; the domain service is one of the instruments.

@Service
public class TransferFundsApplicationService {
    private final AccountRepository accounts;
private final MoneyTransferService transferService;
// constructor omitted @Transactional
public void execute(TransferCommand cmd) {
Account from = accounts.findById(cmd.from()).orElseThrow();
Account to = accounts.findById(cmd.to()).orElseThrow();
transferService.transfer(from, to, cmd.amount());
accounts.save(from);
accounts.save(to);
}
}

Domain Events

When something significant happens in the domain, raise an event. It decouples side effects and is the mechanism for keeping aggregates eventually consistent. Spring’s ApplicationEventPublisher works well, and Spring Data's AbstractAggregateRoot publishes events on save:

public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant occurredAt) {}
@Component
class OrderPlacedListener {
@EventListener
@Async
void on(OrderPlaced event) {
// send confirmation email, update read model, notify warehouse...
}
}

Strategic Design

Tactical patterns make a single model clean. Strategic design keeps you from trying to build one model for the whole company — which always fails.

Bounded Contexts

A bounded context is an explicit boundary within which a model and its ubiquitous language are consistent. “Product” in the Catalog context (name, images, description) is a different model from “Product” in the Inventory context (SKU, stock level, location). Forcing them into one shared Product class produces a god object that nobody owns and everybody fights over.

┌──────────────────┐   OrderPlaced   ┌────────────────────┐
│ SALES context │ ──────event───▶ │ FULFILLMENT │
│ Order, Customer │ │ Shipment, Picking │
│ (pipeline view) │ │ (logistics view) │
└──────────────────┘ └────────────────────┘
│ │
2 │ CustomerRegistered │
▼ ▼
┌──────────────────┐ ┌────────────────────┐
│ BILLING context │ │ INVENTORY │
│ Account, Invoice │ │ StockItem │
└──────────────────┘ └────────────────────┘

In Spring, a bounded context typically maps to a module (Gradle subproject or Maven module), and often — but not always — to a microservice. The context map documents how they relate: shared kernel, customer/supplier, conformist, or (the one you’ll reach for most) an anti-corruption layer.

Anti-Corruption Layer (ACL)

When your context integrates with a legacy system or a third-party API whose model is messy or just different, you build an anti-corruption layer: a translation boundary that converts the foreign model into your ubiquitous language, so the outside mess never leaks into your domain.

// Your domain speaks "Customer". The legacy CRM speaks "CUST_REC".
public interface CustomerLookup { // port, in your language
Optional<Customer> findByEmail(EmailAddress email);
}
@Component
class LegacyCrmAdapter implements CustomerLookup { // the ACL
private final LegacyCrmClient client; @Override public Optional<Customer> findByEmail(EmailAddress email) {
CustRec rec = client.lookup(email.value()); // foreign model
if (rec == null) return Optional.empty();
return Optional.of(translate(rec)); // -> your model
}
private Customer translate(CustRec rec) { // the corruption stops here
return new Customer(
new CustomerId(UUID.fromString(rec.getGuid())),
new CustomerName(rec.getFName(), rec.getLName()),
new EmailAddress(rec.getEmailAddr())
);
}
}

The ACL is one of the highest-leverage patterns in DDD: it lets a clean new context coexist with a legacy system without inheriting its sins.

When DDD Is Overkill

DDD has real costs — more code, more abstraction, more upfront modeling and conversation. Skip it when:

  • The domain is simple. A CRUD app over a few tables with no interesting rules doesn’t need aggregates. Spring Data over JPA entities is the right amount of architecture.
  • Logic is mostly technical, not domain. A file-conversion pipeline or a metrics collector has little “business” to model.
  • The team won’t invest in the language. DDD without ongoing domain-expert collaboration is just unusual package names. The strategic value evaporates.
  • Short-lived projects. The payoff is long-term maintainability; a three-week prototype won’t live long enough to collect it.

Apply DDD where complexity lives — the core domain that differentiates the business — and use simpler approaches for supporting and generic subdomains (like authentication or PDF generation, which you might buy or build trivially).

Common Pitfalls

  • Anemic domain model. Entities with only getters/setters and all logic in services. This is the most common “DDD” anti-pattern — it has the package names but none of the benefits. Behavior belongs with data.
  • Aggregates that are too big. Loading a Customer that drags in every order, payment, and address makes transactions slow and contention high. Keep aggregates small; reference by ID.
  • One model to rule them all. Refusing to split bounded contexts leads to a shared Customer god class. Embrace duplication across contexts — it's healthier than coupling.
  • Tactical patterns without strategic design. Sprinkling value objects everywhere while ignoring context boundaries gets you tidy classes inside one giant tangled model.
  • Letting persistence dictate the model. When @Entity, @ManyToOne, and fetch strategies drive your design, JPA is in charge, not the domain.
  • DDD on the wrong subdomain. Lavishing modeling effort on a generic subdomain (auth, logging) while neglecting the core domain inverts the value.

Closing Thoughts

DDD is not a framework you install; it’s a way of letting the business shape the software. Start with the ubiquitous language and a clear bounded context. Make your value objects immutable and your aggregates small. Keep the domain free of framework concerns, and wrap legacy integrations in an anti-corruption layer. Apply it where complexity earns it, and stay pragmatic everywhere else. Get this right and your code becomes a model of the business that both engineers and experts can read — which is the entire point.

Enjoyed this? Follow devdomain for more practical architecture and Java deep-dives. How do you draw the line between a domain service and an application service in your projects? Let me know in the comments.

--

--

Marcelo Domingues
Marcelo Domingues

Written by Marcelo Domingues

🚀 Lead Software Engineer for Siemens | Crafting Code & Words | Empowering Tech Enthusiasts ✨ 📲 LinkedIn:

devdomain
devdomain

Published in devdomain

DevDomain explores software development for insights, guides, and the latest industry news in helping all code-level coders. Delve deep into the newest trends in tech, level up your skills with expert advice, and discuss them with the rest of the community.