Building Domain-Driven Design with Spring Boot Aggregates
Model complex business logic with DDD aggregates and Spring Boot for scalable and maintainable applications
As systems grow more complex, organizing business logic becomes a serious challenge. Domain-Driven Design (DDD) offers a powerful methodology to build maintainable, modular, and expressive applications by modeling software around the core business domain.
At the heart of DDD lies the concept of aggregates, which group related entities and enforce business invariants. In this guide, you’ll learn how to apply DDD in Spring Boot, focusing on implementing and managing aggregates effectively in Java-based applications.
What is Domain-Driven Design?
Domain-Driven Design is a methodology that emphasizes:
- Modeling software based on business domains
- Creating a ubiquitous language shared by developers and domain experts
- Separating the core domain logic from infrastructure and application concerns
Key building blocks of DDD include:
- Entities: Domain objects with identity
- Value Objects: Immutable objects defined by attributes
- Aggregates: Groups of entities with consistency rules
- Repositories: Abstract storage interfaces for aggregates
- Domain Services: Encapsulate domain logic that doesn’t naturally fit an entity
Understanding Aggregates
An aggregate is a cluster of domain objects that are treated as a single unit for data changes. Every aggregate has a root — an entity that enforces consistency boundaries.
For example, an Order
aggregate may contain:
- The root:
Order
- Value Objects:
Address
,OrderLine
- Entities:
OrderItem
, each with its own identity within the order
All operations that modify an aggregate must go through its root. This ensures that business rules and invariants are always enforced.
Modeling Aggregates in Spring Boot
Let’s model a basic Order
aggregate.
@Entity
public class Order {
@Id
private UUID id;
@ElementCollection
private List<OrderItem> items = new ArrayList<>();
private OrderStatus status;
public void addItem(Product product, int quantity) {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Cannot add items to a confirmed order");
}
items.add(new OrderItem(product, quantity));
}
public void confirm() {
if (items.isEmpty()) {
throw new IllegalStateException("Order must contain at least one item");
}
status = OrderStatus.CONFIRMED;
}
}
All mutations happen through the root (Order
), keeping the aggregate consistent.
Creating Value Objects
Value objects are immutable and defined by their properties, not identity:
@Embeddable
public class Address {
private String street;
private String city;
protected Address() {} // for JPA
public Address(String street, String city) {
this.street = street;
this.city = city;
}
// equals and hashCode based on all fields
}
Avoid setters. Enforce immutability and domain validity in constructors.
Persistence with Repositories
Define a repository that handles the persistence of aggregates:
public interface OrderRepository extends JpaRepository<Order, UUID> {
Optional<Order> findById(UUID id);
}
Only allow aggregates to be loaded and saved via repositories. Avoid leaking entity relationships outside the aggregate boundary.
Encapsulating Business Logic in Domain Services
For operations that span multiple aggregates or don’t belong in any single entity:
@Service
public class OrderService {
private final OrderRepository orderRepo;
private final InventoryService inventoryService;
public void placeOrder(UUID customerId, List<Product> products) {
Order order = new Order(customerId);
products.forEach(p -> order.addItem(p, 1));
order.confirm();
inventoryService.reserve(products);
orderRepo.save(order);
}
}
Keep services thin and focused on orchestration, not on logic that belongs in aggregates.
Aggregate Consistency and Transaction Boundaries
Aggregates should:
- Be modified within transactional boundaries
- Not reference other aggregates directly
- Use IDs to reference other roots, not object references
This ensures loose coupling and simplifies scaling strategies such as CQRS or event sourcing.
Event-Driven Interactions Between Aggregates
If one aggregate’s state change should trigger changes in another, use domain events:
public class OrderConfirmedEvent {
private final UUID orderId;
public OrderConfirmedEvent(UUID orderId) {
this.orderId = orderId;
}
}
Publish events via ApplicationEventPublisher
and handle them in separate listeners. This keeps aggregates cohesive and promotes async communication.
Best Practices for Aggregate Design
- Keep aggregates small and focused
- Design with invariants and boundaries in mind
- Avoid cyclic dependencies between aggregates
- Do not expose internal collections (use defensive copies)
- Prefer constructor-based creation with valid initial state
Conclusion
Domain-Driven Design encourages modeling your system in alignment with business reality. Aggregates help you enforce business rules, maintain consistency, and scale with confidence.
Spring Boot provides the tools — JPA, Spring Data, events, and services — to implement aggregates in a clean and practical way. By designing your aggregates carefully, you create systems that are easier to evolve, test, and understand.