Skip to content

DDD in Action: From Sticky Notes to Production Code

Theory is easy. Practice is where DDD gets real — and where most teams get stuck.

You've read about Bounded Contexts and Aggregates. You understand Ubiquitous Language. But when you sit down with your team in front of a real codebase and real business requirements, a critical question remains: how do you actually do this?

This guide takes you through the complete DDD journey on a concrete real-world system — an online bookstore — from the first collaborative workshop all the way to working production code. Every design decision is explained. Every mistake is shown before the fix. By the end, you'll have a repeatable playbook you can apply to your own system.

This is Part 2 of a DDD series

This guide focuses on the practice of applying DDD. For the foundational theory — Bounded Contexts, Aggregates, Value Objects, Domain Events — read the DDD Complete Guide first.


Our Running Example: An Online Bookstore

Throughout this guide we'll build the core of an online bookstore called Bookly. It sells physical and digital books, supports memberships with discounts, and ships orders worldwide.

At first glance it seems simple. But as we'll discover through Event Storming, the domain is richer and more complex than it appears — exactly the kind of system where DDD pays off.


The Journey Overview

DDD in action follows six phases. We'll cover each one in depth:

  Phase 1          Phase 2          Phase 3
  DISCOVER    →    DEFINE      →    DESIGN
  (Event            (Bounded         (Aggregates
   Storming)         Contexts)        & Models)
  Phase 6          Phase 5          Phase 4
  VALIDATE    ←    INTEGRATE   ←    IMPLEMENT
  (Tests &          (Events &        (Code the
   Refinement)       APIs)            Domain)

Phase 1: Domain Discovery with Event Storming

Event Storming is the fastest way to build shared understanding of a complex domain. It was invented by Alberto Brandolini and takes 4–8 hours for a domain the size of Bookly.

Who Needs to Be in the Room

This is not a developer-only session. You need everyone who understands a slice of the business:

Role What They Contribute
Domain Experts What actually happens in the business
Product Managers What outcomes the business needs
Developers What's technically feasible
UX Designers What users experience
Support / Ops What goes wrong and how it's fixed

The most valuable moments in Event Storming happen when a developer says "wait, that's not how I thought this worked" and a domain expert says "yes, that's exactly the problem we've had for years."

Setting Up the Room

You need:

  • A wall at least 5 metres wide (use paper rolls taped together)
  • Sticky notes in six colours — buy 300+ of each
  • Thick markers (Sharpies) — thin pens can't be read from a distance
  • Roughly 2 hours per session (4 sessions for a full domain)
Colour Represents Format
🟠 Orange Domain Events Past tense verb — "Order Placed"
🔵 Blue Commands Imperative verb — "Place Order"
🟡 Yellow Actors / Users Who triggers the command
🟣 Purple Policies "When X happens, do Y"
🩷 Pink External Systems Stripe, SendGrid, FedEx API
🟢 Green Read Models What information a user sees

Step 1: Chaotic Exploration (45 minutes)

Everyone writes Domain Events on orange stickies and places them on the wall — in any order, no rules. The facilitator's only job is to keep the energy up.

Events are things that happened in the business — past tense, business language, meaningful to a domain expert:

🟠 Book Searched        🟠 Book Added to Cart     🟠 Checkout Started
🟠 Payment Processed    🟠 Order Confirmed         🟠 Inventory Reserved
🟠 Shipment Dispatched  🟠 Book Downloaded         🟠 Membership Activated
🟠 Review Submitted     🟠 Refund Requested        🟠 Order Cancelled
🟠 Delivery Confirmed   🟠 Discount Applied        🟠 Wishlist Updated
🟠 Coupon Redeemed      🟠 Address Validated       🟠 Fraud Detected

The 'stupid question' rule

During chaotic exploration, no sticky note is wrong. Encourage everyone to write duplicates — duplicates reveal disagreements about terminology, which is exactly what you need to surface.

Step 2: Order the Timeline (30 minutes)

Arrange the events left-to-right in rough chronological order. Don't force perfect order — focus on revealing the narrative:

  TIMELINE (left → right = earlier → later):

  ──────────────────────────────────────────────────────────────────→ time

  [Book       [Book Added   [Checkout    [Payment     [Order       [Book
   Searched]   to Cart]      Started]     Processed]   Confirmed]   Downloaded]

                                         [Payment
                                          Failed]

  [Order       [Inventory   [Shipment    [Delivery    [Review
   Confirmed]   Reserved]    Dispatched]  Confirmed]   Submitted]

Disagreements about ordering are gold — they reveal missing steps, unclear ownership, and hidden complexity.

Step 3: Commands and Actors (30 minutes)

For each event, add the blue Command that caused it and the yellow Actor who triggered it:

  🟡 Customer     🔵 Search Books    →    🟠 Book Searched
  🟡 Customer     🔵 Add to Cart     →    🟠 Book Added to Cart
  🟡 Customer     🔵 Initiate Checkout → 🟠 Checkout Started
  🟡 Payment GW   🔵 Process Payment →   🟠 Payment Processed
  🟣 Policy       [When Payment OK]  →    🟠 Order Confirmed
  🟣 Policy       [When Order OK]    →    🟠 Inventory Reserved
  🟡 Warehouse    🔵 Dispatch Order  →    🟠 Shipment Dispatched
  🟡 System       🔵 Deliver File    →    🟠 Book Downloaded

Step 4: Aggregates and Policies (30 minutes)

Group commands and events under yellow Aggregate stickies (large ones). Add purple Policy stickies that describe automation rules:

  🟡 CART Aggregate:
     [Add to Cart] → [Book Added to Cart]
     [Remove from Cart] → [Book Removed from Cart]
     [Apply Coupon] → [Coupon Applied]

  🟡 ORDER Aggregate:
     [Place Order] → [Order Placed]
     [Cancel Order] → [Order Cancelled]
     [Confirm Order] ← 🟣 "When payment succeeds"

  🟡 PAYMENT Aggregate:
     [Process Payment] → [Payment Processed]
                      → [Payment Failed]
     [Refund Payment] → [Refund Issued]

  🟣 POLICY: When Order Confirmed → Reserve Inventory
  🟣 POLICY: When Payment Failed → Restore Cart Items
  🟣 POLICY: When Inventory Reserved → Schedule Shipment

Step 5: External Systems (20 minutes)

Add pink stickies for systems your domain interacts with:

  🩷 Stripe           (payment processing)
  🩷 SendGrid         (email notifications)
  🩷 FedEx API        (shipping rates + tracking)
  🩷 Google Books API (book metadata enrichment)
  🩷 Fraud Detection  (order risk scoring)

Reading the Event Storm Output

When you step back, you should see natural groupings — areas of the board that cluster tightly. These are your candidate Bounded Contexts:

  ┌─────────────────┐  ┌──────────────┐  ┌──────────────────┐
  │ CATALOG cluster │  │ ORDER cluster│  │ PAYMENT cluster  │
  │                 │  │              │  │                  │
  │ Book Searched   │  │ Cart Updated │  │ Payment Process  │
  │ Catalog Updated │  │ Order Placed │  │ Fraud Checked    │
  │ Review Added    │  │ Order Cancel │  │ Refund Issued    │
  └─────────────────┘  └──────────────┘  └──────────────────┘

  ┌──────────────────┐  ┌───────────────┐  ┌──────────────┐
  │ INVENTORY cluster│  │SHIPPING cluster│  │ MEMBER cluster│
  │                  │  │                │  │              │
  │ Stock Reserved   │  │ Shipment Sched │  │ Member Joined│
  │ Stock Released   │  │ Label Printed  │  │ Points Earned│
  │ Stock Adjusted   │  │ Delivered      │  │ Tier Upgraded│
  └──────────────────┘  └───────────────┘  └──────────────┘

Phase 2: Defining Bounded Contexts

With the Event Storm output in hand, we now draw formal boundaries.

The Six Bounded Contexts of Bookly

  ┌───────────────────────────────────────────────────────────────────┐
  │                        BOOKLY PLATFORM                            │
  │                                                                   │
  │  ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────────┐  │
  │  │ CATALOG  │   │  ORDERS  │   │ PAYMENTS │   │  INVENTORY   │  │
  │  │          │   │          │   │          │   │              │  │
  │  │Books     │   │Cart      │   │Transact  │   │Stock         │  │
  │  │Authors   │   │Orders    │   │Refunds   │   │Reservations  │  │
  │  │Reviews   │   │Returns   │   │Invoices  │   │Adjustments   │  │
  │  └──────────┘   └──────────┘   └──────────┘   └──────────────┘  │
  │                                                                   │
  │  ┌──────────────┐   ┌─────────────────────────────────────────┐  │
  │  │   SHIPPING   │   │           MEMBERSHIP                    │  │
  │  │              │   │                                         │  │
  │  │Shipments     │   │Members  Tiers  Points  Discounts        │  │
  │  │Tracking      │   │                                         │  │
  │  │Carriers      │   └─────────────────────────────────────────┘  │
  │  └──────────────┘                                                 │
  └───────────────────────────────────────────────────────────────────┘

The Same Word, Different Worlds

Here's how the term "Book" means something different in each context:

Context "Book" means Key attributes
Catalog Something a customer can discover Title, author, description, cover image, reviews, ISBN
Orders A line item in a purchase Product ID, title (snapshot), price at time of order
Inventory A unit of stock SKU, quantity on hand, reorder threshold, warehouse location
Shipping A physical package Weight, dimensions, fragility, shipping class

This is intentional, not a design flaw

Seeing the same concept modeled differently in each context is correct DDD. Forcing a single shared Book model across all contexts creates a tightly coupled, fragile system. Separate models, even with some duplication, are the right call.

Building the Context Map

Now document how the bounded contexts relate to each other:

  BOOKLY CONTEXT MAP

  ┌──────────┐                              ┌──────────┐
  │ CATALOG  │ ──── Open Host Service ────→ │  ORDERS  │
  │  (U)     │      Published Language      │  (D/ACL) │
  └──────────┘                              └────┬─────┘
                                                 │ Customer-Supplier
                                                 │ (Orders → Payments)
                                          ┌──────────┐
                                          │ PAYMENTS │
                                          │          │
                                          │ 🩷 Stripe │
                                          │  (Conform│
                                          │   ist)   │
                                          └──────────┘

  ORDERS ──── Domain Events ────→ INVENTORY  (eventual consistency)
  ORDERS ──── Domain Events ────→ SHIPPING   (eventual consistency)
  ORDERS ──── Domain Events ────→ MEMBERSHIP (eventual consistency)

Why these patterns?

  • Catalog → Orders (Open Host + ACL): Catalog is a stable reference service. Orders consumes it but translates the catalog Book into its own OrderLineItem through an ACL so catalog changes don't break orders.
  • Orders → Payments (Customer-Supplier): Orders tells Payments what to charge. Payments has no opinion about the business logic — it just processes money.
  • Payments → Stripe (Conformist): We conform to Stripe's API. They don't change for us.
  • Orders → Inventory/Shipping/Membership (Domain Events): Loose coupling via async events — orders doesn't need to know these services exist.

Phase 3: Designing Aggregates

We'll focus on the Orders bounded context to show aggregate design in full detail.

The Aggregate Discovery Process

Three questions guide aggregate design:

  1. What must be consistent together? (consistency boundary)
  2. What is the entry point for all changes? (aggregate root)
  3. What can be eventually consistent? (use events, not direct references)

Candidate Aggregates in the Orders Context

From our Event Storm, we have these events in the Orders context:

Cart:     Book Added to Cart, Book Removed from Cart, Coupon Applied,
          Checkout Started, Cart Abandoned
Order:    Order Placed, Order Confirmed, Order Cancelled,
          Return Requested, Return Approved

This suggests two aggregates: Cart and Order.

Why Not One Big "Purchase" Aggregate?

A beginner might model Cart and Order as one Purchase aggregate. Let's see why that's wrong:

# BAD: one giant aggregate — everything locked together
@dataclass
class Purchase:
    id: UUID
    customer_id: UUID
    cart_items: list[CartItem]       # changes frequently
    order_status: OrderStatus        # changes rarely
    payment_info: PaymentInfo        # PCI-sensitive data
    shipping_address: Address
    return_requests: list[Return]    # different lifecycle
    invoices: list[Invoice]          # finance team owns this

Problems: - A customer adding a book to cart locks the entire Purchase record, blocking a support agent from processing a return - Loading an order for status display forces loading all cart history - Different teams need to own different parts — impossible if they share one aggregate

The Correct Model: Two Small Aggregates

# GOOD: small, focused aggregates
@dataclass
class Cart:                          # Aggregate Root
    id: UUID
    customer_id: UUID
    _items: list[CartItem]
    _coupon: Optional[Coupon]
    # Cart cares about: items, totals, coupon validity

@dataclass
class Order:                         # Aggregate Root
    id: UUID
    customer_id: UUID
    cart_id: UUID                    # reference by ID only — not the object
    _line_items: list[OrderLineItem]
    _status: OrderStatus
    _shipping_address: Address
    # Order cares about: status transitions, line items (snapshot), shipping

Cart and Order are linked by cart_id — an ID reference, not an object reference. When a Cart is converted to an Order, the Order takes a snapshot of the cart's contents. From that point on, the Cart and Order evolve independently.


Phase 4: Implementing the Domain in Code

Let's build the complete Orders bounded context. We'll use Python, but every pattern applies to any language.

Folder Structure

orders/
├── domain/
│   ├── __init__.py
│   ├── cart.py           # Cart aggregate
│   ├── order.py          # Order aggregate
│   ├── value_objects.py  # Money, Address, Coupon, etc.
│   ├── events.py         # Domain events
│   └── exceptions.py     # Domain exceptions
├── application/
│   ├── __init__.py
│   ├── cart_service.py   # Use cases for cart
│   ├── order_service.py  # Use cases for orders
│   └── dto.py            # Data Transfer Objects
├── infrastructure/
│   ├── __init__.py
│   ├── postgres_cart_repo.py
│   ├── postgres_order_repo.py
│   └── event_publisher.py
└── api/
    ├── __init__.py
    ├── cart_router.py
    └── order_router.py

Value Objects

Value objects carry meaning and enforce constraints without an identity:

# domain/value_objects.py
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from uuid import UUID


@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError(f"Money amount cannot be negative: {self.amount}")
        if len(self.currency) != 3:
            raise ValueError(f"Currency must be ISO 3-letter code: {self.currency}")
        # Normalize to 2 decimal places
        object.__setattr__(self, 'amount', self.amount.quantize(
            Decimal('0.01'), rounding=ROUND_HALF_UP
        ))

    def add(self, other: 'Money') -> 'Money':
        self._assert_same_currency(other)
        return Money(self.amount + other.amount, self.currency)

    def subtract(self, other: 'Money') -> 'Money':
        self._assert_same_currency(other)
        result = self.amount - other.amount
        if result < 0:
            raise ValueError("Result would be negative")
        return Money(result, self.currency)

    def multiply(self, factor: Decimal) -> 'Money':
        return Money(self.amount * factor, self.currency)

    def _assert_same_currency(self, other: 'Money') -> None:
        if self.currency != other.currency:
            raise ValueError(
                f"Currency mismatch: {self.currency} vs {other.currency}"
            )

    def __str__(self) -> str:
        return f"{self.amount} {self.currency}"

    @classmethod
    def zero(cls, currency: str = "USD") -> 'Money':
        return cls(Decimal("0.00"), currency)


@dataclass(frozen=True)
class BookId:
    value: UUID

    def __str__(self):
        return str(self.value)


@dataclass(frozen=True)
class CustomerId:
    value: UUID

    def __str__(self):
        return str(self.value)


@dataclass(frozen=True)
class Address:
    street_line_1: str
    street_line_2: str
    city: str
    state_or_province: str
    postal_code: str
    country_code: str  # ISO 3166-1 alpha-2

    def __post_init__(self):
        if not self.street_line_1.strip():
            raise ValueError("Street address is required")
        if len(self.country_code) != 2:
            raise ValueError("Country code must be ISO 2-letter code")

    @property
    def is_international(self) -> bool:
        return self.country_code != "US"


@dataclass(frozen=True)
class Coupon:
    code: str
    discount_percent: int  # 0-100

    def __post_init__(self):
        if not (0 < self.discount_percent <= 100):
            raise ValueError(f"Discount must be 1-100%: {self.discount_percent}")
        if not self.code.strip():
            raise ValueError("Coupon code cannot be empty")

    def apply_to(self, amount: Money) -> Money:
        discount = amount.multiply(Decimal(self.discount_percent) / 100)
        return amount.subtract(discount)

Domain Events

Events are immutable records of things that happened:

# domain/events.py
from dataclasses import dataclass, field
from datetime import datetime, UTC
from uuid import UUID, uuid4
from .value_objects import Money


@dataclass(frozen=True)
class DomainEvent:
    event_id: UUID = field(default_factory=uuid4)
    occurred_at: datetime = field(default_factory=lambda: datetime.now(UTC))


@dataclass(frozen=True)
class BookAddedToCart(DomainEvent):
    cart_id: UUID
    customer_id: UUID
    book_id: UUID
    book_title: str
    unit_price: Money
    quantity: int


@dataclass(frozen=True)
class BookRemovedFromCart(DomainEvent):
    cart_id: UUID
    book_id: UUID


@dataclass(frozen=True)
class CouponApplied(DomainEvent):
    cart_id: UUID
    coupon_code: str
    discount_percent: int


@dataclass(frozen=True)
class CartCheckedOut(DomainEvent):
    cart_id: UUID
    customer_id: UUID
    total_amount: Money


@dataclass(frozen=True)
class OrderPlaced(DomainEvent):
    order_id: UUID
    customer_id: UUID
    cart_id: UUID
    total_amount: Money
    item_count: int


@dataclass(frozen=True)
class OrderConfirmed(DomainEvent):
    order_id: UUID
    customer_id: UUID
    total_amount: Money


@dataclass(frozen=True)
class OrderCancelled(DomainEvent):
    order_id: UUID
    customer_id: UUID
    reason: str
    refund_amount: Money


@dataclass(frozen=True)
class ReturnRequested(DomainEvent):
    order_id: UUID
    customer_id: UUID
    reason: str

Domain Exceptions

Use specific exceptions that speak the Ubiquitous Language:

# domain/exceptions.py

class DomainError(Exception):
    """Base class for all domain errors."""


class EmptyCartError(DomainError):
    """Cannot check out an empty cart."""


class BookAlreadyInCartError(DomainError):
    def __init__(self, book_id: str):
        super().__init__(f"Book {book_id} is already in the cart")


class CartItemLimitExceededError(DomainError):
    def __init__(self, limit: int):
        super().__init__(f"Cannot add more than {limit} items to a cart")


class CouponAlreadyAppliedError(DomainError):
    """Only one coupon can be applied per cart."""


class InvalidOrderStatusTransitionError(DomainError):
    def __init__(self, current: str, attempted: str):
        super().__init__(
            f"Cannot transition order from {current} to {attempted}"
        )


class OrderAlreadyConfirmedError(DomainError):
    """Order has already been confirmed and cannot be modified."""


class ReturnWindowExpiredError(DomainError):
    """The 30-day return window has expired for this order."""

The Cart Aggregate

# domain/cart.py
from dataclasses import dataclass, field
from decimal import Decimal
from uuid import UUID, uuid4
from typing import Optional

from .value_objects import Money, BookId, CustomerId, Coupon
from .events import (
    BookAddedToCart, BookRemovedFromCart,
    CouponApplied, CartCheckedOut, DomainEvent
)
from .exceptions import (
    BookAlreadyInCartError, CartItemLimitExceededError,
    CouponAlreadyAppliedError, EmptyCartError
)

MAX_CART_ITEMS = 50


@dataclass(frozen=True)
class CartItem:
    """Value Object — a snapshot of a book's details at add-to-cart time."""
    book_id: BookId
    book_title: str
    unit_price: Money
    quantity: int

    @property
    def subtotal(self) -> Money:
        return self.unit_price.multiply(Decimal(self.quantity))


@dataclass
class Cart:
    """
    Aggregate Root for the shopping cart.

    Invariants enforced:
    - Max 50 items
    - A book appears at most once (quantity updated, not duplicated)
    - Only one coupon can be active at a time
    - Cart total is always consistent with items
    """
    id: UUID
    customer_id: CustomerId
    _items: list[CartItem] = field(default_factory=list)
    _coupon: Optional[Coupon] = field(default=None)
    _events: list[DomainEvent] = field(default_factory=list)

    # ── Factory ────────────────────────────────────────────────────
    @classmethod
    def create(cls, customer_id: CustomerId) -> 'Cart':
        return cls(id=uuid4(), customer_id=customer_id)

    # ── Commands ───────────────────────────────────────────────────
    def add_book(
        self,
        book_id: BookId,
        book_title: str,
        unit_price: Money,
        quantity: int = 1
    ) -> None:
        # Enforce: max items limit
        if len(self._items) >= MAX_CART_ITEMS:
            raise CartItemLimitExceededError(MAX_CART_ITEMS)

        # Enforce: no duplicates — update quantity instead
        existing = self._find_item(book_id)
        if existing:
            raise BookAlreadyInCartError(str(book_id))

        item = CartItem(
            book_id=book_id,
            book_title=book_title,
            unit_price=unit_price,
            quantity=quantity,
        )
        self._items.append(item)
        self._record(BookAddedToCart(
            cart_id=self.id,
            customer_id=self.customer_id.value,
            book_id=book_id.value,
            book_title=book_title,
            unit_price=unit_price,
            quantity=quantity,
        ))

    def remove_book(self, book_id: BookId) -> None:
        item = self._find_item(book_id)
        if item:
            self._items.remove(item)
            self._record(BookRemovedFromCart(
                cart_id=self.id,
                book_id=book_id.value,
            ))

    def apply_coupon(self, coupon: Coupon) -> None:
        if self._coupon is not None:
            raise CouponAlreadyAppliedError(
                f"Coupon {self._coupon.code} is already applied. "
                f"Remove it before applying a new one."
            )
        self._coupon = coupon
        self._record(CouponApplied(
            cart_id=self.id,
            coupon_code=coupon.code,
            discount_percent=coupon.discount_percent,
        ))

    def checkout(self) -> None:
        if not self._items:
            raise EmptyCartError("Cannot check out an empty cart")
        self._record(CartCheckedOut(
            cart_id=self.id,
            customer_id=self.customer_id.value,
            total_amount=self.total,
        ))

    # ── Queries ────────────────────────────────────────────────────
    @property
    def items(self) -> list[CartItem]:
        return list(self._items)   # defensive copy

    @property
    def subtotal(self) -> Money:
        if not self._items:
            return Money.zero()
        result = self._items[0].subtotal
        for item in self._items[1:]:
            result = result.add(item.subtotal)
        return result

    @property
    def total(self) -> Money:
        sub = self.subtotal
        if self._coupon:
            return self._coupon.apply_to(sub)
        return sub

    @property
    def item_count(self) -> int:
        return sum(item.quantity for item in self._items)

    def is_empty(self) -> bool:
        return len(self._items) == 0

    # ── Event infrastructure ───────────────────────────────────────
    def pull_events(self) -> list[DomainEvent]:
        events = list(self._events)
        self._events.clear()
        return events

    # ── Private helpers ────────────────────────────────────────────
    def _find_item(self, book_id: BookId) -> Optional[CartItem]:
        return next((i for i in self._items if i.book_id == book_id), None)

    def _record(self, event: DomainEvent) -> None:
        self._events.append(event)

The Order Aggregate

# domain/order.py
from dataclasses import dataclass, field
from datetime import datetime, UTC, timedelta
from decimal import Decimal
from enum import Enum
from uuid import UUID, uuid4
from typing import Optional

from .value_objects import Money, BookId, CustomerId, Address
from .events import (
    OrderPlaced, OrderConfirmed, OrderCancelled,
    ReturnRequested, DomainEvent
)
from .exceptions import (
    InvalidOrderStatusTransitionError, OrderAlreadyConfirmedError,
    ReturnWindowExpiredError
)
from .cart import Cart, CartItem

RETURN_WINDOW_DAYS = 30


class OrderStatus(Enum):
    PENDING_PAYMENT = "pending_payment"
    CONFIRMED = "confirmed"
    PROCESSING = "processing"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"
    RETURN_REQUESTED = "return_requested"
    REFUNDED = "refunded"

    def can_cancel(self) -> bool:
        return self in (
            OrderStatus.PENDING_PAYMENT,
            OrderStatus.CONFIRMED,
            OrderStatus.PROCESSING,
        )

    def can_request_return(self) -> bool:
        return self in (OrderStatus.DELIVERED,)


@dataclass(frozen=True)
class OrderLineItem:
    """
    Value Object — a frozen snapshot of a cart item.
    Prices and titles do NOT update when the catalog changes.
    This is intentional — it records what the customer agreed to pay.
    """
    book_id: BookId
    book_title: str        # snapshot — not a live reference
    unit_price: Money      # snapshot — price at time of order
    quantity: int

    @property
    def subtotal(self) -> Money:
        return self.unit_price.multiply(Decimal(self.quantity))


@dataclass
class Order:
    """
    Aggregate Root for a customer's purchase.

    Invariants enforced:
    - Status can only follow valid transitions
    - Cannot cancel a shipped/delivered order
    - Returns only within 30-day window
    - Line items are immutable after order placement
    """
    id: UUID
    customer_id: CustomerId
    cart_id: UUID
    status: OrderStatus
    shipping_address: Address
    _line_items: list[OrderLineItem]
    placed_at: datetime
    confirmed_at: Optional[datetime] = None
    delivered_at: Optional[datetime] = None
    _events: list[DomainEvent] = field(default_factory=list)

    # ── Factory ────────────────────────────────────────────────────
    @classmethod
    def place_from_cart(
        cls,
        cart: Cart,
        shipping_address: Address,
    ) -> 'Order':
        """
        Creates an Order from a Cart.
        Takes a snapshot of cart items — Cart and Order then evolve independently.
        """
        if cart.is_empty():
            raise ValueError("Cannot place an order from an empty cart")

        # Snapshot all cart items into OrderLineItems
        line_items = [
            OrderLineItem(
                book_id=item.book_id,
                book_title=item.book_title,
                unit_price=item.unit_price,
                quantity=item.quantity,
            )
            for item in cart.items
        ]

        order = cls(
            id=uuid4(),
            customer_id=cart.customer_id,
            cart_id=cart.id,
            status=OrderStatus.PENDING_PAYMENT,
            shipping_address=shipping_address,
            _line_items=line_items,
            placed_at=datetime.now(UTC),
        )
        order._record(OrderPlaced(
            order_id=order.id,
            customer_id=cart.customer_id.value,
            cart_id=cart.id,
            total_amount=order.total,
            item_count=len(line_items),
        ))
        return order

    # ── Commands ───────────────────────────────────────────────────
    def confirm(self) -> None:
        if self.status != OrderStatus.PENDING_PAYMENT:
            raise InvalidOrderStatusTransitionError(
                self.status.value, OrderStatus.CONFIRMED.value
            )
        self.status = OrderStatus.CONFIRMED
        self.confirmed_at = datetime.now(UTC)
        self._record(OrderConfirmed(
            order_id=self.id,
            customer_id=self.customer_id.value,
            total_amount=self.total,
        ))

    def cancel(self, reason: str) -> None:
        if not self.status.can_cancel():
            raise InvalidOrderStatusTransitionError(
                self.status.value, OrderStatus.CANCELLED.value
            )
        self.status = OrderStatus.CANCELLED
        self._record(OrderCancelled(
            order_id=self.id,
            customer_id=self.customer_id.value,
            reason=reason,
            refund_amount=self.total,
        ))

    def mark_shipped(self) -> None:
        if self.status != OrderStatus.PROCESSING:
            raise InvalidOrderStatusTransitionError(
                self.status.value, OrderStatus.SHIPPED.value
            )
        self.status = OrderStatus.SHIPPED

    def mark_delivered(self) -> None:
        if self.status != OrderStatus.SHIPPED:
            raise InvalidOrderStatusTransitionError(
                self.status.value, OrderStatus.DELIVERED.value
            )
        self.status = OrderStatus.DELIVERED
        self.delivered_at = datetime.now(UTC)

    def request_return(self, reason: str) -> None:
        if not self.status.can_request_return():
            raise InvalidOrderStatusTransitionError(
                self.status.value, OrderStatus.RETURN_REQUESTED.value
            )
        if not self._within_return_window():
            raise ReturnWindowExpiredError(
                f"Return window of {RETURN_WINDOW_DAYS} days has expired"
            )
        self.status = OrderStatus.RETURN_REQUESTED
        self._record(ReturnRequested(
            order_id=self.id,
            customer_id=self.customer_id.value,
            reason=reason,
        ))

    # ── Queries ────────────────────────────────────────────────────
    @property
    def line_items(self) -> list[OrderLineItem]:
        return list(self._line_items)   # defensive copy

    @property
    def total(self) -> Money:
        if not self._line_items:
            return Money.zero()
        result = self._line_items[0].subtotal
        for item in self._line_items[1:]:
            result = result.add(item.subtotal)
        return result

    def _within_return_window(self) -> bool:
        if not self.delivered_at:
            return False
        window_end = self.delivered_at + timedelta(days=RETURN_WINDOW_DAYS)
        return datetime.now(UTC) <= window_end

    # ── Event infrastructure ───────────────────────────────────────
    def pull_events(self) -> list[DomainEvent]:
        events = list(self._events)
        self._events.clear()
        return events

    def _record(self, event: DomainEvent) -> None:
        self._events.append(event)

Repositories

The interface lives in the domain layer; the implementation lives in infrastructure:

# domain/repositories.py (interface)
from abc import ABC, abstractmethod
from uuid import UUID
from typing import Optional
from .cart import Cart
from .order import Order
from .value_objects import CustomerId


class CartRepository(ABC):
    @abstractmethod
    def save(self, cart: Cart) -> None: ...

    @abstractmethod
    def find_by_id(self, cart_id: UUID) -> Optional[Cart]: ...

    @abstractmethod
    def find_active_by_customer(self, customer_id: CustomerId) -> Optional[Cart]: ...


class OrderRepository(ABC):
    @abstractmethod
    def save(self, order: Order) -> None: ...

    @abstractmethod
    def find_by_id(self, order_id: UUID) -> Optional[Order]: ...

    @abstractmethod
    def find_by_customer(self, customer_id: CustomerId) -> list[Order]: ...

Application Services (Use Cases)

Application services orchestrate without containing business logic:

# application/order_service.py
from uuid import UUID
from dataclasses import dataclass

from ..domain.cart import Cart
from ..domain.order import Order
from ..domain.value_objects import CustomerId, BookId, Money, Address, Coupon
from ..domain.repositories import CartRepository, OrderRepository
from ..domain.exceptions import DomainError


@dataclass
class AddBookToCartCommand:
    customer_id: UUID
    book_id: UUID
    book_title: str
    unit_price_amount: str
    currency: str
    quantity: int = 1


@dataclass
class PlaceOrderCommand:
    customer_id: UUID
    street_line_1: str
    city: str
    state_or_province: str
    postal_code: str
    country_code: str


class CartApplicationService:
    def __init__(
        self,
        cart_repo: CartRepository,
        order_repo: OrderRepository,
        event_publisher,           # injected from outside
    ):
        self._carts = cart_repo
        self._orders = order_repo
        self._events = event_publisher

    def add_book_to_cart(self, cmd: AddBookToCartCommand) -> UUID:
        customer_id = CustomerId(cmd.customer_id)

        # Get or create cart
        cart = self._carts.find_active_by_customer(customer_id)
        if not cart:
            cart = Cart.create(customer_id=customer_id)

        # Delegate to domain — all business logic is there
        cart.add_book(
            book_id=BookId(cmd.book_id),
            book_title=cmd.book_title,
            unit_price=Money(amount=Decimal(cmd.unit_price_amount), currency=cmd.currency),
            quantity=cmd.quantity,
        )

        # Persist and publish events
        self._carts.save(cart)
        for event in cart.pull_events():
            self._events.publish(event)

        return cart.id

    def place_order(self, cmd: PlaceOrderCommand) -> UUID:
        customer_id = CustomerId(cmd.customer_id)

        cart = self._carts.find_active_by_customer(customer_id)
        if not cart:
            raise DomainError("No active cart found for this customer")

        address = Address(
            street_line_1=cmd.street_line_1,
            street_line_2="",
            city=cmd.city,
            state_or_province=cmd.state_or_province,
            postal_code=cmd.postal_code,
            country_code=cmd.country_code,
        )

        # Cart checks out
        cart.checkout()

        # Order is created from cart snapshot
        order = Order.place_from_cart(cart=cart, shipping_address=address)

        # Save both
        self._carts.save(cart)
        self._orders.save(order)

        # Publish all events (CartCheckedOut, OrderPlaced)
        for event in cart.pull_events():
            self._events.publish(event)
        for event in order.pull_events():
            self._events.publish(event)

        return order.id

Phase 5: Integrating Bounded Contexts with Domain Events

When OrderPlaced is published, three other bounded contexts need to react:

  OrderPlaced event published
        ├──→ INVENTORY subscribes: "Reserve the books"
        ├──→ PAYMENTS subscribes: "Initiate payment collection"
        └──→ MEMBERSHIP subscribes: "Start earning points"

This is asynchronous event-driven integration — Orders has no compile-time dependency on Inventory, Payments, or Membership. Each context subscribes to events it cares about.

Event Schema (the Published Language)

Events crossing context boundaries need a stable, versioned schema:

# This is the Published Language — shared schema between contexts
# Published as JSON on a message bus (Kafka, RabbitMQ, etc.)

# orders/events/order_placed_v1.py
@dataclass(frozen=True)
class OrderPlacedV1:
    """
    Published event: emitted when a customer places an order.
    Consumers: Inventory, Payments, Membership, Notifications.
    Schema version: 1 (additive changes only — never remove fields).
    """
    schema_version: str = "1"
    event_type: str = "order.placed"

    order_id: str
    customer_id: str
    placed_at: str          # ISO 8601
    total_amount: str       # decimal string to avoid float issues
    currency: str
    item_count: int
    items: list[dict]       # [{book_id, title, quantity, unit_price}]

    def to_dict(self) -> dict:
        return {
            "schema_version": self.schema_version,
            "event_type": self.event_type,
            "order_id": self.order_id,
            "customer_id": self.customer_id,
            "placed_at": self.placed_at,
            "total_amount": self.total_amount,
            "currency": self.currency,
            "item_count": self.item_count,
            "items": self.items,
        }

The Inventory Context Subscribes

# inventory/application/event_handlers.py

class OrderPlacedHandler:
    """
    Inventory reacts to orders placed in the Orders context.
    This is the Anti-Corruption Layer — we translate the external event
    into our own domain language before processing.
    """
    def __init__(self, stock_service: StockReservationService):
        self._stock = stock_service

    def handle(self, event_data: dict) -> None:
        # Translate external schema → our domain concepts (ACL)
        order_id = event_data["order_id"]
        items = [
            StockReservationItem(
                sku=item["book_id"],         # "book_id" in Orders = "sku" in Inventory
                quantity=item["quantity"],
            )
            for item in event_data["items"]
        ]

        # Delegate to our own domain logic
        self._stock.reserve_for_order(order_id=order_id, items=items)

Handling Eventual Consistency

When you use async events, you must handle scenarios where one step succeeds and another fails:

  Scenario: OrderPlaced published, but Inventory reservation fails

  Option 1 — Saga / Process Manager:
  OrderPlaced → InventoryReserved → PaymentRequested
     ↑                ↓ (fails)
     └── OrderCancelled ← InventoryReservationFailed

  Option 2 — Outbox Pattern:
  Write Order + Event to DB in one transaction.
  Separate process reads Outbox table and publishes events.
  Guarantees "at least once" delivery.
# The Outbox Pattern — guarantees no event is lost on publish failure

# In your repository save():
def save(self, order: Order) -> None:
    with self._session.begin():
        # 1. Save the order record
        order_record = self._mapper.to_record(order)
        self._session.merge(order_record)

        # 2. Save events to outbox table in THE SAME TRANSACTION
        for event in order.pull_events():
            outbox_entry = OutboxEntry(
                id=uuid4(),
                event_type=type(event).__name__,
                payload=json.dumps(event.to_dict()),
                created_at=datetime.now(UTC),
                published=False,
            )
            self._session.add(outbox_entry)

# Separate background process:
# SELECT * FROM outbox WHERE published = false
# → publish each to message bus
# → mark as published
# Retry failed publishes automatically

Phase 6: Testing Your Domain Model

One of the best benefits of a proper domain model: your business logic is fully testable without databases, web frameworks, or external services.

Unit Testing Aggregates

# tests/domain/test_cart.py
import pytest
from decimal import Decimal
from uuid import uuid4

from orders.domain.cart import Cart
from orders.domain.value_objects import BookId, CustomerId, Money, Coupon
from orders.domain.exceptions import (
    BookAlreadyInCartError, CartItemLimitExceededError,
    CouponAlreadyAppliedError, EmptyCartError
)
from orders.domain.events import BookAddedToCart, CartCheckedOut


class TestCart:

    def setup_method(self):
        self.customer_id = CustomerId(uuid4())
        self.cart = Cart.create(customer_id=self.customer_id)
        self.book_id = BookId(uuid4())
        self.price = Money(Decimal("29.99"), "USD")

    def test_add_book_creates_cart_item(self):
        self.cart.add_book(self.book_id, "Clean Code", self.price, quantity=1)

        assert len(self.cart.items) == 1
        assert self.cart.items[0].book_title == "Clean Code"
        assert self.cart.total == self.price

    def test_add_same_book_twice_raises_error(self):
        self.cart.add_book(self.book_id, "Clean Code", self.price)

        with pytest.raises(BookAlreadyInCartError):
            self.cart.add_book(self.book_id, "Clean Code", self.price)

    def test_total_includes_all_items(self):
        book_2 = BookId(uuid4())
        price_2 = Money(Decimal("19.99"), "USD")

        self.cart.add_book(self.book_id, "Clean Code", self.price)
        self.cart.add_book(book_2, "The Pragmatic Programmer", price_2)

        assert self.cart.total == Money(Decimal("49.98"), "USD")

    def test_coupon_reduces_total(self):
        self.cart.add_book(self.book_id, "Clean Code", Money(Decimal("100.00"), "USD"))
        self.cart.apply_coupon(Coupon(code="SAVE20", discount_percent=20))

        assert self.cart.total == Money(Decimal("80.00"), "USD")

    def test_cannot_apply_two_coupons(self):
        self.cart.add_book(self.book_id, "Clean Code", self.price)
        self.cart.apply_coupon(Coupon(code="FIRST", discount_percent=10))

        with pytest.raises(CouponAlreadyAppliedError):
            self.cart.apply_coupon(Coupon(code="SECOND", discount_percent=20))

    def test_checkout_empty_cart_raises_error(self):
        with pytest.raises(EmptyCartError):
            self.cart.checkout()

    def test_add_book_emits_event(self):
        self.cart.add_book(self.book_id, "Clean Code", self.price)

        events = self.cart.pull_events()
        assert len(events) == 1
        assert isinstance(events[0], BookAddedToCart)
        assert events[0].book_title == "Clean Code"

    def test_checkout_emits_event_with_correct_total(self):
        self.cart.add_book(self.book_id, "Clean Code", self.price)
        self.cart.checkout()

        events = self.cart.pull_events()
        checkout_events = [e for e in events if isinstance(e, CartCheckedOut)]
        assert len(checkout_events) == 1
        assert checkout_events[0].total_amount == self.price


class TestOrder:

    def test_cannot_cancel_shipped_order(self):
        order = self._make_order_in_status(OrderStatus.SHIPPED)

        with pytest.raises(InvalidOrderStatusTransitionError):
            order.cancel("Changed my mind")

    def test_return_outside_window_raises_error(self):
        order = self._make_delivered_order(days_ago=31)

        with pytest.raises(ReturnWindowExpiredError):
            order.request_return("Book was damaged")

    def test_return_within_window_succeeds(self):
        order = self._make_delivered_order(days_ago=5)
        order.request_return("Book was damaged")

        assert order.status == OrderStatus.RETURN_REQUESTED

    def test_confirm_publishes_event(self):
        order = self._make_order_in_status(OrderStatus.PENDING_PAYMENT)
        order.confirm()

        events = order.pull_events()
        confirmed = [e for e in events if isinstance(e, OrderConfirmed)]
        assert len(confirmed) == 1
        assert confirmed[0].order_id == order.id

Integration Testing with an In-Memory Repository

# tests/application/test_cart_service.py

class InMemoryCartRepository(CartRepository):
    def __init__(self):
        self._store: dict[UUID, Cart] = {}

    def save(self, cart: Cart) -> None:
        self._store[cart.id] = cart

    def find_by_id(self, cart_id: UUID) -> Optional[Cart]:
        return self._store.get(cart_id)

    def find_active_by_customer(self, customer_id: CustomerId) -> Optional[Cart]:
        return next(
            (c for c in self._store.values()
             if c.customer_id == customer_id),
            None
        )


class FakeEventPublisher:
    def __init__(self):
        self.published: list = []

    def publish(self, event) -> None:
        self.published.append(event)


class TestCartApplicationService:

    def setup_method(self):
        self.cart_repo = InMemoryCartRepository()
        self.order_repo = InMemoryOrderRepository()
        self.events = FakeEventPublisher()
        self.service = CartApplicationService(
            cart_repo=self.cart_repo,
            order_repo=self.order_repo,
            event_publisher=self.events,
        )

    def test_add_book_creates_new_cart(self):
        customer_id = uuid4()
        cmd = AddBookToCartCommand(
            customer_id=customer_id,
            book_id=uuid4(),
            book_title="Domain-Driven Design",
            unit_price_amount="54.99",
            currency="USD",
        )

        cart_id = self.service.add_book_to_cart(cmd)

        cart = self.cart_repo.find_by_id(cart_id)
        assert cart is not None
        assert len(cart.items) == 1
        assert cart.items[0].book_title == "Domain-Driven Design"

    def test_add_book_publishes_event(self):
        cmd = AddBookToCartCommand(
            customer_id=uuid4(),
            book_id=uuid4(),
            book_title="Clean Architecture",
            unit_price_amount="39.99",
            currency="USD",
        )
        self.service.add_book_to_cart(cmd)

        assert len(self.events.published) == 1
        assert isinstance(self.events.published[0], BookAddedToCart)

The Swift Method: From Event Storm to Stories

For teams that use Jira or Linear for story management, the Swift Method (from the original blog post) provides a bridge from Event Storming output to development stories.

BORIS — Business Object Relationships and Interaction Specification

After Event Storming, BORIS maps which actors interact with which aggregates and what events result:

  BORIS table for Bookly Orders context:

  ┌──────────────┬─────────────────────┬──────────────────────────┐
  │ Actor        │ Command             │ Event(s)                 │
  ├──────────────┼─────────────────────┼──────────────────────────┤
  │ Customer     │ Add Book to Cart    │ BookAddedToCart          │
  │ Customer     │ Apply Coupon        │ CouponApplied            │
  │ Customer     │ Initiate Checkout   │ CartCheckedOut           │
  │ Customer     │ Place Order         │ OrderPlaced              │
  │ Customer     │ Cancel Order        │ OrderCancelled           │
  │ Customer     │ Request Return      │ ReturnRequested          │
  │ Payment GW   │ Confirm Payment     │ OrderConfirmed           │
  │ Payment GW   │ Reject Payment      │ OrderCancelled           │
  │ Warehouse    │ Dispatch Order      │ OrderShipped             │
  │ Courier      │ Confirm Delivery    │ OrderDelivered           │
  └──────────────┴─────────────────────┴──────────────────────────┘

SNAP — Specification by NAmePart

SNAP translates each row of BORIS into a story specification. For each Command → Event:

  SNAP for "Add Book to Cart":

  GIVEN: Customer [X] has an active cart (or will get a new one)
  WHEN:  Customer adds Book [Y] with quantity [Z]
  THEN:  Book is in the cart with correct subtotal
  AND:   BookAddedToCart event is published
  AND:   Cart total is recalculated

  Acceptance Criteria:
  ✅ Adding the same book twice raises an error (not duplicated)
  ✅ Adding more than 50 books raises an error
  ✅ Total correctly reflects the coupon discount if applied
  ✅ Checkout with an empty cart is rejected

These SNAP specs translate directly into Jira user stories and unit test cases — the spec IS the test.


Common DDD Pitfalls in Practice

1. Starting with the Database Schema

The mistake: Open a SQL editor, design the tables, then write code to populate them.

Why it fails: You end up with an anemic domain model that mirrors your tables, not your business. Business rules get scattered into stored procedures, services, and controllers.

The fix: Design the domain model first. Let the database schema be derived from the model, not the other way around.

2. Aggregate Too Large → Locking Hell

The symptom: Two users can't use the system simultaneously. Timeouts on basic operations.

The cause: All business objects bundled into one aggregate. Every operation locks the entire thing.

The fix: Ask "what is the minimum set of data that must be consistent?" Design the aggregate around that — and use eventual consistency (domain events) for everything else.

3. Sharing a Database Between Bounded Contexts

The mistake: The Orders service and the Inventory service both SELECT and UPDATE from the same orders table.

Why it fails: Any schema change breaks both services. You can't evolve the contexts independently. Deployment becomes coupled.

The fix: Each bounded context owns its own database. If Inventory needs order data, it subscribes to the OrderPlaced event and maintains its own projection of that data.

# BAD: Inventory queries the Orders database directly
def reserve_stock(order_id):
    order = orders_db.execute("SELECT * FROM orders WHERE id = ?", order_id)
    # Now Inventory is coupled to Orders' schema forever

# GOOD: Inventory maintains its own read model, populated by events
class InventoryOrderProjection:
    def on_order_placed(self, event: dict) -> None:
        # Store only what Inventory cares about
        self._db.execute(
            "INSERT INTO inventory_orders (order_id, items) VALUES (?, ?)",
            event["order_id"], json.dumps(event["items"])
        )

4. Putting Business Logic in Application Services

The symptom: Application service methods are hundreds of lines long. Your domain objects are just data classes.

The fix: Move every if statement that represents a business rule into the aggregate. Application services should read like a summary: "get this, call that, save, publish."

5. Skipping Event Storming ("We already know the domain")

Why this fails: You don't know what you don't know. The most valuable output of Event Storming is the surprises — the business rules that are in someone's head but not in any document.

Even if you think you know the domain, run a 2-hour event storming session. The "Wait, WHAT?" moments will pay for the time investment many times over.


DDD in Action: Your Playbook

Use this checklist when starting a DDD project:

Discovery Phase

  • Identify all stakeholders — who holds domain knowledge?
  • Run Event Storming (minimum 4 hours for a meaningful domain)
  • Build the event timeline and identify hotspots (areas of confusion/complexity)
  • Write the first draft of your Ubiquitous Language glossary

Design Phase

  • Draw candidate Bounded Context boundaries on the Event Storm output
  • Name each bounded context (one team = one context as a starting point)
  • Document the Context Map (what relationships exist, which patterns apply)
  • Identify aggregates per context — start small, split later if needed
  • Validate aggregate boundaries: "What must be consistent together?"

Implementation Phase

  • Create the folder structure (domain / application / infrastructure / api)
  • Implement Value Objects first (they're pure, easy to test)
  • Implement Aggregates with business invariants and domain events
  • Define Repository interfaces in the domain layer
  • Implement Application Services (thin orchestration only)
  • Implement Repository in the infrastructure layer
  • Wire event publishing through the Outbox Pattern

Validation Phase

  • Unit test every aggregate in isolation (no database required)
  • Integration test application services with in-memory repositories
  • Test event publishing and consumption across context boundaries
  • Review: does the code read like the business? Would a domain expert understand it?
  • Update the Ubiquitous Language glossary as understanding evolves

Summary

DDD in action follows a clear loop:

  Discover the domain → Draw the boundaries → Design the aggregates
         ↑                                              ↓
  Refine the model  ← Test rigorously     ← Implement in code

The key insight is that code is a model of your understanding of the domain. As your understanding deepens — through Event Storming, stakeholder conversations, and shipping features — your model should deepen with it.

The practices that make this work:

  • Event Storming to build shared understanding before a line of code is written
  • Bounded Contexts to give teams autonomy and prevent semantic collisions
  • Small Aggregates to enforce consistency without locking the world
  • Domain Events to integrate without coupling
  • Rich Domain Models to keep business logic where it belongs — in the domain
  • The Outbox Pattern to guarantee events are never silently lost
  • Tests that read like specifications — the SNAP format gives you both

DDD is not about patterns for their own sake. It's about software that is honest — honest about what the business does, honest about who owns what, honest about where the complexity lives. Code that any domain expert can look at and say: "Yes, that's what we do."


Want to dive deeper into the foundational theory behind these practices? Read Domain-Driven Design: The Complete Guide. Have questions? Connect on LinkedIn, X or reach out via email.

Discussion

Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.