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.
Symptom: Anemic Models Breaking Invariants under High Concurrency
When looking at Order.java and seeing nothing but @Getter, @Setter, and a list of OrderItem objects, you’ve found a liability, not an asset. In a true DDD approach, the Aggregate is the boundary of consistency. If you see DataIntegrityViolationException or inconsistent state (e.g., an order marked PAID but with zero items), it’s because the service layer is manual-stearing the state.
In Spring Boot, we often mistake "Entity" for "JPA Managed Object." To fix a bleeding consistency boundary, you must move the logic from the @Service into the Aggregate.
```java
@Entity
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order extends AbstractAggregateRoot<Order> {
@Id
private UUID id;
@Enumerated(EnumType.STRING)
private OrderStatus status;@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "order_id") private List<OrderItem> items = new ArrayList<>();
// Business Invariant: You cannot add items to a paid order. // Business Invariant: Total amount cannot exceed credit limit. public void addItem(Product product, int quantity) { if (this.status != OrderStatus.CREATED) { throw new IllegalStateException("Cannot modify order in state: " + status); } if (quantity <= 0) { throw new IllegalArgumentException("Quantity must be positive"); }
this.items.add(new OrderItem(product.getId(), product.getPrice(), quantity)); // Registering a Domain Event for the side effects (Stock, Analytics) registerEvent(new OrderItemAddedEvent(this.id, product.getId())); } } ```
By making the constructor PROTECTED and removing setters, you force the business logic into the Aggregate. Run this check on your codebase:
grep -r "set" src/main/java/com/company/domain/models/ | wc -l
If the count is high, your "Domain Model" is just a database schema in disguise. The goal is to see that count drop in favor of domain-specific verbs like place(), cancel(), or fulfill().
Symptom: The "God Context" Causing Hibernate N+1 Explosions
If your User entity is linked to Order, Profile, BillingInfo, SupportTicket, and ShippingAddress, you haven't defined a Bounded Context. You have a Distributed Monolith inside a single JVM. This manifests as a 5xx spike when fetching a simple user profile because Hibernate tries to hydrate half the database.
In DDD, "User" in the Identity context is not the same as "Buyer" in the Ordering context. They might share a UUID, but they should be separate entities in separate packages (or separate microservices).
[ Identity Context ] [ Ordering Context ] [ Shipping Context ]
| | |
User(id, hash) Buyer(id, name) Receiver(id, addr)
| | |
+------- Shared ID ---------+---------------------------+
To diagnose bloated contexts, inspect your SQL logs during a standard GET request:
tail -f logs/application.log | grep "Hibernate: select" | uniq -c
If a single request triggers 15 different table joins across unrelated domains, you need to split the Aggregate. Use Value Objects for attributes that don't need their own lifecycle. A ShippingAddress should be a @Embeddable Value Object, not a separate entity with its own ID, unless you are building a dedicated Address Management System.
Symptom: Stale Data and Memory Leaks via Domain Event Loops
When using @EventListener in Spring, it’s easy to create a recursive chain. OrderPaidEvent triggers InvoiceCreatedEvent, which triggers EmailSentEvent, which accidentally updates the Order again, triggering a loop. This shows up as a StackOverflowError or a slow leak in the HikariCP connection pool as transactions stay open too long.
The fix is distinguishing between Synchronous Domain Events (same transaction) and Asynchronous Integration Events (out-of-process).
Use src/main/resources/application.yml to set a threshold for long-running transactions and catch these early:
spring:
jpa:
properties:
hibernate:
generate_statistics: true
logging:
level:
org.hibernate.stat: DEBUG
If you see transactional: true and the time is > 500ms for a simple update, you are doing too much in one unit of work. Offload side effects using Spring’s @TransactionalEventListener. This ensures the event is only processed *after* the database commit.
```java
@Component
public class InventoryHandler {@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(OrderPlacedEvent event) { // This runs in a NEW transaction or a separate thread // No risk of rolling back the original Order. inventoryService.reserve(event.getOrderId()); } } ```
Symptom: Integration Tests Taking 10+ Minutes
If your test suite requires @SpringBootTest and a full PostgreSQL container for every single business rule, you haven't implemented a Domain Layer. You've implemented a "Scripted Database" layer.
True DDD allows you to test the most complex logic (the Aggregates and Value Objects) with pure JUnit 5 and zero Spring context.
Run this command to find "heavy" tests that don't need the context:
grep -l "@SpringBootTest" src/test/java/**/*Test.java | xargs grep -L "mockMvc"
If a test has @SpringBootTest but doesn't touch the web layer or the database, refactor it to a POJO test. We moved our core pricing logic from a Spring-managed PricingService into a PriceCalculator Value Object. The result: p99 of the test suite dropped from 8 minutes to 45 seconds.
A pure domain test looks like this:
@Test
void should_apply_bulk_discount_correctly() {
// No Spring, no DB, just pure logic
Money basePrice = Money.of(100, "USD");
Quantity qty = Quantity.of(50);
Price result = PriceCalculator.calculate(basePrice, qty);
assertThat(result.getAmount()).isEqualTo(new BigDecimal("4500.00"));
}
Symptom: Repository Interfaces Leaking Infrastructure Details
When you see findByStatusAndCreatedDateAfter(Status s, LocalDateTime d) in your Domain Layer, you are leaking SQL concepts upwards. The Domain should speak the language of the business (Ubiquitous Language), not the language of the persistence store.
If changes to your database schema (like changing a column name) require changes in your business services, your layers are coupled.
Use the Specification Pattern or a clean Interface to hide the "How" from the "What." Instead of a generic save(), use business-oriented methods in your Repository interfaces.
public interface OrderRepository {
// Domain-centric naming
Optional<Order> findActiveOrder(CustomerId id);
void store(Order order);
}
In the implementation (OrderJpaRepository), you can use all the Spring Data JPA magic you want, but keep it hidden from the OrderService. When we migrated from MongoDB to PostgreSQL for a specific Bounded Context, the Domain package remained untouched. Only the Infrastructure package changed. This is the definition of a clean architectural boundary.
Symptom: Mass "Object-Mapping" Boilerplate (DTO to Entity)
If you find yourself writing 400-line Mapper classes or getting lost in MapStruct configurations, your Bounded Contexts are likely too thin, or you're bypassing the Domain Model entirely to talk to the UI.
In a healthy DDD-Spring Boot app, the DTO is a contract for the outside world, and the Aggregate is the internal source of truth. If they are 1:1 identical, you might not need DDD. But when they differ, do not let the DTO leak into the Aggregate's constructor.
Instead of:
public Order(OrderDTO dto) (Couples domain to API)
Use:
public Order(OrderIdentifier id, CustomerId customerId, List<LineItem> items)
Check for this smell by searching for DTO imports in your domain package:
``bash
grep -r "com.company.api.dto" src/main/java/com/company/domain/
``
If this returns hits, you have a circular dependency that will make your Bounded Context impossible to extract into a microservice later.
The Hard Cut: When to Stop Using Aggregates
The sharpest takeaway from implementing DDD in Spring Boot is knowing when to quit. DDD is a tool for managing complexity, not for every CRUD operation. If a feature is just "Save this form to a table," using an AggregateRoot with Domain Events is over-engineering that adds 300ms of developer latency to every change.
We adopted a "Side-by-Side" approach:
1. The Complex Core: Orders, Payments, Inventory. These use strict DDD, Aggregates, and no setters.
2. The Boring Support: Analytics logs, Static Content, User Preferences. These use standard Spring Data REST or simple @Data POJOs.
By segregating these, our "Core" remains highly maintainable and unit-testable, while the "Support" remains fast to develop. The moment we tried to force a UserPreference into an AggregateRoot, the code complexity doubled without any business benefit. Use DDD where the business rules are expensive to get wrong, and stay lean everywhere else.
When NOT to use DDD
DDD earns its cost in exactly one situation: the domain is complex, evolving and central to the business, and the people who understand it are not the same people who write the code. Everything else — the ubiquitous language, the aggregates, the bounded contexts, the anti-corruption layers — is scaffolding to close that gap. Skip DDD when the domain is well-understood and stable (an invoicing system that follows a legal spec), when the team is small enough that the language gap does not exist, or when the app is essentially a UI over a database with a handful of validations. Applied to the wrong problem, DDD produces the same passthrough classes that hexagonal-overkill produces, but with more elaborate names. The tell that DDD is being applied wastefully is a codebase full of aggregates whose only invariant is 'the fields are not null'.
Go deeper
Further reading
Stay in the Loop
Get the next tutorial in your inbox
Continue reading in Software Design & Architecture →
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.
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.
Modular Monolith Architecture in Spring Boot — The Right Way to Scale a Monolith
Why modern teams are returning to modular monoliths — module boundaries, package-by-feature, internal events and a clean migration path to microservices in Spring Boot.
Hexagonal Architecture with Spring Boot — Build Clean, Maintainable Applications
A practical guide to Ports and Adapters (Hexagonal Architecture) in Spring Boot — isolate your domain, make your code testable, and keep infrastructure swappable.
