Software Design & Architecture6 min read·By Liyabona Saki··

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:

bash
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).

text
[ 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:

bash
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:

yaml
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:

bash
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:

java
@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.

java
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

#Spring Boot#DDD#Domain-Driven Design#Architecture#Aggregates

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