Domain-Driven Design (DDD) with Spring Boot — Practical Guide for Real Systems
A no-nonsense guide to Domain-Driven Design with Spring Boot — bounded contexts, aggregates, value objects, domain events and how to apply DDD without ceremony.
Introduction
Domain-Driven Design (DDD) gets a bad reputation because most introductions drown in vocabulary before showing a single line of code. This guide is the opposite. We'll build a small order-management system in Spring Boot and pick up the DDD concepts in the order you actually need them.
DDD is not a framework. It's a way of organizing complexity so that the code looks like the business — and stays that way as the business changes.
Key takeaways
- DDD's biggest win is alignment between code and domain experts through a shared *ubiquitous language*.
- Bounded contexts carve a large domain into independently-modeled chunks. Each context is its own micro-world.
- Aggregates are the unit of consistency — one transaction, one aggregate (almost always).
- Value objects make impossible states unrepresentable.
- Domain events decouple aggregates without distributed transactions.
Strategic vs tactical DDD
- Strategic DDD is the map-making part — discovering bounded contexts, drawing context maps, naming the language.
- Tactical DDD is the implementation toolkit — entities, value objects, aggregates, repositories, services.
You don't need to do all of strategic DDD to benefit from the tactical patterns. But applying tactical patterns inside an incoherent domain model just produces well-organized confusion. Spend a day on event storming with the business before you write a class.
Ubiquitous language
In an e-commerce company, "order" can mean four different things:
- To the storefront, an *order* is a checkout receipt.
- To the warehouse, an *order* is a pick list.
- To finance, an *order* is an invoiceable event.
- To shipping, an *order* is a parcel.
Forcing one Order class to satisfy all four is how you end up with 80 fields and a service called OrderHelperUtilManager. DDD says: let each context have its own Order. They reference each other by ID, not by sharing a class.
Bounded contexts
┌── Sales context ──┐ ┌── Fulfillment ──┐ ┌── Billing ──┐
│ Order │ │ Shipment │ │ Invoice │
│ Cart │ │ PickList │ │ Payment │
│ Customer (light) │ │ Warehouse │ │ Customer$ │
└───────────────────┘ └─────────────────┘ └─────────────┘
│ │ │
└────── domain events ──┴──── (Kafka or in-process) ────
In a modular monolith each bounded context is a module. In microservices each is a service. Either way, the contract between them is events and small API DTOs — never shared entities.
The reference domain
We'll model the Sales context: a customer places an order; the order is paid or cancelled; once paid it is handed off to Fulfillment via an event.
Step 1 — Value objects
Anything defined by its value (not identity) becomes a value object. They are immutable, self-validating, and comparable by value.
```java
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.signum() < 0) throw new IllegalArgumentException("Money cannot be negative");
}public Money add(Money other) { if (!currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch"); return new Money(amount.add(other.amount), currency); }
public static final Money ZERO_EUR = new Money(BigDecimal.ZERO, Currency.getInstance("EUR")); } ```
Money, Email, PostalAddress, Quantity, Sku — all value objects. The day you wrap them is the day a whole category of bugs vanishes.
Step 2 — Entities and identity
An entity has identity that persists through state changes. Two Orders with the same fields are still two different orders if their IDs differ.
public record OrderId(UUID value) {
public static OrderId generate() { return new OrderId(UUID.randomUUID()); }
}
Typed IDs prevent the classic service.charge(orderId, customerId) vs service.charge(customerId, orderId) bug. The compiler now catches it.
Step 3 — The aggregate
An aggregate is a cluster of objects treated as one unit for data changes. The aggregate root is the only entry point — outside code talks to the root, never to internal members.
```java
public final class Order {
private final OrderId id;
private final CustomerId customer;
private final List<OrderLine> lines = new ArrayList<>();
private OrderStatus status;
private final List<DomainEvent> events = new ArrayList<>();private Order(OrderId id, CustomerId customer) { this.id = id; this.customer = customer; this.status = OrderStatus.DRAFT; }
public static Order open(CustomerId customer) { Order o = new Order(OrderId.generate(), customer); o.events.add(new OrderOpened(o.id, customer, Instant.now())); return o; }
public void addLine(Sku sku, Quantity qty, Money unitPrice) { require(status == OrderStatus.DRAFT, "Can only add lines to a draft order"); lines.add(new OrderLine(sku, qty, unitPrice)); }
public void place() { require(status == OrderStatus.DRAFT, "Order already placed"); require(!lines.isEmpty(), "Cannot place an empty order"); this.status = OrderStatus.PLACED; events.add(new OrderPlaced(id, customer, total(), Instant.now())); }
public Money total() { return lines.stream().map(OrderLine::subtotal).reduce(Money.ZERO_EUR, Money::add); }
public List<DomainEvent> pullEvents() { var copy = List.copyOf(events); events.clear(); return copy; }
private static void require(boolean condition, String msg) { if (!condition) throw new IllegalStateException(msg); } } ```
Every invariant ("at least one line", "draft only") lives inside the aggregate. Application services cannot violate them because the methods don't let them.
The golden rule: one aggregate per transaction
If a use case modifies two aggregates, model the second change as an event the second aggregate reacts to in a separate transaction. This keeps each aggregate consistent and avoids large, blocking transactions.
Step 4 — Repository
The repository is an abstraction owned by the domain, not Spring Data:
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
The implementation lives in infrastructure (JPA, Mongo, whatever) — see the Hexagonal Architecture tutorial for the full pattern.
Step 5 — Application service
```java
@Service
@RequiredArgsConstructor
public class PlaceOrderHandler {
private final OrderRepository repo;
private final DomainEventPublisher publisher;@Transactional public OrderId handle(PlaceOrderCommand cmd) { Order order = Order.open(cmd.customer()); cmd.lines().forEach(l -> order.addLine(l.sku(), l.qty(), l.unitPrice())); order.place(); repo.save(order); publisher.publishAll(order.pullEvents()); return order.id(); } } ```
The service coordinates. It does not contain rules.
Step 6 — Domain events
public sealed interface DomainEvent permits OrderOpened, OrderPlaced, OrderCancelled {}
public record OrderPlaced(OrderId id, CustomerId customer, Money total, Instant at) implements DomainEvent {}
The Billing context listens for OrderPlaced and creates an invoice. Fulfillment listens and reserves stock. Neither knows the other exists.
Anemic vs rich domain models
The most common DDD anti-pattern: classes that are just getters and setters, with all the rules in a Service. That's an anemic model — DDD in name only.
```java
// ❌ anemic
order.setStatus(OrderStatus.PLACED); // no rule enforced
orderService.calculateTotal(order); // logic outside the data// ✅ rich order.place(); // rule lives with the data Money total = order.total(); ```
If your aggregate has more setters than behaviors, you have a database row pretending to be an object.
CRUD vs DDD
| | CRUD | DDD | |---|---|---| | Mental model | Tables | Behaviors | | Endpoints | Verbs over rows | Verbs over aggregates | | Validation | In controllers | In aggregates | | Best for | Forms, admin panels | Domains with real rules |
A reporting backend is fine as CRUD. A pricing engine, a payments system, a clinical trial workflow — those *need* DDD.
Event storming in 3 sentences
Get the business in a room. Cover a wall in sticky notes — orange for events (past tense), blue for commands, yellow for aggregates. The clusters that emerge are your bounded contexts and aggregates.
Common mistakes
- One huge aggregate — modeling the whole order *and* the customer *and* the invoice as one aggregate. Split them.
- DDD-flavored CRUD — calling your service
PlaceOrderServicewhile still using setters. - Sharing entities across contexts — Sales
Customerand BillingCustomerare different classes. Linked by ID, not by inheritance. - Doing all of DDD at once — adopt value objects and rich aggregates first. Bounded contexts and event sourcing later.
Production best practices
- Persist domain events in the same transaction as the aggregate change (transactional outbox pattern).
- Keep aggregates small — a good aggregate fits on a page.
- Use immutable commands and events (records).
- Map JPA entities to domain aggregates with a dedicated mapper; never annotate the aggregate directly with JPA.
FAQ
Do I need event sourcing to do DDD? No. Event sourcing is one *persistence* strategy. Plain JPA + domain events works for most systems.
Is DDD slow? The runtime cost is negligible. The design cost up-front is real and worth it for non-trivial domains.
Can I mix DDD and CRUD in one app? Yes — common, even. Apply DDD to the complex contexts and CRUD to the simple admin screens.
Related tutorials
Architecture
Domain-Driven Design — Bounded Contexts
TL;DR
Key takeaways
- Understand the core concepts behind Domain-Driven Design (DDD) with Spring Boot — Practical Guide for Real Systems in a production context.
- Apply the patterns to real Software Design & Architecture systems, not just toy examples.
- Recognize the trade-offs, failure modes, and operational concerns before adopting them.
- Get a clear path to the next step — related tutorials, tools, and reference architectures.
Avoid these
Common mistakes
1. Copy-pasting code without understanding the trade-offs
It's tempting to ship a snippet from a blog post into production, but Software Design & Architecture patterns only work when the failure modes are understood. Always reason about timeouts, retries, and consistency.
2. Skipping observability from day one
Structured logs, metrics, and traces are not optional. Wire them in before you ship — debugging Software Design & Architecture systems without them is painful and expensive.
3. Optimizing too early
Premature caching, sharding, or microservice extraction adds operational cost. Validate the bottleneck with real measurements first.
4. Ignoring security defaults
Secrets in env files, open management ports, missing RBAC — these are the most common production incidents. Treat security as part of the definition of done.
Ship it safely
Production best practices
Apply these before promoting Domain-Driven Design (DDD) with Spring Boot — Practical Guide for Real Systems to a real production environment.
Scalability
Design Software Design & Architecture services to scale horizontally. Keep request handlers stateless, push session and cache state to external stores (Redis, the database), and benchmark p95/p99 latency under realistic load before tuning.
Monitoring & Observability
Emit metrics (RED/USE), structured JSON logs, and distributed traces from day one. Wire dashboards and alerts to SLOs you actually care about — error rate, latency, saturation — not vanity metrics.
Logging
Log with correlation IDs, never log secrets or PII, and centralize logs (ELK, Loki, CloudWatch). Use levels deliberately: INFO for state changes, WARN for recoverable issues, ERROR for incidents.
Security
Apply least-privilege IAM, rotate secrets through a vault, validate every input, and patch dependencies on a schedule. For HTTP services, enable TLS everywhere and set sensible security headers.
Testing
Layer unit, integration, and contract tests. Run them in CI on every PR, and add smoke tests post-deploy. For Software Design & Architecture systems, also run chaos and load tests before a major release.
Reliability & Rollouts
Ship with health checks, readiness probes, graceful shutdown, and a rollback strategy. Prefer canary or blue/green deploys over big-bang releases.
Questions
Frequently asked questions
Is this tutorial up to date?
Yes. This tutorial was last reviewed and updated on May 26, 2026. We revisit popular Software Design & Architecture tutorials regularly to keep them aligned with current best practices.
What level is this tutorial aimed at?
It is written for working developers with some backend experience. Beginners can still follow along, and senior engineers will find production-grade patterns and trade-off discussions.
Do I need to follow every step in order?
The walkthrough is sequential because each step depends on the previous one. If you only need a specific concept, the table of contents at the top of the article lets you jump straight to that section.
Where can I find the source code?
Code samples are inlined in the tutorial. When a companion repository is published it will be linked at the top of this page.
Go deeper
Further reading
More From the Channel
Follow the full tutorial series on YouTube
The MasterLabSystems channel publishes in-depth, project-based tutorials on Java, Spring Boot, microservices, Docker, Kubernetes, AWS and DevOps — the same topics covered on this site, with full code walkthroughs.
Stay in the Loop
Get the next tutorial in your inbox
next tutorial →
Event-Driven Architecture with Spring Boot and Kafka — Building Reactive Distributed Systems
Related tutorials
Essential Software Design Principles Every Developer Should Know
A practical overview of the design principles that separate junior and senior engineers — DRY, KISS, YAGNI, SOLID, separation of concerns and more.
DRY, KISS, and YAGNI Explained
Three short acronyms that prevent most over-engineering — what DRY, KISS and YAGNI mean and how to apply them without going too far.
Composition Over Inheritance in Java
Why Java developers should prefer composition over inheritance — with side-by-side refactoring examples that show why composition scales better.
