Back to blog

Domain-Driven Design (DDD): Strategic & Tactical Patterns

software-architectureddddesign-patternsbackendsystem-design
Domain-Driven Design (DDD): Strategic & Tactical Patterns

Most software projects fail not because of bad code, but because of a disconnect between the code and the business it serves. Developers build features, but the model in the code doesn't match the model in the domain expert's head. Over time, this disconnect grows — the code becomes harder to change, harder to reason about, and harder to align with evolving business needs.

Domain-Driven Design (DDD) is a set of principles and patterns that tackle this problem head-on. Introduced by Eric Evans in his 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software, DDD provides both strategic patterns (how to organize a large system into bounded contexts) and tactical patterns (how to model business logic inside each context).

DDD isn't a framework or a library — it's a way of thinking about software design that puts the domain model at the center.

In this post, we'll cover:

✅ What DDD is (and what it's not)
✅ Strategic DDD: bounded contexts, ubiquitous language, context mapping
✅ Tactical DDD: entities, value objects, aggregates, repositories, domain events
✅ Aggregate design rules and consistency boundaries
✅ Domain services vs application services
✅ Anti-corruption layer and integration patterns
✅ Event storming workshops
✅ DDD and microservices: bounded context = service boundary
✅ DDD with hexagonal and clean architecture
✅ Practical example: modeling an e-commerce domain
✅ When DDD is overkill


The Problem: Code That Doesn't Speak the Business Language

In most projects, the conversation goes like this:

Domain expert: "When a customer places an order, we need to check inventory, reserve the items, calculate pricing with any active promotions, and then confirm the order."

Developer: "Got it. I'll create a POST /api/orders endpoint that inserts a row into the orders table and updates the inventory table."

The developer understood the HTTP request and the database tables, but missed the domain concepts: inventory reservation, promotion rules, order confirmation workflow. The resulting code works, but it doesn't model the business. Six months later, when the business adds bundle pricing or pre-orders, the code becomes a tangled mess of if-else statements.

DDD bridges this gap by making the code reflect the domain.


Strategic DDD: The Big Picture

Strategic DDD is about dividing a large system into smaller, well-defined parts where each part has its own model and its own language.

Ubiquitous Language

The most important concept in DDD is the ubiquitous language — a shared vocabulary between developers and domain experts that is used consistently in conversations, documentation, and code.

❌ Without ubiquitous language:
- Domain expert says: "approved application"
- Developer writes: `request.setStatus("A")`
- Database has: `app_status_cd = 2`
 
✅ With ubiquitous language:
- Domain expert says: "approved application"
- Developer writes: `application.approve()`
- Database has: `status = 'APPROVED'`

The ubiquitous language isn't just documentation — it's enforced in code. Method names, class names, and variable names use the same terms the domain expert uses. When you read the code, you should be able to show it to a domain expert and have them understand the intent.

Bounded Context

A bounded context is a boundary within which a particular domain model is defined and applicable. It's the most critical pattern in strategic DDD.

Consider an e-commerce system. The word "Product" means different things in different contexts:

In the Catalog context, a Product has a name, description, and images. In the Pricing context, it has a base price, discounts, and tax rules. In the Inventory context, it has a SKU, quantity, and reorder level.

These are different models — and that's OK. Each bounded context owns its model and doesn't need to know about the others. Trying to create a single "Product" class that works everywhere leads to a bloated, confusing "God class."

Subdomains

A subdomain is a segment of the business domain. There are three types:

TypeDescriptionExampleInvestment
CoreThe competitive advantage — what makes the business uniquePricing engine, recommendation algorithmHigh — custom development, DDD
SupportingNecessary but not differentiatingOrder management, customer profilesMedium — simpler models, less DDD
GenericSolved problems, available off-the-shelfAuthentication, email sending, payment processingLow — buy or use SaaS

DDD is most valuable for core subdomains. Don't apply heavy DDD patterns to generic or supporting subdomains where a simple CRUD approach is sufficient.

Context Mapping

Context mapping defines how bounded contexts relate to and communicate with each other.

Common relationship patterns:

PatternDescriptionWhen to Use
PartnershipTwo contexts evolve together, coordinated planningTwo teams working closely on related features
Customer-SupplierUpstream (supplier) provides what downstream (customer) needsOne team depends on another's data/API
ConformistDownstream conforms to upstream's model as-isUsing an external API you can't change
Anti-Corruption Layer (ACL)Downstream translates upstream's model into its own languageProtecting your model from a legacy or external system
Published LanguageShared language for communication (events, APIs)Well-defined integration contracts between contexts
Shared KernelTwo contexts share a subset of the modelSmall shared code between closely related contexts
Separate WaysNo integration — contexts operate independentlyWhen the cost of integration exceeds the benefit

Tactical DDD: Building Blocks

Tactical DDD provides the patterns for implementing the domain model inside a bounded context.

Entity

An entity is an object defined by its identity, not its attributes. Two entities with the same attributes but different IDs are different objects.

public class Customer {
    private final CustomerId id;  // Identity
    private String name;
    private Email email;
    private CustomerStatus status;
 
    public void activate() {
        if (this.status == CustomerStatus.SUSPENDED) {
            throw new IllegalStateException("Cannot activate suspended customer");
        }
        this.status = CustomerStatus.ACTIVE;
    }
 
    public void suspend(String reason) {
        this.status = CustomerStatus.SUSPENDED;
        // Record domain event
    }
 
    // Identity-based equality
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer)) return false;
        return id.equals(((Customer) o).id);
    }
 
    @Override
    public int hashCode() {
        return id.hashCode();
    }
}

Key characteristics:

  • Has a unique identity that persists across time
  • Equality based on identity, not attributes
  • Contains business behavior (not just data)
  • Mutable — state changes through business methods

Value Object

A value object is defined by its attributes, not an identity. Two value objects with the same attributes are the same thing.

// Value object — defined by its attributes
public record Money(BigDecimal amount, Currency currency) {
 
    public Money {
        if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount must be non-negative");
        }
        Objects.requireNonNull(currency, "Currency is required");
    }
 
    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);
    }
 
    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
    }
}
 
// Usage
Money price = new Money(new BigDecimal("29.99"), Currency.USD);
Money total = price.multiply(3);  // $89.97
// Another value object — Email
public record Email(String value) {
 
    public Email {
        if (value == null || !value.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")) {
            throw new IllegalArgumentException("Invalid email: " + value);
        }
    }
}
 
// Another value object — Address
public record Address(String street, String city, String state, String zipCode, String country) {
 
    public Address {
        Objects.requireNonNull(street, "Street is required");
        Objects.requireNonNull(city, "City is required");
        Objects.requireNonNull(country, "Country is required");
    }
}

Key characteristics:

  • No identity — equality based on attributes
  • Immutable — operations return new instances
  • Self-validating — enforces invariants in constructor
  • Replaces primitive types with domain concepts (Money instead of BigDecimal, Email instead of String)

Aggregate

An aggregate is a cluster of entities and value objects treated as a single unit for data changes. It has one aggregate root — the only entity through which external code can interact with the aggregate.

// Order is the Aggregate Root
public class Order {
    private final OrderId id;
    private final CustomerId customerId;
    private OrderStatus status;
    private final List<OrderItem> items;  // Entities inside aggregate
    private ShippingAddress shippingAddress;  // Value object
    private Money totalAmount;  // Value object
 
    // Factory method — enforces invariants at creation
    public static Order place(CustomerId customerId, List<OrderItem> items,
                               ShippingAddress address) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("Order must have at least one item");
        }
        Order order = new Order();
        order.id = OrderId.generate();
        order.customerId = customerId;
        order.status = OrderStatus.PLACED;
        order.items = new ArrayList<>(items);
        order.shippingAddress = address;
        order.totalAmount = calculateTotal(items);
        return order;
    }
 
    // Business method — encapsulates domain logic
    public void addItem(ProductId productId, String productName,
                        Money price, int quantity) {
        if (status != OrderStatus.PLACED) {
            throw new IllegalStateException("Cannot modify confirmed order");
        }
        OrderItem item = new OrderItem(productId, productName, price, quantity);
        items.add(item);
        recalculateTotal();
    }
 
    public void removeItem(ProductId productId) {
        if (status != OrderStatus.PLACED) {
            throw new IllegalStateException("Cannot modify confirmed order");
        }
        items.removeIf(item -> item.getProductId().equals(productId));
        if (items.isEmpty()) {
            throw new IllegalStateException("Order must have at least one item");
        }
        recalculateTotal();
    }
 
    public void confirm() {
        if (status != OrderStatus.PLACED) {
            throw new IllegalStateException("Can only confirm PLACED orders");
        }
        status = OrderStatus.CONFIRMED;
    }
 
    public void ship(String trackingNumber) {
        if (status != OrderStatus.CONFIRMED) {
            throw new IllegalStateException("Can only ship CONFIRMED orders");
        }
        status = OrderStatus.SHIPPED;
    }
 
    // Internal consistency — always valid
    private void recalculateTotal() {
        this.totalAmount = items.stream()
            .map(OrderItem::subtotal)
            .reduce(Money.ZERO_USD, Money::add);
    }
 
    // Read-only access to internal entities
    public List<OrderItem> getItems() {
        return Collections.unmodifiableList(items);
    }
}

Aggregate Design Rules

These rules keep aggregates manageable and performant:

Rule 1: Protect business invariants inside the aggregate

The aggregate root is responsible for ensuring all business rules are satisfied. No external code can put the aggregate in an invalid state.

Rule 2: Design small aggregates

// ❌ Large aggregate — loads everything
public class Customer {
    private List<Order> orders;        // Could be thousands!
    private List<Address> addresses;
    private List<PaymentMethod> payments;
    private LoyaltyAccount loyalty;
}
 
// ✅ Small aggregates — reference by ID
public class Customer {
    private CustomerId id;
    private String name;
    private Email email;
    // Orders, addresses, payments are separate aggregates
}
 
public class Order {
    private OrderId id;
    private CustomerId customerId;  // Reference by ID, not by object
    private List<OrderItem> items;
}

Rule 3: Reference other aggregates by ID

Never hold a direct object reference to another aggregate. Use IDs to maintain loose coupling and enable distribution.

Rule 4: Update one aggregate per transaction

Each transaction should modify one aggregate. Cross-aggregate consistency is handled through domain events and eventual consistency.

// ❌ Updating multiple aggregates in one transaction
@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
    Order order = Order.place(...);
    orderRepo.save(order);
 
    Inventory inventory = inventoryRepo.findByProductId(cmd.productId());
    inventory.reserve(cmd.quantity());  // Different aggregate!
    inventoryRepo.save(inventory);
}
 
// ✅ One aggregate per transaction + domain event
@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
    Order order = Order.place(...);
    orderRepo.save(order);
    eventPublisher.publish(new OrderPlacedEvent(order.getId(), order.getItems()));
}
 
// Separate handler updates inventory
@EventHandler
public void onOrderPlaced(OrderPlacedEvent event) {
    for (OrderItemData item : event.getItems()) {
        Inventory inventory = inventoryRepo.findByProductId(item.productId());
        inventory.reserve(item.quantity());
        inventoryRepo.save(inventory);
    }
}

Repository

A repository provides the illusion of an in-memory collection of aggregates. It abstracts away persistence concerns.

// Repository interface — defined in the domain layer
public interface OrderRepository {
    OrderId nextId();
    Order findById(OrderId id);
    List<Order> findByCustomerId(CustomerId customerId);
    void save(Order order);
    void delete(OrderId id);
}

Key rules:

  • One repository per aggregate root (not per entity)
  • Interface lives in the domain layer
  • Implementation lives in the infrastructure/adapter layer
  • Returns domain objects, never database entities

Domain Event

A domain event is something that happened in the domain that other parts of the system might be interested in. It's named in the past tense because it represents something that already occurred.

// Domain event — immutable, past tense
public record OrderPlacedEvent(
    OrderId orderId,
    CustomerId customerId,
    Money totalAmount,
    List<OrderItemData> items,
    Instant occurredAt
) implements DomainEvent {
 
    public OrderPlacedEvent(OrderId orderId, CustomerId customerId,
                             Money totalAmount, List<OrderItemData> items) {
        this(orderId, customerId, totalAmount, items, Instant.now());
    }
}
 
public record OrderShippedEvent(
    OrderId orderId,
    String trackingNumber,
    Instant occurredAt
) implements DomainEvent {}
 
public record OrderCancelledEvent(
    OrderId orderId,
    String reason,
    Instant occurredAt
) implements DomainEvent {}

Domain events enable:

  • Decoupling between bounded contexts
  • Eventual consistency between aggregates
  • Audit trails and event sourcing
  • Triggering side effects without polluting core logic

Domain Service

A domain service contains business logic that doesn't naturally belong to any single entity or value object. It operates on multiple aggregates or requires external information.

// Domain service — business logic that spans aggregates
public class PricingService {
 
    public Money calculateOrderTotal(List<OrderItem> items,
                                      DiscountPolicy discountPolicy,
                                      TaxPolicy taxPolicy) {
        Money subtotal = items.stream()
            .map(OrderItem::subtotal)
            .reduce(Money.ZERO_USD, Money::add);
 
        Money afterDiscount = discountPolicy.apply(subtotal);
        Money withTax = taxPolicy.apply(afterDiscount);
        return withTax;
    }
}
 
// Domain service — transfer money between accounts
public class TransferService {
 
    public void transfer(Account from, Account to, Money amount) {
        if (!from.hasBalance(amount)) {
            throw new InsufficientBalanceException(from.getId(), amount);
        }
        from.debit(amount);
        to.credit(amount);
    }
}

Domain service vs Application service:

AspectDomain ServiceApplication Service
ContainsBusiness logicOrchestration/coordination
DependenciesDomain objects onlyRepositories, ports, external services
ExampleCalculate pricing, validate transferPlace order use case, send notification
LayerDomain layerApplication/use case layer

Anti-Corruption Layer (ACL)

When integrating with external systems or legacy code, an Anti-Corruption Layer translates between the external model and your domain model, preventing the external model from corrupting your bounded context.

// Anti-corruption layer — translates legacy model to your domain model
public class LegacyCustomerAdapter implements CustomerInfoProvider {
 
    private final LegacyCustomerApiClient legacyClient;
 
    @Override
    public CustomerProfile getProfile(CustomerId customerId) {
        // Call legacy API with its own terminology
        LegacyCustRecord record = legacyClient.getCustByAcctNbr(
            customerId.value()
        );
 
        // Translate to your domain model
        return new CustomerProfile(
            new CustomerId(record.getAcctNbr()),
            record.getFirstNm() + " " + record.getLastNm(),
            new Email(record.getEmailAddr()),
            mapLegacyTier(record.getCustTierCd())  // "G" → GOLD
        );
    }
 
    private CustomerTier mapLegacyTier(String legacyCode) {
        return switch (legacyCode) {
            case "G" -> CustomerTier.GOLD;
            case "S" -> CustomerTier.SILVER;
            case "B" -> CustomerTier.BRONZE;
            default -> CustomerTier.STANDARD;
        };
    }
}

The ACL keeps your domain model clean even when the external system uses cryptic naming, different concepts, or outdated conventions.


Event Storming

Event Storming is a collaborative workshop technique for discovering the domain model. Created by Alberto Brandolini, it brings together developers and domain experts to map out the business process using sticky notes.

How It Works

Step 1: Domain Events (Orange)

Everyone writes domain events on orange sticky notes and places them on a timeline.

[Order Placed] → [Payment Received] → [Inventory Reserved] → [Order Shipped] → [Order Delivered]

Step 2: Commands (Blue)

What triggers each event? Commands are the actions users or systems take.

{Place Order} → [Order Placed]
{Process Payment} → [Payment Received]
{Reserve Inventory} → [Inventory Reserved]
{Ship Order} → [Order Shipped]

Step 3: Aggregates (Yellow)

Which aggregate handles each command-event pair?

Step 4: Bounded Contexts

Group aggregates that work closely together into bounded contexts.

Event Storming produces a shared understanding of the domain, identifies bounded contexts, and reveals the domain events that connect them.


Practical Example: E-Commerce Order System

Let's put it all together with a realistic e-commerce example.

Bounded Contexts Identified

Order Context Implementation

// === Value Objects ===
 
public record OrderId(String value) {
    public static OrderId generate() {
        return new OrderId(UUID.randomUUID().toString());
    }
}
 
public record CustomerId(String value) {}
 
public record ProductId(String value) {}
 
public record Money(BigDecimal amount, String currency) {
    public static final Money ZERO_USD = new Money(BigDecimal.ZERO, "USD");
 
    public Money add(Money other) {
        return new Money(amount.add(other.amount), currency);
    }
 
    public Money multiply(int qty) {
        return new Money(amount.multiply(BigDecimal.valueOf(qty)), currency);
    }
}
 
// === Entities ===
 
public class OrderItem {
    private final ProductId productId;
    private final String productName;
    private final Money unitPrice;
    private int quantity;
 
    public Money subtotal() {
        return unitPrice.multiply(quantity);
    }
 
    public void increaseQuantity(int additional) {
        if (additional <= 0) throw new IllegalArgumentException("Must be positive");
        this.quantity += additional;
    }
}
 
// === Aggregate Root ===
 
public class Order {
    private final OrderId id;
    private final CustomerId customerId;
    private OrderStatus status;
    private List<OrderItem> items;
    private ShippingAddress shippingAddress;
    private Money totalAmount;
    private final List<DomainEvent> domainEvents = new ArrayList<>();
 
    public static Order place(CustomerId customerId, List<OrderItem> items,
                               ShippingAddress address) {
        if (items.isEmpty()) {
            throw new EmptyOrderException();
        }
 
        Order order = new Order(OrderId.generate(), customerId,
            OrderStatus.PLACED, items, address);
        order.recalculateTotal();
 
        // Register domain event
        order.domainEvents.add(new OrderPlacedEvent(
            order.id, customerId, order.totalAmount, order.getItemData()
        ));
        return order;
    }
 
    public void confirm(PaymentId paymentId) {
        if (status != OrderStatus.PLACED) {
            throw new InvalidOrderStateException(id, status, "confirm");
        }
        this.status = OrderStatus.CONFIRMED;
        domainEvents.add(new OrderConfirmedEvent(id, paymentId));
    }
 
    public void ship(String trackingNumber) {
        if (status != OrderStatus.CONFIRMED) {
            throw new InvalidOrderStateException(id, status, "ship");
        }
        this.status = OrderStatus.SHIPPED;
        domainEvents.add(new OrderShippedEvent(id, trackingNumber));
    }
 
    public void cancel(String reason) {
        if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
            throw new InvalidOrderStateException(id, status, "cancel");
        }
        this.status = OrderStatus.CANCELLED;
        domainEvents.add(new OrderCancelledEvent(id, reason));
    }
 
    // Collect and clear domain events
    public List<DomainEvent> pullDomainEvents() {
        List<DomainEvent> events = new ArrayList<>(domainEvents);
        domainEvents.clear();
        return events;
    }
}

Application Service (Use Case)

// Application service — orchestrates domain objects and ports
public class PlaceOrderUseCase {
 
    private final OrderRepository orderRepository;
    private final ProductCatalogPort productCatalog;
    private final EventPublisher eventPublisher;
 
    public PlaceOrderUseCase(OrderRepository orderRepository,
                              ProductCatalogPort productCatalog,
                              EventPublisher eventPublisher) {
        this.orderRepository = orderRepository;
        this.productCatalog = productCatalog;
        this.eventPublisher = eventPublisher;
    }
 
    public OrderId execute(PlaceOrderCommand command) {
        // Validate products exist and get current prices
        List<OrderItem> items = command.items().stream()
            .map(item -> {
                ProductInfo product = productCatalog.getProduct(item.productId());
                return new OrderItem(
                    product.id(), product.name(), product.price(), item.quantity()
                );
            })
            .toList();
 
        ShippingAddress address = new ShippingAddress(
            command.street(), command.city(), command.state(),
            command.zipCode(), command.country()
        );
 
        // Create aggregate — business rules enforced inside
        Order order = Order.place(
            new CustomerId(command.customerId()), items, address
        );
 
        // Persist
        orderRepository.save(order);
 
        // Publish domain events
        order.pullDomainEvents().forEach(eventPublisher::publish);
 
        return order.getId();
    }
}

Folder Structure

src/main/java/com/example/ordering/
├── domain/                              # Domain Layer
│   ├── model/
│   │   ├── Order.java                   # Aggregate root
│   │   ├── OrderItem.java               # Entity
│   │   ├── OrderStatus.java             # Enum
│   │   ├── OrderId.java                 # Value object
│   │   ├── CustomerId.java              # Value object
│   │   ├── Money.java                   # Value object
│   │   ├── ShippingAddress.java         # Value object
│   │   └── ProductId.java               # Value object
│   ├── event/
│   │   ├── DomainEvent.java             # Base interface
│   │   ├── OrderPlacedEvent.java
│   │   ├── OrderConfirmedEvent.java
│   │   ├── OrderShippedEvent.java
│   │   └── OrderCancelledEvent.java
│   ├── exception/
│   │   ├── EmptyOrderException.java
│   │   └── InvalidOrderStateException.java
│   ├── port/
│   │   ├── OrderRepository.java         # Driven port
│   │   ├── ProductCatalogPort.java      # Driven port
│   │   └── EventPublisher.java          # Driven port
│   └── service/
│       └── PricingService.java          # Domain service

├── application/                         # Application Layer
│   ├── PlaceOrderUseCase.java
│   ├── ConfirmOrderUseCase.java
│   ├── CancelOrderUseCase.java
│   └── command/
│       ├── PlaceOrderCommand.java
│       └── CancelOrderCommand.java

├── infrastructure/                      # Infrastructure Layer
│   ├── persistence/
│   │   ├── OrderJpaEntity.java
│   │   ├── OrderJpaRepository.java
│   │   └── OrderRepositoryImpl.java
│   ├── messaging/
│   │   └── KafkaEventPublisher.java
│   ├── rest/
│   │   ├── OrderController.java
│   │   └── dto/
│   │       ├── PlaceOrderRequest.java
│   │       └── OrderResponse.java
│   └── external/
│       └── CatalogServiceClient.java    # ACL for catalog context

DDD and Microservices

DDD and microservices are natural partners. The bounded context maps directly to a microservice boundary.

Key alignment:

  • Each bounded context = one microservice
  • Each microservice owns its database (database per service)
  • Communication between contexts via domain events (async)
  • Anti-corruption layer for each integration point
  • Ubiquitous language is scoped to each service

Warning: Don't use microservice boundaries to discover bounded contexts. Discover bounded contexts first (through event storming and domain analysis), then decide which ones become microservices.


DDD with Hexagonal and Clean Architecture

DDD, hexagonal architecture, and clean architecture complement each other:

LayerDDD PatternHexagonal ConceptClean Architecture
Inner coreEntities, Value Objects, Domain EventsCore business logicEntities layer
Business rulesAggregates, Domain ServicesCore business logicUse Cases layer
Interface boundaryRepository interfacesPortsInterface Adapters
External systemsRepository implementations, ACLAdaptersFrameworks & Drivers

The combination works like this:

  • DDD tells you what to model (aggregates, events, value objects)
  • Hexagonal/Clean tells you where to put it (layers, ports, adapters)
  • Dependency rule tells you how things connect (all dependencies point inward)

When DDD Is Overkill

DDD adds significant complexity. Here's when to use it — and when to skip it.

Use DDD when:

  • The domain is complex with many business rules
  • Domain experts are available and willing to collaborate
  • The project is long-lived and will evolve
  • Multiple bounded contexts with different models
  • Business logic is the competitive advantage

Skip DDD when:

  • Simple CRUD with minimal business logic
  • No access to domain experts
  • Short-lived projects or prototypes
  • The domain is well-understood and stable
  • Small team where the ceremony adds overhead

Pragmatic approach:

You don't have to go all-in on DDD. Use the patterns that add value:

PatternWhen to UseSkip When
Ubiquitous languageAlways — costs nothing, always helpsNever skip this
Bounded contextsMultiple teams or complex domainsSingle small application
AggregatesComplex business rules and invariantsSimple CRUD operations
Value objectsWhenever you have domain conceptsNot worth it for trivial types
Domain eventsCross-aggregate communication neededSingle aggregate, no side effects
Event StormingComplex or unfamiliar domainWell-understood, simple domain
Repository patternMultiple persistence options or testability needsDirect ORM usage is fine

Common Mistakes

1. Anemic Domain Model

// ❌ Anemic — entity is just a data bag
public class Order {
    private String status;
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}
 
// Service does all the work
public class OrderService {
    public void confirmOrder(Order order) {
        if (!order.getStatus().equals("PLACED")) {
            throw new RuntimeException("Invalid state");
        }
        order.setStatus("CONFIRMED");
    }
}
// ✅ Rich domain model — entity owns its behavior
public class Order {
    private OrderStatus status;
 
    public void confirm() {
        if (status != OrderStatus.PLACED) {
            throw new InvalidOrderStateException(id, status, "confirm");
        }
        this.status = OrderStatus.CONFIRMED;
    }
}

2. Giant Aggregates

// ❌ Too large — loads everything, locks too much
public class Customer {
    private List<Order> orders;         // Could be 10,000+
    private List<Review> reviews;       // Unnecessary coupling
    private ShoppingCart cart;
    private WishList wishList;
    private LoyaltyAccount loyalty;
}
// ✅ Small, focused aggregates connected by ID
public class Customer {
    private CustomerId id;
    private String name;
    private Email email;
}
 
public class Order {
    private OrderId id;
    private CustomerId customerId;  // Reference by ID
}
 
public class ShoppingCart {
    private CartId id;
    private CustomerId customerId;  // Reference by ID
}

3. Sharing Aggregates Across Bounded Contexts

// ❌ Shared "Product" used in catalog, pricing, and inventory
public class Product {
    private String name;
    private String description;     // Catalog
    private BigDecimal price;       // Pricing
    private List<Discount> discounts; // Pricing
    private int stockQuantity;      // Inventory
    private String warehouseLocation; // Inventory
    // God class — everyone changes it
}
// ✅ Each context has its own model
// Catalog context
public class CatalogProduct {
    private ProductId id;
    private String name;
    private String description;
    private List<String> images;
}
 
// Pricing context
public class PricedItem {
    private ProductId productId;
    private Money basePrice;
    private List<DiscountRule> applicableDiscounts;
}
 
// Inventory context
public class StockItem {
    private ProductId productId;
    private int quantity;
    private String warehouseId;
    private int reorderLevel;
}

4. Using DDD Everywhere

Don't apply aggregates, repositories, and domain events to a simple settings page or admin CRUD. Reserve DDD patterns for the core domain where business complexity justifies the investment.


Summary

Domain-Driven Design is both a philosophy and a toolkit for building software that reflects the business domain.

Strategic patterns (the big picture):

  • Ubiquitous language — shared vocabulary between developers and domain experts, enforced in code
  • Bounded contexts — clear boundaries where each model has a specific meaning
  • Context mapping — relationships between contexts (partnership, customer-supplier, ACL, etc.)
  • Subdomains — core (invest heavily), supporting (simpler models), generic (buy/use SaaS)

Tactical patterns (the building blocks):

  • Entities — objects with identity and behavior
  • Value objects — immutable objects defined by attributes (Money, Email, Address)
  • Aggregates — consistency boundaries with a root entity
  • Repositories — persistence abstraction, one per aggregate root
  • Domain events — past-tense records of things that happened
  • Domain services — business logic that doesn't belong to a single entity

Key rules:

  • Design small aggregates, reference others by ID
  • One aggregate per transaction, eventual consistency across aggregates
  • Rich domain models with behavior, not anemic data bags
  • Ubiquitous language everywhere — code should read like the business

DDD + architecture patterns:

  • Bounded contexts map naturally to microservice boundaries
  • DDD's domain layer fits perfectly inside hexagonal/clean architecture
  • Domain events enable loose coupling between contexts

"The heart of software is its ability to solve domain-related problems for its users." — Eric Evans


What's Next in the Software Architecture Series

This is post 10 of 12 in the Software Architecture Patterns series:

Related posts:

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.