Domain-Driven Design

Design software systems that model complex business domains. Covers bounded contexts, aggregates, value objects, domain events, context mapping, and the patterns that align code structure with business understanding.

Domain-Driven Design is an approach to software development that aligns code, models, and team structure around the core business domain. It matters when a system must evolve with complex, changing business logic—when the software is not just a tool, but the engine of the business.

Core Concepts and Workflow

Identify Bounded Contexts

A bounded context defines the scope of a domain model, where a set of terms, rules, and processes are consistent and mutually understood. A bounded context is not a module or a service—it is a conceptual boundary.

To define a bounded context:

  1. Gather stakeholders—domain experts, product owners, and developers—and conduct a workshop to map business processes using Ubiquitous Language.
  2. Extract key entities, value objects, and aggregates from the conversation.
  3. Cluster related concepts into logical groups.
  4. Draw a context map to show how contexts relate: shared kernel, customer-supplier, open host, and conformist.

Example: In a logistics platform, *Order Management*, *Inventory*, and *Billing* are distinct bounded contexts.

Each context owns its domain model, including:

A context map might show:

Model with Ubiquitous Language

Ubiquitous Language is the common vocabulary used by developers, domain experts, and business stakeholders. It is not a glossary—it is a living contract.

To implement Ubiquitous Language:

  public class Order {
      private UUID id;
      private Customer customer;
      private List<LineItem> items;
      private PaymentStatus paymentStatus;
      private Instant orderDate;

      public void addLineItem(Product product, int quantity) { ... }
      public boolean isFullyPaid() { ... }
      public void ship(ShippingAddress shippingAddress) {
          this.shippingAddress = shippingAddress;
          publish(new OrderShipped(this.id));
      }
  }
  Order order = OrderRepository.findById(orderId);
  order.ship(new ShippingAddress("123 Main St", "Portland", "OR", "97201"));
  public class OrderFulfillmentService {
      public void fulfill(Order order) {
          if (order.isFullyPaid()) {
              InventoryService.reserve(order.items());
              order.markAsFulfilled();
              order.publish(new OrderFulfilled(order.id));
          } else {
              throw new UnpaidOrderException(order.id);
          }
      }
  }

Use Aggregates to Enforce Invariants

An aggregate is a cluster of related objects treated as a unit for data changes. The root entity of the aggregate ensures consistency.

Example: Order is an aggregate root. It enforces:

public class Order {
    private final List<LineItem> lineItems = new ArrayList<>();

    public void addLineItem(Product product, int quantity) {
        LineItem item = new LineItem(product, quantity);
        lineItems.add(item);
        ensureInvariants();
    }

    private void ensureInvariants() {
        if (lineItems.isEmpty()) {
            throw new InvalidOrderException("Order must have at least one item");
        }
        BigDecimal total = lineItems.stream()
            .map(li -> li.product().price().multiply(BigDecimal.valueOf(li.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);

        if (!total.equals(this.total)) {
            throw new InconsistentTotalException("Total does not match line item sum");
        }
    }

    public void markAsPaid(Money amount) {
        if (amount.compareTo(this.total) < 0) {
            throw new InsufficientPaymentException(this.id, amount, this.total);
        }
        this.paymentStatus = PaymentStatus.PAID;
        publish(new PaymentConfirmed(this.id, amount));
    }
}

Implement Domain Events

Domain events are immutable facts that describe something significant that happened in the domain.

To publish an event:

  public class OrderShipped {
      private final UUID orderId;
      private final Instant shippedAt;
      private final ShippingAddress destination;

      public OrderShipped(UUID orderId, Instant shippedAt, ShippingAddress destination) {
          this.orderId = orderId;
          this.shippedAt = shippedAt;
          this.destination = destination;
      }

      // getters
  }
  public class Order {
      public void ship(ShippingAddress shippingAddress) {
          this.shippingAddress = shippingAddress;
          this.shippedAt = Instant.now();

          DomainEventPublisher.publish(new OrderShipped(this.id, this.shippedAt, shippingAddress));
      }
  }
  public class BillingService {
      public BillingService(EventBus eventBus) {
          eventBus.subscribe(OrderShipped.class, this::handleOrderShipped);
      }

      public void handleOrderShipped(OrderShipped event) {
          Invoice invoice = new Invoice(event.orderId(), event.shippedAt());
          InvoiceRepository.save(invoice);
      }
  }

Apply the Repository Pattern

Repositories provide a collection-like interface for accessing aggregates.

public interface OrderRepository {
    Order findById(UUID id);
    void save(Order order);
    List<Order> findUnshippedOrders();
}

public class JdbcOrderRepository implements OrderRepository {
    private final DataSource dataSource;

    public JdbcOrderRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Order findById(UUID id) {
        String sql = "SELECT * FROM orders WHERE id = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            stmt.setObject(1, id);

            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return mapRowToOrder(rs);
                }
                throw new OrderNotFoundException(id);
            }
        } catch (SQLException e) {
            throw new RepositoryException("Failed to load order", e);
        }
    }

    private Order mapRowToOrder(ResultSet rs) throws SQLException {
        UUID id = rs.getObject("id", UUID.class);
        Instant orderDate = rs.getTimestamp("order_date").toInstant();
        String status = rs.getString("payment_status");
        // ... map items from separate table
        return new Order(id, orderDate, PaymentStatus.valueOf(status), items);
    }

    @Override
    public void save(Order order) {
        // ... insert or update order and line items
    }
}

Common Patterns and Pitfalls

The Over-Engineered Aggregate

The most common failure is overloading aggregates with too much logic. A Customer aggregate might handle:

But when the business introduces a new concept—customer tier—the Customer aggregate becomes bloated.

Symptoms:

Solution:

  public class Customer {
      public void upgradeToPremium() {
          this.tier = CustomerTier.PREMIUM;
          publish(new CustomerUpgradedToPremium(this.id, Instant.now()));
      }
  }
  public class LoyaltyService {
      public void handleCustomerUpgradedToPremium(CustomerUpgradedToPremium event) {
          // grant bonus points, send welcome email
      }
  }

Shared Kernel vs. Conformist

When two contexts share a model (e.g., Customer), they must agree on structure and semantics.

Shared Kernel:

Conformist:

Failure mode:

Solution:

Transaction Boundaries and Saga Patterns

When a business process spans multiple aggregates and bounded contexts, use sagas.

Example: *Order Fulfillment Saga*:

  1. Order is created.
  2. Inventory reserves stock.
  3. Payment processes payment.
  4. Order ships and publishes OrderShipped.

Each step is a compensating transaction in case of failure.

public class OrderFulfillmentSaga {
    private final SagaRepository sagaRepository;

    public void startFulfillment(UUID orderId) {
        OrderFulfillmentSaga saga = new OrderFulfillmentSaga(orderId);
        saga.addStep(new ReserveStockStep(orderId));
        sagaRepository.save(saga);
    }

    public void handleStockReserved(StockReserved event) {
        OrderFulfillmentSaga saga = sagaRepository.findById(event.sagaId());
        saga.markStepAsCompleted(Step.RESERVE_STOCK);
        if (saga.allStepsCompleted()) {
            sagaRepository.save(saga);
            publish(new PaymentRequested(saga.orderId()));
        }
    }

    public void handlePaymentFailed(PaymentFailed event) {
        OrderFulfillmentSaga saga = sagaRepository.findById(event.sagaId());
        saga.markStepAsFailed(Step.PAYMENT);
        sagaRepository.save(saga);
        publish(new CompensateStockReservation(saga.orderId()));
    }

    public void handleStockReservationFailed(CompensateStockReservation event) {
        InventoryService.release(event.orderId());
    }
}

Error Handling and Failure Modes

Aggregate Invariant Violation

Eventual Consistency

Missing Event Handler

    # Check event bus subscription
    curl http://event-bus:8080/api/subscriptions
    # Expected: {"service": "billing-service", "events": ["OrderShipped"]}

If missing, register subscription:

    POST /api/subscriptions
    {
      "service": "billing-service",
      "events": ["OrderShipped"]
    }

Response: 201 Created with {"id": "sub-123", "service": "billing-service"}

Key Principles and Practices

  {
    "eventId": "1a2b3c",
    "eventType": "OrderShipped",
    "version": 2,
    "timestamp": "2024-01-15T10:00:00Z",
    "data": {
      "orderId": "d4e5f6",
      "shippedAt": "2024-01-15T10:05:00Z",
      "destination": {
        "addressLine1": "123 Main St",
        "city": "Portland",
        "state": "OR",
        "zipCode": "97201"
      }
    }
  }

Domain-Driven Design is not a checklist—it is a discipline of thinking. It builds software that evolves with the business, not just in response to it.

This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.