Complete guide to Domain-Driven Development (DDD): a software development approach that focuses on building software around business domains and models. Learn core concepts, strategic design patterns, tactical patterns, and practical implementation strategies.
Table of Contents
1. What is Domain-Driven Development?
Domain-Driven Development (DDD) is a software development approach introduced by Eric Evans in his book "Domain-Driven Design: Tackling Complexity in the Heart of Software." DDD focuses on building software that reflects a deep understanding of the business domain, emphasizing collaboration between technical and domain experts to create a shared model of the problem space.
At its core, DDD is about placing the domain model at the center of the software design process. Instead of focusing primarily on technical concerns, DDD encourages developers to understand the business domain deeply and express that understanding through code. The domain model becomes a living representation of business concepts, rules, and processes.
DDD is particularly valuable for complex business domains where the problem space is rich and nuanced. It helps teams avoid the common pitfall of creating anemic domain models that merely act as data containers, instead promoting rich domain models that encapsulate business logic and behavior.
2. Why Use DDD?
- Alignment with business: DDD ensures that software closely mirrors business needs and terminology, reducing the gap between what stakeholders want and what developers build.
- Complex domain handling: For complex business domains, DDD provides patterns and techniques to manage complexity effectively.
- Maintainability: Well-designed domain models are easier to understand and modify as business requirements evolve.
- Team collaboration: The ubiquitous language promotes better communication between developers and domain experts.
- Testability: Rich domain models with clear boundaries make it easier to write meaningful tests.
- Scalability: Strategic design patterns help organize large codebases and enable independent team work.
3. Core Concepts
3.1 Domain
The domain is the sphere of knowledge or activity around which the business logic revolves. It represents the problem space that the software addresses. For example, in an e-commerce system, the domain includes concepts like orders, products, customers, payments, and shipping.
Understanding the domain requires deep collaboration with domain experts—people who understand the business deeply, such as product managers, business analysts, or subject matter experts. The domain is not about technology; it's about the business itself.
3.2 Ubiquitous Language
Ubiquitous Language is a common vocabulary shared by developers and domain experts. It's the language used in code, documentation, and conversations about the domain. This shared language eliminates translation errors and ensures everyone understands the same concepts.
Example: In a banking domain, terms like "Account," "Transaction," "Balance," and "Overdraft" should mean the same thing whether spoken by a banker or written in code. If a developer uses "AccountBalance" but the domain expert calls it "Balance," this mismatch creates confusion.
// Good: Uses ubiquitous language
public class Account {
private Balance balance;
public void applyTransaction(Transaction transaction) {
// Business logic here
}
}
// Bad: Technical terms that don't match domain language
public class AccountEntity {
private BigDecimal accountBalanceValue;
public void processTransactionRecord(TransactionRecord record) {
// Mismatch with domain terminology
}
}
3.3 Bounded Context
A Bounded Context is an explicit boundary within which a domain model is valid. Different bounded contexts can have different models for the same concept. For example, "Customer" in the Sales context might be different from "Customer" in the Shipping context.
Bounded contexts help manage complexity by allowing teams to work independently on different parts of the system without conflicting models. Each context has its own ubiquitous language and domain model.
- Name
- Purchase History] end subgraph "Shipping Bounded Context" B[Customer Model
- Name
- Address
- Delivery Preferences] end subgraph "Billing Bounded Context" C[Customer Model
- Name
- Payment Method
- Billing Address] end style A fill:#e1f5ff,stroke:#0273bd,stroke-width:2px style B fill:#fff4e1,stroke:#f57c00,stroke-width:2px style C fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
3.4 Domain Model
The Domain Model is an abstraction that represents the domain concepts and their relationships. It's not just a data model; it's a rich model that includes both data and behavior. The domain model should capture business rules, invariants, and processes.
A rich domain model encapsulates business logic within domain objects, rather than having it scattered across service layers. This makes the model more expressive and easier to understand.
4. Strategic Design Patterns
Strategic design patterns help organize large systems and manage complexity at a high level. They focus on how different parts of the system relate to each other.
4.1 Bounded Context
As mentioned earlier, bounded contexts define explicit boundaries for domain models. They're the primary organizational unit in DDD.
4.2 Context Mapping
Context Mapping is a technique for visualizing relationships between bounded contexts. It helps identify integration patterns and potential conflicts.
Common relationships:
- Shared Kernel: Two contexts share a common subset of the domain model.
- Customer-Supplier: One context (customer) depends on another (supplier).
- Conformist: One context conforms to another's model without modification.
- Anticorruption Layer: A context protects itself from another context's model.
- Separate Ways: Contexts are independent and don't integrate.
- Open Host Service: A context provides a protocol for others to access its functionality.
4.3 Domain Events
Domain Events represent something meaningful that happened in the domain. They're used to communicate between bounded contexts and maintain eventual consistency.
public class OrderPlacedEvent {
private final OrderId orderId;
private final CustomerId customerId;
private final Money totalAmount;
private final Instant occurredOn;
// Constructor, getters...
}
// Publishing domain events
public class Order {
private List<DomainEvent> domainEvents = new ArrayList<>();
public void place() {
// Business logic
this.status = OrderStatus.PLACED;
domainEvents.add(new OrderPlacedEvent(this.id, this.customerId, this.total, Instant.now()));
}
public List<DomainEvent> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
}
5. Tactical Design Patterns
Tactical patterns provide building blocks for implementing domain models within a bounded context. These patterns help create rich, expressive domain models.
5.1 Entity
An Entity is an object with a unique identity that persists over time, even if its attributes change. Entities are identified by their ID, not by their attributes.
public class Order {
private OrderId id; // Unique identity
private CustomerId customerId;
private OrderStatus status;
private List<OrderItem> items;
// Identity-based equality
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Order order = (Order) o;
return Objects.equals(id, order.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
5.2 Value Object
A Value Object is an object defined by its attributes rather than identity. Value objects are immutable and compared by value. Examples include Money, Address, and Email.
public class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
if (amount == null || currency == null) {
throw new IllegalArgumentException("Amount and currency are required");
}
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies");
}
return new Money(this.amount.add(other.amount), this.currency);
}
// Value-based equality
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return Objects.equals(amount, money.amount) &&
Objects.equals(currency, money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
5.3 Aggregate
An Aggregate is a cluster of related objects treated as a single unit. It has a root entity (Aggregate Root) that controls access to the aggregate's internal objects. Aggregates maintain consistency boundaries.
// Aggregate Root
public class Order {
private OrderId id;
private CustomerId customerId;
private List<OrderItem> items; // Part of aggregate
private OrderStatus status;
// Business logic that maintains invariants
public void addItem(ProductId productId, Quantity quantity, Money unitPrice) {
if (this.status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify placed order");
}
OrderItem item = new OrderItem(productId, quantity, unitPrice);
this.items.add(item);
}
public void place() {
if (this.items.isEmpty()) {
throw new IllegalStateException("Cannot place empty order");
}
this.status = OrderStatus.PLACED;
// Publish domain event...
}
// Only aggregate root exposes public methods
public Money getTotal() {
return items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.ZERO, Money::add);
}
}
5.4 Domain Service
A Domain Service contains domain logic that doesn't naturally fit within an entity or value object. It's a stateless service that operates on domain objects.
public class OrderPricingService {
public Money calculateTotal(Order order, Customer customer) {
Money subtotal = order.getSubtotal();
Money discount = calculateDiscount(customer, subtotal);
Money tax = calculateTax(subtotal, customer.getAddress());
Money shipping = calculateShipping(order, customer.getAddress());
return subtotal
.subtract(discount)
.add(tax)
.add(shipping);
}
private Money calculateDiscount(Customer customer, Money amount) {
// Complex discount calculation logic
// This doesn't belong in Order or Customer
}
}
5.5 Repository
A Repository provides an abstraction for accessing aggregates. It encapsulates the logic needed to retrieve and persist aggregates, keeping domain logic separate from persistence concerns.
public interface OrderRepository {
void save(Order order);
Order findById(OrderId id);
List<Order> findByCustomerId(CustomerId customerId);
void remove(Order order);
}
// Implementation (infrastructure layer)
@Repository
public class JpaOrderRepository implements OrderRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public void save(Order order) {
entityManager.persist(order);
// Handle domain events...
}
@Override
public Order findById(OrderId id) {
return entityManager.find(Order.class, id);
}
}
5.6 Factory
A Factory encapsulates complex object creation logic. Factories are useful when creating aggregates requires complex initialization or when the creation process should be hidden.
public class OrderFactory {
private final ProductRepository productRepository;
private final PricingService pricingService;
public Order createOrder(CustomerId customerId, List<OrderLineRequest> lineRequests) {
Order order = new Order(OrderId.generate(), customerId);
for (OrderLineRequest request : lineRequests) {
Product product = productRepository.findById(request.getProductId());
Money unitPrice = pricingService.getPrice(product, customerId);
order.addItem(product.getId(), request.getQuantity(), unitPrice);
}
return order;
}
}
6. Implementation Examples
6.1 Complete Order Aggregate Example
// Value Object: OrderId
public class OrderId {
private final String value;
private OrderId(String value) {
this.value = value;
}
public static OrderId of(String value) {
return new OrderId(value);
}
public static OrderId generate() {
return new OrderId(UUID.randomUUID().toString());
}
// equals, hashCode, toString...
}
// Value Object: Money
public class Money {
private final BigDecimal amount;
private final Currency currency;
public static final Money ZERO = new Money(BigDecimal.ZERO, Currency.USD);
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
validateSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
// Other methods...
}
// Entity: OrderItem (part of Order aggregate)
public class OrderItem {
private ProductId productId;
private Quantity quantity;
private Money unitPrice;
public OrderItem(ProductId productId, Quantity quantity, Money unitPrice) {
this.productId = productId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
public Money getSubtotal() {
return unitPrice.multiply(quantity.getValue());
}
}
// Aggregate Root: Order
public class Order {
private OrderId id;
private CustomerId customerId;
private List<OrderItem> items = new ArrayList<>();
private OrderStatus status;
private List<DomainEvent> domainEvents = new ArrayList<>();
public Order(OrderId id, CustomerId customerId) {
this.id = id;
this.customerId = customerId;
this.status = OrderStatus.DRAFT;
}
public void addItem(ProductId productId, Quantity quantity, Money unitPrice) {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify order in status: " + status);
}
items.add(new OrderItem(productId, quantity, unitPrice));
}
public void place() {
if (items.isEmpty()) {
throw new IllegalStateException("Cannot place empty order");
}
this.status = OrderStatus.PLACED;
domainEvents.add(new OrderPlacedEvent(id, customerId, getTotal(), Instant.now()));
}
public Money getTotal() {
return items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.ZERO, Money::add);
}
public List<DomainEvent> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
6.2 Application Service Example
@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final DomainEventPublisher eventPublisher;
@Transactional
public OrderId createOrder(CreateOrderCommand command) {
Order order = new Order(OrderId.generate(), command.getCustomerId());
for (OrderLineCommand line : command.getLines()) {
Product product = productRepository.findById(line.getProductId());
order.addItem(product.getId(), line.getQuantity(), product.getPrice());
}
orderRepository.save(order);
// Publish domain events
order.getDomainEvents().forEach(eventPublisher::publish);
order.clearDomainEvents();
return order.getId();
}
@Transactional
public void placeOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId);
order.place();
orderRepository.save(order);
order.getDomainEvents().forEach(eventPublisher::publish);
order.clearDomainEvents();
}
}
7. Best Practices
7.1 Keep Domain Logic in Domain Layer
Business logic should live in the domain layer, not in application services or infrastructure. Application services orchestrate, but domain objects contain the business rules.
7.2 Use Value Objects for Primitive Obsession
Avoid using primitives everywhere. Create value objects for domain concepts like Email, Money, Address, etc. This makes the code more expressive and prevents errors.
7.3 Maintain Aggregate Consistency
Aggregates should always be in a consistent state. All invariants should be enforced within the aggregate boundary. Use domain events for cross-aggregate communication.
7.4 Keep Aggregates Small
Large aggregates are hard to maintain and can cause performance issues. Keep aggregates focused and small. If an aggregate becomes too large, consider splitting it.
7.5 Use Domain Events for Integration
Use domain events to communicate between bounded contexts and aggregates. This maintains loose coupling and enables eventual consistency.
7.6 Continuous Refinement
The domain model should evolve as understanding deepens. Don't be afraid to refactor the model as you learn more about the domain.
8. Common Pitfalls
8.1 Anemic Domain Model
An anemic domain model has entities that are just data containers with getters and setters. Business logic ends up in service classes, making the domain model less expressive.
// Bad: Anemic domain model
public class Order {
private OrderId id;
private List<OrderItem> items;
private OrderStatus status;
// Only getters and setters
public void setStatus(OrderStatus status) {
this.status = status;
}
}
// Good: Rich domain model
public class Order {
private OrderId id;
private List<OrderItem> items;
private OrderStatus status;
public void place() {
if (items.isEmpty()) {
throw new IllegalStateException("Cannot place empty order");
}
this.status = OrderStatus.PLACED;
}
}
8.2 God Aggregates
Creating aggregates that are too large and contain too much responsibility. This makes them hard to maintain and can cause concurrency issues.
8.3 Leaky Abstractions
Allowing infrastructure concerns to leak into the domain layer. The domain should not depend on frameworks or databases.
8.4 Ignoring Bounded Contexts
Trying to create a single unified model for the entire system. Different contexts may need different models for the same concept.
9. When to Use DDD
DDD is not appropriate for every project. Consider DDD when:
- Complex business domain: The business logic is complex and requires deep domain knowledge.
- Long-lived projects: The system will evolve over time and needs to adapt to changing business requirements.
- Team collaboration: Multiple teams work on the system and need clear boundaries.
- Domain expertise available: You have access to domain experts who can collaborate on the model.
DDD may be overkill for:
- Simple CRUD applications
- Data-centric applications with minimal business logic
- Short-term projects
- Projects without domain complexity
0 Comments