Introduction: DDD as the Foundation for Modularity
Domain Driven Design (DDD) is not just an approach to software modeling: it is the most powerful tool for identifying the correct module boundaries in a modular monolith. Without DDD, module boundaries become arbitrary, based on technical criteria rather than real domain boundaries. The result is a fragile architecture that does not reflect the business.
In this article we will explore the fundamental concepts of DDD applied to modularization: Bounded Contexts, Ubiquitous Language, Context Mapping, and Aggregate Design. We will see how to translate these concepts into concrete module boundaries and how to implement them in Java/Spring Boot code.
What You Will Learn in This Article
- DDD fundamentals: entities, value objects, aggregates, and bounded contexts
- How to identify your application's bounded contexts
- Ubiquitous Language: why each module has its own vocabulary
- Context Mapping: Anti-Corruption Layer, Shared Kernel, Customer/Supplier
- Aggregate Design: the key to consistent transactions
- How to translate bounded contexts into Java package structure
- Conway's Law: aligning teams and architecture
DDD Fundamentals for the Modular Monolith
Domain Driven Design introduces a precise vocabulary for modeling software around the business domain. Let us look at the fundamental building blocks:
Entities
An entity is a domain object with a unique identity that persists over time.
Two entities are equal if they have the same ID, regardless of their attribute values.
Examples: Order, User, Product.
Value Objects
A value object is an immutable object defined by its attributes, with no identity
of its own. Two value objects with the same attributes are identical. Examples: Money,
Address, EmailAddress.
Aggregates
An aggregate is a cluster of entities and value objects treated as a single unit for modification operations. The aggregate has a root entity serving as the access point, guaranteeing the integrity of internal invariants. Transactions must never cross aggregate boundaries.
// Aggregate Example: Order
// Order is the aggregate root entity
// OrderItem and Money are part of the aggregate
public class Order {
private final UUID id; // entity identity
private UUID userId;
private List<OrderItem> items; // child entities
private Money total; // value object
private OrderStatus status;
private Instant createdAt;
// Factory method - only way to create an Order
public static Order create(UUID userId, List<ProductDto> products) {
Order order = new Order();
order.id = UUID.randomUUID();
order.userId = userId;
order.items = products.stream()
.map(p -> OrderItem.fromProduct(p))
.toList();
order.total = Money.sum(
order.items.stream().map(OrderItem::getPrice).toList()
);
order.status = OrderStatus.CREATED;
order.createdAt = Instant.now();
return order;
}
// Invariants are protected within the aggregate
public void cancel() {
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalStateException(
"Cannot cancel a shipped order"
);
}
this.status = OrderStatus.CANCELLED;
}
}
// Immutable Value Object
public record Money(BigDecimal amount, Currency currency) {
public static Money sum(List<Money> values) {
return new Money(
values.stream()
.map(Money::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add),
values.get(0).currency()
);
}
}
Bounded Contexts: Domain Boundaries
A Bounded Context is a linguistic and functional boundary within which a domain model is valid and consistent. Within a bounded context, every term has a precise and unambiguous meaning. Across context boundaries, the same concept can have different meanings.
Concrete example: the concept of "Product" has different meanings in different contexts:
- In the Catalog: a product has name, description, images, categories
- In the Warehouse: a product has available quantity, location, reorder threshold
- In the Order: a product is a line item with unit price, ordered quantity, discount
- In Shipping: a product has weight, dimensions, fragility
Forcing a single Product model satisfying all these contexts creates a
God Class with dozens of attributes, most of which are irrelevant for each
specific context.
// WRONG: A single Product model for all contexts
// "God Class" violating the bounded context principle
public class Product {
// Catalog data
private String name;
private String description;
private List<String> images;
private Category category;
// Warehouse data
private int stockQuantity;
private String warehouseLocation;
private int reorderThreshold;
// Order data
private BigDecimal unitPrice;
private BigDecimal discount;
// Shipping data
private double weight;
private Dimensions dimensions;
private boolean fragile;
// ... 30+ attributes
}
// CORRECT: One model per bounded context
// Catalog
public class CatalogProduct {
private UUID id;
private String name;
private String description;
private List<String> images;
private Category category;
}
// Warehouse
public class InventoryItem {
private UUID productId; // reference, not the full object
private int stockQuantity;
private String location;
private int reorderThreshold;
}
// Order
public class OrderLineItem {
private UUID productId; // reference
private Money unitPrice;
private int quantity;
private Money discount;
}
Fundamental Rule
Each bounded context must have its own model for shared concepts. Contexts communicate through reference IDs, not shared objects. This is the key principle making modules independent and extractable.
Ubiquitous Language: One Vocabulary per Context
The Ubiquitous Language is the shared vocabulary between developers and domain experts within a bounded context. Each context has its own language, and the same term can have different meanings in different contexts.
This concept has practical implications for naming in code:
- In the Order module:
Customeris the person placing the order, with billing address and payment method - In the Shipping module:
Customeris the recipient, with delivery address and shipping preferences - In the Support module:
Customeris the ticket opener, with interaction history and priority level
Forcing a single Customer model for all contexts creates coupling and prevents
teams from evolving their models independently.
Context Mapping: How Contexts Communicate
Context Mapping defines how bounded contexts interact with each other. Several relationship patterns exist, each with specific trade-offs:
Anti-Corruption Layer (ACL)
The Anti-Corruption Layer is a translation layer isolating a bounded context from another context's model. When the Order module needs data from the Catalog module, it does not directly use Catalog DTOs: it translates them into its own internal model through an ACL.
// Anti-Corruption Layer in the Order module
// Translates Catalog concepts into Order language
package com.ecommerce.order.internal.acl;
@Component
class CatalogAntiCorruptionLayer {
private final CatalogModuleApi catalogModule;
// Translates ProductDto (catalog) to OrderProduct (order)
public OrderProduct resolveProduct(UUID productId) {
ProductDto catalogProduct = catalogModule.findById(productId)
.orElseThrow(() -> new ProductNotAvailableException(productId));
// Translation: takes only what the Order context needs
return new OrderProduct(
catalogProduct.id(),
catalogProduct.name(),
Money.of(catalogProduct.price(), Currency.EUR),
catalogProduct.isAvailable()
);
}
}
// OrderProduct: internal model of the Order context
// Not the Catalog's ProductDto
record OrderProduct(
UUID productId,
String displayName,
Money price,
boolean available
) {}
Shared Kernel
The Shared Kernel is a small set of code shared between two or more bounded
contexts. It typically includes common value objects (like Money, Address),
shared domain events, and constants. The shared kernel must be minimal and changed
only with consent from all involved teams.
// Shared Kernel: code shared between modules
package com.ecommerce.shared;
// Shared value objects
public record Money(BigDecimal amount, Currency currency) {
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException();
}
return new Money(this.amount.add(other.amount), this.currency);
}
}
public record Address(
String street, String city,
String zipCode, String country
) {}
// Shared domain events
public record OrderCreatedEvent(
UUID orderId, UUID userId, Money total, Instant timestamp
) {}
Customer/Supplier
In the Customer/Supplier pattern, one context (supplier) provides data or services to another context (customer). The supplier defines the interface but considers the customer's needs. This pattern applies when one module clearly depends on another in a unidirectional manner.
Aggregate Design: Transactions and Consistency
Aggregate design is crucial for a modular monolith because it defines transactional boundaries. An ACID transaction should operate within a single aggregate. Interactions between different aggregates (even within the same module) should be managed with eventual consistency through domain events.
Aggregate Design Rules
- Small: an aggregate should be as small as possible. Only aggregate what must be consistent in a single transaction
- Reference by ID: aggregates do not contain direct references to other aggregates, only their IDs
- One transaction per aggregate: never modify multiple aggregates in the same transaction
- Eventual consistency between aggregates: use domain events to coordinate changes between different aggregates
// Correct Aggregate Design
// Order Aggregate - transactional boundary
public class Order {
private UUID id;
private UUID userId; // reference by ID, not User object
private UUID paymentId; // reference by ID, not Payment object
private List<OrderItem> items; // part of the aggregate
private OrderStatus status;
// Transactional operation: happens within the same aggregate
@Transactional
public void addItem(UUID productId, int quantity, Money price) {
// Verify internal invariants
if (this.status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify confirmed order");
}
this.items.add(new OrderItem(productId, quantity, price));
this.recalculateTotal();
}
}
// WRONG: transaction spanning multiple aggregates
@Transactional
public void createOrderAndReserveStock(CreateOrderCmd cmd) {
Order order = orderRepo.save(Order.create(cmd));
// VIOLATION: modifies another aggregate in the same transaction
inventoryService.reserveStock(order.getItems());
// If reserveStock fails, the entire rollback is complex
}
// CORRECT: eventual consistency between aggregates
@Transactional
public void createOrder(CreateOrderCmd cmd) {
Order order = orderRepo.save(Order.create(cmd));
// Publish event: the Inventory module will react asynchronously
events.publish(new OrderCreatedEvent(order.getId(), order.getItems()));
}
Identifying Bounded Contexts: A Practical Method
Identifying correct bounded contexts is as much art as science. Here is a practical four-step method:
- Event Storming: gather developers and domain experts. Identify all domain events on orange sticky notes. Group events into logical clusters: each cluster is a potential bounded context
- Language analysis: identify where the same term has different meanings. Each linguistic divergence indicates a context boundary
- Dependency analysis: map dependencies between clusters. Clusters with few external dependencies are good candidates for independent modules
- Team validation: verify that identified boundaries reflect organizational structure and team competencies
Conway's Law
Conway's Law states that organizations design systems mirroring their own communication structure. In practice: module boundaries should align with team boundaries. A team responsible for the Order module should not need daily coordination with the Shipping module team to complete their work.
If code structure does not mirror team structure, friction arises: frequent merge conflicts, constant coordination meetings, mutual blocking. Aligning architecture and organization is a prerequisite for modular monolith success.
Next Article
In the next article we will tackle database design for modular monoliths: shared schema vs per-module schema, data ownership, distributed transaction management, and patterns like Saga and Event Sourcing for eventual consistency. We will translate the bounded contexts identified here into concrete data structures.







