Domain-Driven Design (DDD) is not a technology choice, but a discipline for managing complexity in software by focusing on the core business domain.
Let’s see how this plays out in a real-world scenario. Imagine an e-commerce platform. We’re building out the "Order" microservice.
# order-service.yaml
apiVersion: v1
kind: Service
metadata:
name: order-service
labels:
app: order-service
spec:
selector:
app: order-service
ports:
- protocol: TCP
port: 8080
targetPort: 8080
// OrderAggregate.java
@Aggregate
public class Order {
@AggregateId
private String orderId;
private List<OrderItem> items;
private OrderStatus status;
// ... constructors, methods for adding items, changing status ...
public void addItem(ProductId productId, int quantity, Price price) {
// Domain logic: check stock, price validity, etc.
this.items.add(new OrderItem(productId, quantity, price));
}
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalStateException("Order must be in PENDING state to confirm.");
}
this.status = OrderStatus.CONFIRMED;
// Publish OrderConfirmedEvent
}
}
The core problem DDD solves is how to map complex, evolving business logic into software that is maintainable and understandable, especially as systems grow into multiple microservices. It provides a shared language and a set of patterns to align technical design with business realities.
When we talk about DDD, we’re really talking about two main aspects: the Strategic Design (how to break down the big picture) and the Tactical Design (how to build the individual pieces).
Strategic Design involves identifying Bounded Contexts. These are explicit boundaries within which a particular model is defined and applicable. For our e-commerce example, we might have Bounded Contexts like "Order Management," "Inventory," "Payment," and "Shipping." The key is that the meaning of a term like "Product" can differ between these contexts. In "Order Management," a Product might just need an ID and a price. In "Inventory," it needs stock levels, warehouse location, etc.
Tactical Design provides the building blocks within a Bounded Context. The most important are Aggregates. An Aggregate is a cluster of domain objects that can be treated as a single unit. It has a root entity (the Aggregate Root) that all external access goes through. The Aggregate Root is responsible for enforcing the rules and invariants of the Aggregate. In the Order example above, Order is the Aggregate Root. You can’t directly add an OrderItem to the Order’s internal list; you must go through the Order object’s addItem method. This ensures that business rules (like checking stock before adding an item, or ensuring the order is in a valid state before confirming) are always applied.
Other tactical patterns include Entities (objects with a distinct identity that persists over time, like Order or Customer), Value Objects (objects defined by their attributes, not identity, like Price or Address), and Domain Events (significant occurrences in the domain that other parts of the system might react to, like OrderConfirmedEvent).
The real power comes when these Bounded Contexts interact. DDD encourages explicit integration patterns. For the "Order Management" context to know if an order can be confirmed, it might need to query the "Inventory" context. This interaction should be modeled carefully. A common pattern is using Anti-Corruption Layers (ACLs). An ACL acts as a translation layer, protecting the model in one Bounded Context from being corrupted by the model in another.
When an order is confirmed, the Order service might publish an OrderConfirmedEvent. The Payment service, listening for this event, can then initiate the payment process. The Shipping service might listen for a PaymentSuccessfulEvent to start preparing for shipment. This loose coupling is a hallmark of well-designed microservices using DDD principles.
Consider how Price is modeled. In the Order context, Price might be a simple Value Object with a BigDecimal amount and a Currency.
// Price.java
@ValueObject
public class Price {
private final BigDecimal amount;
private final Currency currency;
// constructor, getters, equals/hashCode
}
However, in a separate Product Catalog Bounded Context, Price might be more complex, including historical pricing, sale adjustments, and region-specific rules. The Order service wouldn’t directly use the Product Catalog’s Price object. Instead, it would receive a simplified representation, perhaps just a Price Value Object, ensuring its own internal model remains clean and focused.
The most surprising truth about DDD is that its patterns, especially Aggregates, are not merely about object-oriented design; they are fundamentally about transactional consistency boundaries. An Aggregate is the unit of consistency. All changes within an Aggregate must be atomic. This is why you can only have one Aggregate Root, and external references to other objects within the Aggregate must be to the Aggregate Root itself.
When you’re defining your Aggregates, think about the operations that must happen atomically. If adding an item to an order and updating the order’s total price must always succeed or fail together, they belong in the same Aggregate. If confirming an order and deducting inventory can be eventually consistent, they likely belong in separate Aggregates (and potentially separate Bounded Contexts).
The next major challenge you’ll face is handling eventual consistency across Bounded Contexts.