What Is Domain-Driven Design (DDD)? Concepts, Patterns, and Practical Examples

0 0

Domain-Driven Design (DDD) is an approach to software development that places the business domain at the center of software modeling and implementation. Instead of organizing applications primarily around database tables, frameworks, or technical components, DDD encourages developers and domain experts to build a shared understanding of business processes, rules, and terminology. That understanding becomes the foundation of the software model.

Introduced by Eric Evans in his book Domain-Driven Design: Tackling Complexity in the Heart of Software, DDD is especially valuable when business behavior is complex, evolves frequently, or spans multiple organizational responsibilities. Its concepts include ubiquitous language, bounded contexts, entities, value objects, aggregates, domain services, and domain events. This guide explains strategic and tactical DDD, demonstrates how these concepts fit together, and implements an order management example using Python, FastAPI, PostgreSQL, and SQLAlchemy.

Table of Contents

What Is Domain-Driven Design?

Domain-Driven Design is a method of developing software through a deep understanding of the business problems it solves. The central objective is to create a model that accurately expresses important business concepts and their relationships, then continuously refine that model as understanding improves.

DDD is not a framework, programming language, or mandatory folder structure. It is a collection of principles and modeling techniques that connect business knowledge with software design.

Origins and Core Principles

Eric Evans introduced the foundational DDD patterns in his 2003 book, widely associated with its 2004 publication. The approach addressed a recurring problem in enterprise software: technical implementations often become complex because the underlying business domain is not modeled clearly.

DDD emphasizes several principles:

  • Focus on the core domain: Invest the greatest modeling effort in business capabilities that provide competitive differentiation.
  • Collaborate with domain experts: Software developers should learn how the business operates rather than inventing rules independently.
  • Use a shared language: Business terminology should be reflected in discussions, documentation, APIs, tests, and code.
  • Protect model boundaries: Different business contexts may require different representations of similar concepts.
  • Express business behavior: Domain objects should enforce rules, not merely store data.
  • Refine models continuously: New business understanding should influence the software model.

The business model is not a one-time design document. It evolves as developers and domain experts discover new rules, exceptions, and responsibilities.

A well-designed model makes important behaviors visible in the code and reduces the gap between what business stakeholders describe and what the software actually implements.

Strategic vs. Tactical Domain-Driven Design

DDD contains two related areas: strategic design and tactical design.

Aspect Strategic DDD Tactical DDD
Primary question Where should model boundaries exist? How should domain behavior be implemented?
Main concepts Subdomains, bounded contexts, context maps Entities, value objects, aggregates, services, events
Focus Business capabilities and relationships Business rules and consistency
Typical participants Domain experts, architects, engineering teams Developers and domain experts
Outcome Clear model ownership and context boundaries Expressive, testable domain implementations

Strategic design usually deserves attention first. Building elegant entities inside a poorly defined business boundary can preserve the wrong model rather than solve the underlying organizational problem.

For example, if Ordering and Inventory use the word "available" differently, forcing them into one universal Product model can create unnecessary coupling even when the individual classes are carefully implemented.

Core Concepts of Domain-Driven Design

Tactical DDD provides building blocks for expressing business behavior in software. These patterns help developers represent identity, immutable values, consistency boundaries, business operations, and important events.

They should be introduced according to domain complexity. Not every application needs every pattern, and modeling every database table as an aggregate is not DDD.

Ubiquitous Language

Ubiquitous Language is a shared vocabulary developed by technical and business participants within a particular domain model or bounded context.

Suppose stakeholders frequently use the terms "reserve inventory," "authorize payment," and "confirm order." The implementation should represent these concepts directly rather than hiding them behind generic methods such as update_status().

Compare two interfaces:

# Ambiguous technical API.
order.update_status("approved")

# Business-oriented domain API.
order.confirm_payment(authorization_id)
order.confirm()

The second interface communicates more about permitted behavior and business intent.

Ubiquitous Language is not merely choosing readable variable names. It involves continuously discussing ambiguous terms, distinguishing business concepts, and correcting the model when language reveals inconsistent assumptions.

For example, "customer" might refer to a registered account in Identity, a purchaser in Ordering, and a billing entity in Payments. DDD does not require every context to adopt the same model.

Entities and Value Objects

DDD distinguishes entities from value objects based on identity.

An entity has a continuing identity. Two Order objects representing the same order identifier refer to the same business entity even when the order's state changes.

A value object is defined by its values. It typically has no independent business identity and is often immutable.

Examples of value objects include Money, DateRange, EmailAddress, and geographic coordinates.

from dataclasses import dataclass


@dataclass(frozen=True)
class Money:
    amount_cents: int
    currency: str = "USD"

    def __post_init__(self) -> None:
        if type(self.amount_cents) is not int:
            raise ValueError("Amount must use integer cents")

        if self.currency != "USD":
            raise ValueError("Only USD is supported")

    def add(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Currency mismatch")

        return Money(
            amount_cents=(
                self.amount_cents + other.amount_cents
            ),
            currency=self.currency,
        )

Two Money objects with the same amount and currency represent the same value. A customer account or purchase order, by contrast, usually requires an explicit identity.

Value objects are useful for moving validation closer to the domain. Instead of repeatedly checking currency and amount formats in controllers and services, the model can enforce valid representations when values are created.

Aggregates and Aggregate Roots

An aggregate is a group of domain objects treated as one consistency boundary. Its aggregate root controls operations that must preserve the aggregate's invariants.

For example, an Order aggregate may contain OrderItems. Callers should manipulate order items through the Order root rather than modifying arbitrary child records independently.

The Order root can enforce rules such as:

  • An order must contain at least one item before submission.
  • Item quantities must be positive.
  • The same SKU cannot appear twice when the business prohibits duplicates.
  • Submitted orders cannot be modified as drafts.
  • A cancelled order cannot transition directly to shipped.

Aggregates are not simply collections of related database rows. Their main purpose is defining which invariants must remain consistent within a business operation.

A useful rule of thumb is to design transactions around aggregate boundaries. Do not automatically expand an aggregate to include every object related through a foreign key.

For example, Ordering and Inventory may each have separate aggregates even when order placement and stock reservation participate in one checkout workflow.

Domain Services and Domain Events

Some business operations do not naturally belong to a single entity. A domain service can implement a business policy that coordinates domain concepts while remaining independent of infrastructure.

For example, a shipping eligibility policy might evaluate an order's destination, shipment weight, and selected delivery method.

A domain event describes something important that has already occurred in the domain:

from dataclasses import dataclass
from datetime import datetime


@dataclass(frozen=True)
class OrderPlaced:
    event_id: str
    order_id: str
    occurred_at: datetime

The event expresses a business fact, not an instruction to perform an arbitrary database update.

Domain events can trigger internal application behavior and may eventually lead to published integration events.

However, an in-memory event object is not the same as a durable message. Production delivery may require an outbox, message broker, retry handling, and idempotent consumers.

Repositories and Factories

Repositories provide collection-like access to aggregates without exposing persistence implementation details.

For example, an application service may request an Order aggregate by ID or ask the repository to persist a newly created Order.

The repository interface should reflect domain operations instead of exposing unrestricted ORM sessions and SQL expressions.

Factories encapsulate object creation when establishing valid initial state requires substantial logic. They can be useful for creating complex aggregates, but simple constructors or named class methods are often sufficient.

For example, Order.place(...) can validate input, initialize the order's state, and record an OrderPlaced event.

Neither repositories nor factories should become universal abstraction frameworks that obscure the business model.

Strategic DDD and Bounded Contexts

Strategic DDD helps identify where different models should apply and how those models relate to one another. This becomes especially important as applications grow into multiple business capabilities, teams, or independent services.

The goal is not to divide the system into as many components as possible. It is to protect coherent business models and clarify ownership.

Domains and Subdomains

A domain is the business problem area the software addresses. A large domain can contain several subdomains with distinct responsibilities.

For an e-commerce platform, subdomains might include:

  • Catalog: Products, descriptions, categories, and merchandising.
  • Ordering: Order creation, modification, cancellation, and lifecycle.
  • Inventory: Stock availability and reservations.
  • Payments: Authorization, capture, refunds, and settlement.
  • Fulfillment: Shipment creation, carrier selection, and tracking.
  • Notifications: Customer communication preferences and delivery.

DDD often classifies subdomains into core, supporting, and generic categories.

A core subdomain represents business differentiation. Supporting subdomains enable the business but are not necessarily its primary competitive advantage. Generic subdomains solve common problems that can often use established products or services.

This classification helps prioritize engineering effort. An organization should not necessarily invest the same custom-modeling effort in its unique pricing engine and its ordinary email delivery integration.

Identifying Bounded Contexts

A bounded context defines the boundary within which a particular domain model and language remain consistent.

The same term can represent different concepts in different contexts.

Context Meaning of Product Primary Concern
Catalog Sellable item with descriptions, images, and attributes Merchandising and discovery
Inventory Stock-keeping unit associated with warehouse quantities Availability and reservations
Ordering Purchased item snapshot with quantity and agreed price Order consistency
Fulfillment Physical item that must be picked, packed, and shipped Delivery execution

Trying to create a single shared Product class for all four contexts can lead to a model with unrelated properties and dependencies.

Bounded contexts allow each model to evolve according to its own rules and responsibilities.

A bounded context does not automatically require its own microservice. Several bounded contexts can exist inside one modular monolith, and a deployment boundary should be chosen based on additional operational considerations.

Context Mapping and Integration Patterns

Context mapping describes relationships between bounded contexts, including how they exchange information and which models influence others.

Common integration patterns include:

  • Customer-Supplier: One context provides capabilities that another relies upon, with an explicit collaboration relationship.
  • Conformist: A downstream context adopts the upstream model when it has limited influence over the contract.
  • Anti-Corruption Layer: A translation boundary prevents an external model from contaminating the local domain.
  • Published Language: A documented interchange language defines communication between contexts.
  • Open Host Service: A context exposes a defined protocol for consumers.
  • Separate Ways: Contexts remain independent when integration costs exceed its value.

An anti-corruption layer is particularly valuable when integrating a legacy inventory system whose terminology does not match the Ordering context.

For example, the legacy system may return allocation_state="HELD", while Ordering understands the business result as InventoryReserved.

Translate that result at the boundary instead of introducing legacy terminology throughout the Ordering domain.

For a more detailed analysis of identifying microservice boundaries using DDD, read Defining Service Boundaries with Domain-Driven Design.

Production Design Example: E-Commerce Platform

Consider an e-commerce system that accepts customer orders, reserves inventory, authorizes payments, and arranges delivery. These operations form one customer-facing workflow but involve several distinct business capabilities.

DDD helps identify which decisions and state belong together and where consistency must be coordinated across boundaries.

Ordering, Inventory, and Payments

Start by assigning authoritative ownership of business state.

Bounded Context Owned State Business Operations
Ordering Orders and their lifecycle Place, cancel, confirm order
Inventory Available and reserved stock Reserve, release, adjust stock
Payments Payment attempts and financial state Authorize, capture, refund
Fulfillment Shipments and delivery progress Create shipment, dispatch, track

Ordering should not directly update Inventory's stock tables or Payments' authorization state.

Instead, it communicates through explicit operations and events. Inventory remains responsible for deciding whether stock can be reserved; Payments remains responsible for financial authorization and reconciliation.

Aggregate Boundaries and Consistency

Inside Ordering, the Order aggregate can enforce consistency between order status, line items, totals, and permitted state transitions.

Inside Inventory, a StockItem or Reservation aggregate may enforce that available inventory cannot become negative.

These are different consistency boundaries.

A local order transaction should not attempt to synchronously lock records inside every participating context merely because they are part of the same checkout workflow.

The important distinctions are:

  • Aggregate boundary: Which domain invariants require immediate consistency?
  • Bounded context: Where does one coherent domain model apply?
  • Deployment boundary: Which components should run and deploy independently?
  • Transaction boundary: Which state changes can be committed atomically?

These boundaries influence one another but are not identical.

One bounded context may contain many aggregates. Multiple bounded contexts may run inside one deployment. A business workflow may span several independently committed transactions.

Cross-Context Workflows and Failure Recovery

A checkout process can begin when Ordering places a new order and records an OrderPlaced event.

Other contexts respond according to their responsibilities:

  1. Ordering creates a pending order and records the event.
  2. Inventory attempts to reserve the required stock.
  3. Payments attempts to authorize the payment.
  4. Ordering evaluates the outcomes and confirms or rejects the order.
  5. Fulfillment begins after the order is confirmed.

The workflow must account for failures and delays. Payment authorization may time out after succeeding, inventory reservation may fail, or an event may be delivered more than once.

Use stable operation identifiers, durable workflow state, idempotent processing, and compensating operations where required.

For multi-service transactions, the Saga pattern provides a way to coordinate local transactions and compensations without relying on one global database transaction.

DDD helps define the participating business responsibilities; saga orchestration and reliable messaging address the distributed execution behavior.

Ready-to-Use Example with Python and FastAPI

The following example implements the Ordering bounded context. It includes an Order aggregate, immutable value objects, a domain event, an application service, a PostgreSQL repository, and an HTTP adapter.

Order creation persists the aggregate and an outbox event in the same database transaction. The example intentionally stops at durable event recording; publishing the outbox to a message broker is a separate responsibility that requires a publisher, retries, and delivery monitoring.

Project Structure and Dependencies

Create this project structure and add empty __init__.py files inside the Python package directories:

ddd-orders/
  app/
    __init__.py
    domain.py
    use_cases.py
    postgres.py
    api.py
  tests/
    test_orders.py
  requirements.txt
  docker-compose.yml

Create requirements.txt:

fastapi>=0.115,<1
uvicorn>=0.30,<1
sqlalchemy>=2.0,<3
psycopg[binary]>=3.2,<4
pytest>=8,<10
httpx>=0.27,<1

Use a tested lockfile for production. The version ranges above are intended for the educational application.

Domain Models, Value Objects, and Invariants

The domain layer contains the business model. It has no dependency on FastAPI, SQLAlchemy, or PostgreSQL.

We model Money as a value object, OrderItem as an immutable component, Order as the aggregate root, and OrderPlaced as a domain event.

Create app/domain.py:

from dataclasses import dataclass, field
from datetime import datetime, timezone
from uuid import uuid4


@dataclass(frozen=True)
class Money:
    amount_cents: int
    currency: str = "USD"

    def __post_init__(self) -> None:
        if (
            type(self.amount_cents) is not int
            or self.amount_cents < 0
        ):
            raise ValueError("Invalid amount")

        if self.currency != "USD":
            raise ValueError("Only USD is supported")


@dataclass(frozen=True)
class OrderItem:
    sku: str
    quantity: int
    unit_price: Money

    def __post_init__(self) -> None:
        if not self.sku.strip():
            raise ValueError("SKU is required")

        if (
            type(self.quantity) is not int
            or self.quantity <= 0
        ):
            raise ValueError("Quantity must be positive")

    @property
    def subtotal_cents(self) -> int:
        return (
            self.quantity
            * self.unit_price.amount_cents
        )


@dataclass(frozen=True)
class OrderPlaced:
    event_id: str
    order_id: str
    occurred_at: datetime


@dataclass
class Order:
    order_id: str
    customer_id: str
    items: tuple[OrderItem, ...]
    status: str = "pending"
    events: list[OrderPlaced] = field(
        default_factory=list,
        repr=False,
        compare=False,
    )

    def __post_init__(self) -> None:
        if not self.order_id.strip():
            raise ValueError("Order ID is required")

        if not self.customer_id.strip():
            raise ValueError("Customer ID is required")

        if not self.items:
            raise ValueError("Order requires items")

        skus = [item.sku for item in self.items]

        if len(skus) != len(set(skus)):
            raise ValueError("Duplicate SKUs")

        if self.status not in {
            "pending",
            "confirmed",
            "cancelled",
        }:
            raise ValueError("Invalid order status")

    @property
    def total_cents(self) -> int:
        return sum(
            item.subtotal_cents
            for item in self.items
        )

    @classmethod
    def place(
        cls,
        order_id: str,
        customer_id: str,
        items: tuple[OrderItem, ...],
    ) -> "Order":
        order = cls(
            order_id=order_id,
            customer_id=customer_id,
            items=items,
        )

        # Record a business fact after successful
        # aggregate validation.
        order.events.append(
            OrderPlaced(
                event_id=str(uuid4()),
                order_id=order_id,
                occurred_at=datetime.now(
                    timezone.utc
                ),
            )
        )

        return order

    def confirm(self) -> None:
        if self.status != "pending":
            raise ValueError(
                "Only pending orders can be confirmed"
            )

        self.status = "confirmed"

    def cancel(self) -> None:
        if self.status != "pending":
            raise ValueError(
                "Only pending orders can be cancelled"
            )

        self.status = "cancelled"

The Order aggregate protects its initial structure, validates line items, calculates a monetary total, and enforces permitted status transitions.

The place() factory records an OrderPlaced domain event only after a valid Order has been created.

The event exists in memory until the application persists it. A newly constructed aggregate does not guarantee that any external subscriber has received a message.

For a production financial domain, Money would typically support more currencies and explicit monetary operations. The USD-only rule here keeps the example focused on aggregate behavior.

Application Use Cases and Repository Contract

The application layer coordinates domain behavior and persistence. It should know what an Order is and what it means to place one, but not which SQL statements are required.

Create app/use_cases.py:

from dataclasses import dataclass
from typing import Protocol

from app.domain import Money, Order, OrderItem


class OrderAlreadyExists(Exception):
    pass


class OrderNotFound(Exception):
    pass


class OrderRepository(Protocol):
    def add(self, order: Order) -> bool:
        """
        Persist the order and its pending events
        atomically. False means the ID exists.
        """

    def get(self, order_id: str) -> Order | None:
        """Return an aggregate or None."""


@dataclass(frozen=True)
class ItemInput:
    sku: str
    quantity: int
    unit_price_cents: int


@dataclass(frozen=True)
class PlaceOrderCommand:
    order_id: str
    customer_id: str
    items: tuple[ItemInput, ...]


@dataclass(frozen=True)
class OrderResult:
    order_id: str
    customer_id: str
    status: str
    total_cents: int


def to_result(order: Order) -> OrderResult:
    return OrderResult(
        order_id=order.order_id,
        customer_id=order.customer_id,
        status=order.status,
        total_cents=order.total_cents,
    )


class PlaceOrder:
    def __init__(
        self,
        repository: OrderRepository,
    ) -> None:
        self.repository = repository

    def execute(
        self,
        command: PlaceOrderCommand,
    ) -> OrderResult:
        items = tuple(
            OrderItem(
                sku=item.sku,
                quantity=item.quantity,
                unit_price=Money(
                    amount_cents=item.unit_price_cents
                ),
            )
            for item in command.items
        )

        order = Order.place(
            order_id=command.order_id,
            customer_id=command.customer_id,
            items=items,
        )

        if not self.repository.add(order):
            raise OrderAlreadyExists(
                f"Order {order.order_id} already exists"
            )

        return to_result(order)


class GetOrder:
    def __init__(
        self,
        repository: OrderRepository,
    ) -> None:
        self.repository = repository

    def execute(self, order_id: str) -> OrderResult:
        order = self.repository.get(order_id)

        if order is None:
            raise OrderNotFound(
                f"Order {order_id} not found"
            )

        return to_result(order)

The repository contract explicitly requires aggregate persistence and pending event recording to be atomic.

This is an important design decision. An implementation that commits the order and then separately inserts an outbox event would not satisfy that contract.

The application exposes plain input and result objects instead of returning ORM rows or FastAPI response models.

PostgreSQL Repository and Transactional Outbox

The infrastructure implementation stores orders and pending domain events in PostgreSQL. The same transaction inserts the order and its outbox records.

This prevents the specific failure where an order is committed but its corresponding outbox event is never recorded.

Create app/postgres.py:

import os

from sqlalchemy import (
    Boolean,
    DateTime,
    Integer,
    JSON,
    String,
    create_engine,
)
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    Session,
    mapped_column,
)

from app.domain import Money, Order, OrderItem


DATABASE_URL = os.getenv(
    "DATABASE_URL",
    "postgresql+psycopg://app:app@localhost:5432/orders",
)

engine = create_engine(
    DATABASE_URL,
    pool_pre_ping=True,
)


class Base(DeclarativeBase):
    pass


class OrderRow(Base):
    __tablename__ = "orders"

    order_id: Mapped[str] = mapped_column(
        String(100),
        primary_key=True,
    )
    customer_id: Mapped[str] = mapped_column(
        String(100),
        nullable=False,
    )
    status: Mapped[str] = mapped_column(
        String(30),
        nullable=False,
    )
    items: Mapped[list] = mapped_column(
        JSON,
        nullable=False,
    )
    total_cents: Mapped[int] = mapped_column(
        Integer,
        nullable=False,
    )


class OutboxRow(Base):
    __tablename__ = "outbox"

    event_id: Mapped[str] = mapped_column(
        String(100),
        primary_key=True,
    )
    event_type: Mapped[str] = mapped_column(
        String(100),
        nullable=False,
    )
    aggregate_id: Mapped[str] = mapped_column(
        String(100),
        nullable=False,
        index=True,
    )
    payload: Mapped[dict] = mapped_column(
        JSON,
        nullable=False,
    )
    occurred_at: Mapped[object] = mapped_column(
        DateTime(timezone=True),
        nullable=False,
    )
    published: Mapped[bool] = mapped_column(
        Boolean,
        nullable=False,
        default=False,
    )


class PostgresOrderRepository:
    def __init__(
        self,
        session: Session,
    ) -> None:
        self.session = session

    def add(self, order: Order) -> bool:
        items = [
            {
                "sku": item.sku,
                "quantity": item.quantity,
                "unit_price_cents": (
                    item.unit_price.amount_cents
                ),
            }
            for item in order.items
        ]

        statement = (
            insert(OrderRow)
            .values(
                order_id=order.order_id,
                customer_id=order.customer_id,
                status=order.status,
                items=items,
                total_cents=order.total_cents,
            )
            .on_conflict_do_nothing(
                index_elements=["order_id"]
            )
            .returning(OrderRow.order_id)
        )

        # Both the aggregate and pending events
        # are recorded in one ACID transaction.
        with self.session.begin():
            inserted_id = self.session.execute(
                statement
            ).scalar_one_or_none()

            if inserted_id is None:
                return False

            for event in order.events:
                self.session.add(
                    OutboxRow(
                        event_id=event.event_id,
                        event_type="OrderPlaced",
                        aggregate_id=event.order_id,
                        occurred_at=event.occurred_at,
                        payload={
                            "event_id": event.event_id,
                            "order_id": event.order_id,
                        },
                        published=False,
                    )
                )

        return True

    def get(
        self,
        order_id: str,
    ) -> Order | None:
        row = self.session.get(
            OrderRow,
            order_id,
        )

        if row is None:
            return None

        return Order(
            order_id=row.order_id,
            customer_id=row.customer_id,
            status=row.status,
            items=tuple(
                OrderItem(
                    sku=item["sku"],
                    quantity=item["quantity"],
                    unit_price=Money(
                        amount_cents=(
                            item["unit_price_cents"]
                        )
                    ),
                )
                for item in row.items
            ),
        )

The PostgreSQL primary key protects against duplicate order IDs under concurrent requests. The outbox rows are committed only when the order insert succeeds.

If inserting an outbox record fails, the transaction rolls back the order insert as well.

This example demonstrates atomic persistence, not event publication. A production outbox dispatcher should claim pending records safely, publish them to the chosen broker, manage retries, and track delivery status.

Consumers must still tolerate duplicate delivery. Marking an event as published after sending it can fail if the process crashes between the broker acknowledgment and database update.

For a complete workflow, use publisher locking or leasing, bounded retry policies, dead-letter handling, and idempotent event consumers.

FastAPI Endpoints and Dependency Injection

The HTTP adapter converts requests into application commands and maps application outcomes to HTTP responses.

Create app/api.py:

from contextlib import asynccontextmanager
from typing import Annotated, Iterator

from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session

from app.postgres import (
    Base,
    PostgresOrderRepository,
    engine,
)
from app.use_cases import (
    GetOrder,
    ItemInput,
    OrderAlreadyExists,
    OrderNotFound,
    PlaceOrder,
    PlaceOrderCommand,
)


class OrderItemRequest(BaseModel):
    sku: str = Field(min_length=1)
    quantity: int = Field(gt=0)
    unit_price_cents: int = Field(ge=0)


class PlaceOrderRequest(BaseModel):
    order_id: str = Field(min_length=1)
    customer_id: str = Field(min_length=1)
    items: list[OrderItemRequest] = Field(
        min_length=1
    )


class OrderResponse(BaseModel):
    order_id: str
    customer_id: str
    status: str
    total_cents: int


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Demo initialization; use migrations
    # for production schema changes.
    Base.metadata.create_all(bind=engine)
    yield
    engine.dispose()


app = FastAPI(
    title="DDD Ordering Context",
    lifespan=lifespan,
)


def get_session() -> Iterator[Session]:
    with Session(engine) as session:
        yield session


def get_repository(
    session: Annotated[
        Session,
        Depends(get_session),
    ],
) -> PostgresOrderRepository:
    return PostgresOrderRepository(session)


@app.post(
    "/orders",
    status_code=201,
    response_model=OrderResponse,
)
def place_order(
    request: PlaceOrderRequest,
    repository: Annotated[
        PostgresOrderRepository,
        Depends(get_repository),
    ],
):
    command = PlaceOrderCommand(
        order_id=request.order_id,
        customer_id=request.customer_id,
        items=tuple(
            ItemInput(
                sku=item.sku,
                quantity=item.quantity,
                unit_price_cents=item.unit_price_cents,
            )
            for item in request.items
        ),
    )

    try:
        return PlaceOrder(repository).execute(
            command
        )
    except ValueError as exc:
        raise HTTPException(
            status_code=422,
            detail=str(exc),
        ) from exc
    except OrderAlreadyExists as exc:
        raise HTTPException(
            status_code=409,
            detail=str(exc),
        ) from exc


@app.get(
    "/orders/{order_id}",
    response_model=OrderResponse,
)
def get_order(
    order_id: str,
    repository: Annotated[
        PostgresOrderRepository,
        Depends(get_repository),
    ],
):
    try:
        return GetOrder(repository).execute(
            order_id
        )
    except OrderNotFound as exc:
        raise HTTPException(
            status_code=404,
            detail=str(exc),
        ) from exc

FastAPI handles transport-level validation and response serialization. The domain and application use cases have no dependency on the HTTP framework.

The application uses synchronous SQLAlchemy operations from synchronous FastAPI endpoints. This is intentional; an async implementation should use the corresponding async database interfaces and session lifecycle.

Unit Tests for Domain Behavior

Unit tests should validate domain invariants and application workflows without requiring a PostgreSQL instance.

Create tests/test_orders.py:

import pytest

from app.domain import Money, Order, OrderItem
from app.use_cases import (
    GetOrder,
    ItemInput,
    OrderAlreadyExists,
    OrderNotFound,
    PlaceOrder,
    PlaceOrderCommand,
)


class InMemoryOrderRepository:
    def __init__(self) -> None:
        self.orders: dict[str, Order] = {}
        self.outbox = []

    def add(self, order: Order) -> bool:
        if order.order_id in self.orders:
            return False

        self.orders[order.order_id] = order
        self.outbox.extend(order.events)
        return True

    def get(self, order_id: str) -> Order | None:
        return self.orders.get(order_id)


def sample_command() -> PlaceOrderCommand:
    return PlaceOrderCommand(
        order_id="order-1001",
        customer_id="customer-42",
        items=(
            ItemInput(
                sku="keyboard",
                quantity=2,
                unit_price_cents=2000,
            ),
        ),
    )


def test_order_total():
    result = PlaceOrder(
        InMemoryOrderRepository()
    ).execute(sample_command())

    assert result.total_cents == 4000
    assert result.status == "pending"


def test_order_placed_event():
    repo = InMemoryOrderRepository()

    PlaceOrder(repo).execute(sample_command())

    assert len(repo.outbox) == 1
    assert repo.outbox[0].order_id == "order-1001"


def test_duplicate_order_is_rejected():
    repo = InMemoryOrderRepository()
    use_case = PlaceOrder(repo)

    use_case.execute(sample_command())

    with pytest.raises(OrderAlreadyExists):
        use_case.execute(sample_command())

    # No second event is persisted.
    assert len(repo.outbox) == 1


def test_invalid_quantity():
    with pytest.raises(ValueError):
        OrderItem(
            sku="keyboard",
            quantity=0,
            unit_price=Money(2000),
        )


def test_cannot_confirm_twice():
    order = Order.place(
        order_id="order-2",
        customer_id="customer-42",
        items=(
            OrderItem(
                sku="keyboard",
                quantity=1,
                unit_price=Money(2000),
            ),
        ),
    )

    order.confirm()

    with pytest.raises(ValueError):
        order.confirm()


def test_cannot_cancel_confirmed_order():
    order = Order.place(
        order_id="order-3",
        customer_id="customer-42",
        items=(
            OrderItem(
                sku="mouse",
                quantity=1,
                unit_price=Money(1500),
            ),
        ),
    )

    order.confirm()

    with pytest.raises(ValueError):
        order.cancel()


def test_retrieve_existing_order():
    repo = InMemoryOrderRepository()

    PlaceOrder(repo).execute(sample_command())

    result = GetOrder(repo).execute(
        "order-1001"
    )

    assert result.customer_id == "customer-42"


def test_missing_order():
    with pytest.raises(OrderNotFound):
        GetOrder(
            InMemoryOrderRepository()
        ).execute("missing")

The tests verify actual business behavior: order validation, aggregate transitions, duplicate prevention, and event recording.

They do not prove that PostgreSQL commits the order and outbox atomically. That guarantee needs integration testing against a real database, including deliberate transaction failures and concurrent insert attempts.

Run the Application and Inspect Domain Events

Use PostgreSQL through Docker Compose. The API runs directly through Uvicorn.

Create docker-compose.yml:

services:
  postgres:
    image: postgres:16-alpine

    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app

    ports:
      - "127.0.0.1:5432:5432"

    healthcheck:
      test:
        - CMD-SHELL
        - pg_isready -U app -d orders
      interval: 5s
      timeout: 3s
      retries: 10

The credentials are intended for local development only. Production deployments should use managed secrets and appropriate network isolation.

Run the following commands:

# Start the local database.
docker compose up -d --wait

# Create the Python environment.
python3 -m venv .venv
source .venv/bin/activate

# Install dependencies.
pip install -r requirements.txt

# Run domain and application tests.
pytest -q

# Configure PostgreSQL.
export DATABASE_URL="postgresql+psycopg://app:app@localhost:5432/orders"

# Start the API.
uvicorn app.api:app --reload

# In another terminal, place an order.
curl -i -X POST \
  http://127.0.0.1:8000/orders \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "order-1001",
    "customer_id": "customer-42",
    "items": [
      {
        "sku": "keyboard",
        "quantity": 2,
        "unit_price_cents": 2000
      }
    ]
  }'

# Retrieve the order.
curl -i \
  http://127.0.0.1:8000/orders/order-1001

# Inspect pending domain events.
docker compose exec postgres \
  psql -U app -d orders \
  -c 'SELECT event_id, event_type, aggregate_id, published FROM outbox;'

# Clean up local resources when finished.
docker compose down

The create request should return 201 Created with a pending order and a total of 4,000 cents. The outbox query should show an unpublished OrderPlaced event with the same order ID.

This demonstrates a useful production design principle: the aggregate and its recorded event are stored in the same transaction, while event delivery is intentionally decoupled.

For a complete system, add an outbox publisher, reliable message transport, delivery monitoring, and idempotent consumers. The publisher should not mark an event as delivered merely because it attempted to send the message.

DDD vs. Hexagonal, Onion, and Clean Architecture

DDD is frequently used alongside Hexagonal, Onion, and Clean Architecture, but they address different aspects of software design.

DDD focuses primarily on understanding and modeling business behavior. The other patterns focus more explicitly on how to organize application dependencies and isolate infrastructure.

Domain Modeling vs. Code Organization

The distinctions can be summarized as follows:

Approach Primary Question Main Emphasis
Domain-Driven Design How should business concepts and boundaries be modeled? Language, domain knowledge, aggregates, bounded contexts
Hexagonal Architecture How should the application communicate with external systems? Ports and adapters
Onion Architecture How should dependencies be arranged around the domain? Domain-centered layers and inward dependencies
Clean Architecture How should entities, use cases, and interface boundaries be organized? Business rules and explicit dependency boundaries

These approaches are complementary. A DDD Order aggregate can live inside a Hexagonal application core, an Onion domain layer, or a Clean Architecture entity module.

Using DDD does not require a specific folder structure, and implementing Clean Architecture does not automatically mean the domain model is correct.

When Domain-Driven Design Makes Sense

DDD is particularly useful for software with complex business rules, frequent policy changes, multiple business capabilities, or significant collaboration between technical and nontechnical experts.

Examples include:

  • Payment and financial transaction platforms.
  • Insurance claims and underwriting systems.
  • Inventory and supply chain management.
  • Order and fulfillment platforms.
  • Healthcare scheduling and clinical workflow applications.
  • Enterprise systems with multiple teams and responsibilities.

For simple CRUD systems with minimal business behavior, tactical DDD may introduce unnecessary classes and abstractions.

However, strategic DDD techniques can still be useful even when the implementation is simple. Clear terminology and responsibility boundaries improve communication without requiring elaborate aggregate models.

Common Mistakes and Production Practices

DDD is often misapplied when developers focus on class names and directory structures instead of understanding the business domain.

The quality of the model depends on domain knowledge, explicit invariants, and correct boundaries—not the number of patterns used.

Common DDD Implementation Mistakes

Common mistakes include:

  • Starting from database tables: Produces persistence-shaped models rather than business behavior.
  • Skipping domain experts: Developers invent business rules without validating their assumptions.
  • Using one universal model: Different bounded contexts become coupled through shared entities.
  • Creating aggregates that are too large: Excessive transaction scope and contention reduce scalability.
  • Treating every entity as an aggregate root: Consistency rules become fragmented.
  • Using anemic domain models: Entities contain fields while all business rules live in generic services.
  • Creating a microservice for every bounded context: Adds network and operational overhead without guaranteed value.
  • Confusing events with commands: Events should describe facts, not merely instruct receivers to perform work.
  • Publishing events without durable coordination: Database changes and messages can become inconsistent.
  • Assuming exactly-once business processing: Message redelivery can duplicate external side effects.
  • Sharing writable database state across contexts: Business ownership becomes ambiguous.
  • Overengineering simple workflows: Excessive abstractions obscure straightforward behavior.

A useful modeling review asks whether a class or operation represents a real business concept, whether its invariants are explicit, and whether its boundary matches the business responsibility.

Production Checklist

Before deploying a DDD-based application, verify that:

  • Important domain terminology is understood and consistently used.
  • Core, supporting, and generic subdomains are identified where useful.
  • Bounded contexts have clear model and responsibility ownership.
  • Aggregate roots protect meaningful consistency invariants.
  • Value objects enforce valid representations.
  • Business rules live in domain entities or appropriate domain services.
  • Application services coordinate use cases without duplicating domain policies.
  • Repositories operate through domain-oriented contracts.
  • Database constraints support critical invariants under concurrency.
  • Transaction boundaries are explicitly defined.
  • Domain event recording and external event publishing are distinguished.
  • Outbox publication supports retries and failure recovery.
  • Message consumers tolerate duplicate delivery.
  • Cross-context integrations preserve model ownership.
  • Unit tests exercise aggregate behavior independently of infrastructure.
  • Integration tests validate database transactions and external adapters.
  • Operational monitoring exposes failed and delayed business workflows.

Frequently Asked Questions

DDD introduces terminology that overlaps with architecture patterns and distributed-system concepts. Understanding its actual requirements helps prevent unnecessary complexity.

Does DDD Require Microservices?

No. DDD can be implemented inside monoliths, modular monoliths, microservices, and other application structures.

Bounded contexts define model boundaries, not mandatory deployment boundaries. A monolith can contain multiple independently modeled contexts.

What Is the Difference Between an Aggregate and a Bounded Context?

An aggregate is a consistency boundary around related domain objects, controlled through an aggregate root.

A bounded context defines where a coherent domain model and vocabulary apply.

One bounded context may contain several aggregates, and a bounded context does not necessarily correspond to one microservice.

Does Every Entity Need a Repository?

No. Repositories are commonly designed around aggregate roots rather than every individual entity.

Objects owned by an aggregate are generally accessed and modified through the root so its invariants remain protected.

Are Domain Events the Same as Integration Events?

Not necessarily. A domain event represents a meaningful occurrence within the domain model.

An integration event is a contract published for other contexts or applications. It may be derived from a domain event, but often requires separate schema design, versioning, delivery guarantees, and security considerations.

Should Every CRUD Application Use DDD?

No. Systems with minimal business behavior may be easier to maintain using simpler application and persistence structures.

DDD becomes more valuable as business rules, terminology, relationships, and organizational boundaries become more complex.

Can You Introduce DDD into an Existing Application?

Yes. DDD can be introduced incrementally by identifying important business capabilities, improving domain terminology, extracting business rules, and clarifying ownership boundaries.

There is no requirement to rewrite the application or immediately split it into microservices.

Conclusion

Domain-Driven Design helps developers create software that reflects the business domain rather than allowing infrastructure choices and database schemas to determine the model.

Strategic DDD identifies coherent business contexts and relationships. Tactical DDD provides patterns such as entities, value objects, aggregates, domain services, repositories, and domain events for expressing business behavior in code.

The most important practice is continuous collaboration with domain experts. A technically elegant model that misrepresents business rules is still the wrong model.

When combined with clear application boundaries, appropriate transaction design, and reliable messaging, DDD can improve maintainability and consistency across complex business systems without requiring unnecessary microservices or abstraction layers.

Key Takeaway: Start with the business domain, establish a shared language, identify meaningful boundaries, and design aggregates around invariants that must remain consistent. Let infrastructure support the model instead of forcing the model to follow infrastructure.

For further reading on the original modeling concepts, see Eric Evans' Domain-Driven Design Reference and Martin Fowler's overview of Domain-Driven Design.

Comments (0)

Related articles