← Back

Detailed Application Design — Domain Modeling & Internal Architecture

After defining business goals and decomposing the system into Bounded Contexts, the next crucial step is descending into detailed software design: how do we structure the inside of each domain to ensure robustness, maintainability, and clarity?

Let's continue with our e-commerce case study (Order Placement) to explore core detailed design decisions at the heart of the system.


🏗️ 1. Tactical DDD Modeling: Aggregates, Entities & Value Objects

Within a business domain, not all concepts share the same lifecycle. Detailed design explicitly differentiates:

💎 Value Objects

Immutable objects defined solely by their values (no technical identifier). They encapsulate self-validation and prevent invalid state right from instantiation.

🛒 Examples: Money(amount: 199.99, currency: EUR), Quantity(2), Email("client@domain.com").

🆔 Entities

Objects that possess a continuous, unique identity over time, even as their mutable attributes evolve through their lifecycle.

🛒 Example: OrderLine(id: "item-12", productId: "PROD-98", quantity: 2, unitPrice: 199.99€).

🛡️ Aggregate Root

A cluster of domain objects treated as a single indivisible transactional unit. It serves as the sole gateway: external callers never manipulate internal entities directly.

🛒 Example: Order or Cart act as roots that guard their internal lines, recompute totals, and enforce state invariants.

🏷️ Domain Class Diagram & Tactical Classification

Here is the comprehensive class diagram illustrating the complete sales journey: from the Product Catalog and Cart to Order Placement and adjacent bounded contexts. Each class features its explicit tactical stereotype: «Aggregate Root», «Entity», or «Value Object».

DDD Stereotype Legend: 🛡️ «Aggregate Root» 🆔 «Entity» 💎 «Value Object» 📦 Catalog Context «Aggregate Root» CatalogProduct - id: ProductId - sku: SKU - title: ProductName - unitPrice: Money - isPublished: boolean + updatePrice(newPrice) + publish(): void 📦 Cart Context «Aggregate Root» Cart - id: CartId - custId: CustomerId - updatedAt: Date + addItem() + removeItem() + clear() «Entity» CartItem - id: CartItemId - prodId: ProductId - qty: Quantity - price: Money 📦 Ordering Context «Aggregate Root» Order - id: OrderId - customerId: CustomerId - status: OrderStatus - address: ShippingAddress - version: number + addLine(prodId, qty, price) + markAsPaid(): void + cancel(reason): void «Entity» OrderLine - id: OrderLineId - productId: ProductId - quantity: Quantity - unitPrice: Money «Entity» AppliedPromotion - promoCode: PromoCode, discount: Money ⚡ Emitted Domain Events: • CartCheckedOut(cartId, customerId) • OrderPlaced(orderId, customerId, totalAmount, lines[]) • OrderPaid(orderId, paymentTransactionId) • OrderShipped(orderId, trackingNumber) • OrderCancelled(orderId, reason) 💎 Value Objects (Shared & Specialized) «Value Object» Money amount: number currency: Currency «Value Object» Quantity value: positiveInt max: 20 per item «Value Object» ShippingAddress street: string, zipCode: string, city: string country: CountryCode «Value Object» OrderStatus DRAFT | PAID | PENDING | SHIPPED «Value Object» PromoCode code: string discountRate: number «Value Object» — Strongly-Typed IDs • OrderId(value: UUID) • CartId(value: UUID) • CartItemId(value: UUID) • OrderLineId(value: UUID) • CustomerId(value: UUID) • ProductId(value: SKU / string) • TransactionId(value: UUID) • IdempotencyKey(key: string) «Value Object» — Business Rules & Formats • CustomerEmail(validFormatRegex) • SKU(code: "ELEC-SAM-S24") • VatRate(percentage: 20.0) • ProductName(title: string) • TrackingNumber(carrier, num) 🌐 Remote Bounded Contexts «Aggregate Root» (Identity) Customer - id: CustomerId - email: CustomerEmail - creditLimit: Money + canPlaceOrder(): bool «Aggregate Root» (Stock) InventoryItem - productId: ProductId - availableQty: Quantity + reserve(qty): bool + release(qty): void «Aggregate Root» (Payment) PaymentTransaction - txId: TransactionId - orderId: OrderId - amount: Money + capture(): Receipt checkout()

📐 Invariant Rules & Transactional Boundaries

As depicted in the domain class diagram:

  1. Cart $\rightarrow$ Order Lifecycle (Cart vs Order):
    • The Shopping Cart (Cart) is an ephemeral and permissive aggregate (items added/removed without hard inventory reservations).
    • Upon checkout (Checkout), the cart freezes and instantiates the Order (Order) aggregate with its strict invariants (guaranteed prices, validated delivery addresses, customer limit checks).
  2. Decoupling from the Product Catalog (CatalogProduct):
    • The OrderLine and CartItem entities only reference the product by its ProductId and copy the instant unit price into an immutable Money Value Object. If the catalog team adjusts product pricing later, historical orders remain untouched.
  3. Strong Internal Encapsulation:
    • Internal entities (OrderLine, CartItem, AppliedPromotion) can only be queried or modified through their respective aggregate roots (Order, Cart).
  4. Inter-Context Decoupling (Customer, Inventory, Payment):
    • Relationships across bounded contexts never share direct in-memory object references, but rely strictly on strongly typed identifiers (CustomerId, ProductId, OrderId) and asynchronous domain events.

🎯 2. Contract Design & Data Flows (Ports & DTOs)

Detailed software design explicitly defines boundaries between the outside world and the core domain via clean contracts:

1. Architectural Flow: Input DTO ➔ Use Case ➔ Domain Model ➔ Output DTO

An Input DTO (Data Transfer Object) conveys raw parameters from adapters (REST APIs, message brokers, web UI) into the Use Case without containing any business rules. The Use Case orchestrates execution, loads and manipulates Domain Entities and Aggregate Roots, enforces state invariants, and projects the outcome into an immutable Output DTO.

Input DTO to Use Case, Domain Core and Output DTO across Core Scenarios 📥 Input DTO (Command / Query) ⚙️ Use Case (Application) 🏛️ Domain Core (Entities & Invariants) 📤 Output DTO (Result) SearchCatalogQuery • keyword?: string • category?: string • minPrice / maxPrice?: number • sortBy?: 'price' | 'popular' + GetProductQuery(id) Search criteria & filters SearchCatalogUseCase 1. Validate filter criteria 2. CatalogRepository.search() 3. Filter active items in stock 4. Compute discounts & VAT 5. Map to CatalogResult Lookup & catalog indexing Domain: Catalog Store 🟣 «Aggregate Root» CatalogProduct 🟢 «Value Objects» ProductId, SKU, Money, VatRate Invariants: Unique SKU, Price >= 0 Rule: Hidden inactive items in search Immutable product query read-model CatalogResult • total: 42 • items: ProductSummary[] • facets: CategoryFacet[] + ProductDetails Paginated & enriched query view AddCartItemCommand • cartId: CartId • productId: ProductId • quantity: number (> 0) + UpdateCartItemCommand + Remove / ClearCartCommand Cart mutation commands ManageCartUseCase 1. CartRepository.findById() 2. CatalogGateway.checkStock() 3. cart.addItem(item, qty) 4. CartRepository.save(cart) 5. Map to CartResult Cart session & item lifecycle Core Domain: Shopping Cart 🟣 «Aggregate Root» Cart 🟠 «Entity» CartItem 🟢 «Value Objects» CartId, ProductId, Money, PromoCode Invariants: Max 10 units / line item Computation: Total = Σ(CartItem.price * qty) Dynamic & permissive aggregate CartResult • cartId: UUID • itemsCount: 3 • totalHT: $85.00 • totalTTC: $102.00 • items: CartItemDto[] Updated cart snapshot CreateOrderCommand • customerId: CustomerId • cartId: CartId • shippingAddress: {...} + ConfirmOrderCommand + CancelOrderCommand(reason) Order placement commands PlaceOrder / ConfirmOrderUseCase 1. Fetch Cart & Customer 2. Instantiate Order aggregate 3. Order.validate() + calculate() 4. OrderRepository.save(order) 5. EventPublisher.publish() Workflow & transactional boundary Core Domain: Order 🟣 «Aggregate Root» Order 🟠 «Entity» OrderLine, Promo 🟢 «Value Objects» ShippingAddress, Money, OrderId Invariants: Total = Sum(Lines) - Discount Transition: DRAFT ➔ PAID ➔ SHIPPED Pure domain logic without frameworks OrderResult • orderId: UUID • status: "PENDING" • totalTTC: $129.90 • createdAt: ISO8601 • lines: OrderLineDto[] Immutable response projection AuthorizePaymentCommand • orderId: OrderId • amount: number • currency: "EUR" + CapturePaymentCommand + RefundPaymentCommand(amount) Financial transaction command PaymentUseCase 1. Verify Order status 2. Call payment gateway SPI (charge) 3. Persist PaymentTx record 4. Emit PaymentAuthorizedEvent 5. Map to PaymentResult Payment security & PSP orchestration Core Domain: Payment 🟣 «Aggregate Root» PaymentTransaction 🟢 «Value Objects» TransactionId, Money, OrderId Invariants: Positive amount > 0 Idempotency: Exactly 1 charge / OrderId Audit trail & financial accounting PaymentResult • txId: UUID • status: "AUTHORIZED" • receiptUrl: string • paidAt: ISO8601 + PaymentReceipt Payment confirmation receipt GetTrackingQuery • orderId: OrderId • trackingNumber?: string + ListOrderEventsQuery(orderId) + GetDeliveryStatusQuery(id) Shipment tracking queries TrackOrderUseCase 1. Verify OrderId exists 2. CarrierGateway.getShipment() 3. EventStore.getDomainEvents() 4. Aggregate lifecycle checkpoints 5. Map to TrackingResult Real-time tracking & audit log Domain: Order Tracking & Events 🟣 «Aggregate Root» OrderTracking 🟢 «Value Objects» TrackingNumber, Carrier, DomainEvent Invariants: Chronological checkpoints Statuses: PREPARATION ➔ SHIPPED ➔ DELIVERED Immutable event history TrackingResult • trackingNumber: string • carrier: "DHL" | "Chronopost" • status: "IN_TRANSIT" • estimatedDelivery: ISO8601 • checkpoints: EventDto[] Complete shipment timeline

🔒 3. Concurrency Management & Transactions

In high-concurrency environments (e.g., flash sales of limited inventory or concurrent basket checkouts), detailed design must anticipate data races and uphold strict transactional integrity.

🛡️ Optimistic Locking with Version Numbering

Optimistic concurrency control assumes collisions are infrequent, thereby avoiding expensive, throughput-killing database table/row locks (such as SELECT FOR UPDATE).

1. How It Works

  1. Initial Read: Every aggregate root (e.g., Order, Cart, InventoryItem) carries an attribute version: number persisted in the database (starting at 1). When a query loads the entity, it captures the current version (e.g., version = 1).
  2. In-Memory Mutation: The Use Case executes business rules and increments the expected version (version = 2).
  3. Conditional Atomic Write: When persisting (UPDATE), a predicate clause verifies that the database version hasn't changed since the initial read:
    UPDATE orders 
    SET status = 'PAID', version = version + 1 
    WHERE id = :id AND version = 1;
    
  4. Conflict Detection:
    • Winning Transaction (Request A): The record matches (row_count = 1), the update commits, and the database version increments to 2.
    • Concurrent Transaction (Request B): Because the row version is now 2, the condition WHERE version = 1 matches zero rows (row_count = 0). The database rejects the update without blocking other threads.
  5. Business Exception Handling: The ORM or data repository throws an OptimisticLockException. The system can either:
    • Automatically replay the Use Case (Retry) with freshly loaded state.
    • Return an HTTP 409 Conflict response to the caller with the latest state.

2. Optimistic Locking Sequence Diagram

Optimistic Locking Sequence: Conflict Detection via 'version' Field 👤 Client 1 (Request A) 🗄️ Database (DB) 👤 Client 2 (Request B) 1. SELECT id, total, version FROM orders Return: Order { version: 1 } 2. SELECT id, total, version FROM orders Return: Order { version: 1 } Business Logic A order.pay() Business Logic B order.cancel() 3. UPDATE orders SET ..., version=2 WHERE v=1 ✅ Success (1 row) DB version = 2 HTTP 200 OK (Order confirmed) 4. UPDATE orders SET ..., version=2 WHERE v=1 ❌ Conflict (0 rows) v=1 not found (v=2) Immediate Rollback OptimisticLockException (0 rows affected) HTTP 409 Conflict Response or automated retry with fresh reload

🔑 Idempotency Keys & Request Deduplication

In distributed and web environments, network blips, socket timeouts, or repeated client clicks often re-issue mutating requests (POST /v1/orders, POST /v1/payments). Without idempotency, customers could be billed twice or produce duplicate orders.

Idempotency guarantees that executing an identical operation multiple times results in the exact same business state on the server.

1. How It Works

  1. Generating a Unique Key: The client generates a random UUID and transmits it within a dedicated HTTP header: Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d.
  2. Atomic Registration Lock: Upon receipt, an interceptor or API middleware attempts to atomically insert the key into a dedicated record store:
    INSERT INTO idempotency_records (key, request_hash, status, created_at)
    VALUES (:idempotency_key, :hash, 'IN_PROGRESS', NOW())
    ON CONFLICT (key) DO NOTHING;
    
  3. Branching on Key Status:
    • Initial Request (IN_PROGRESS): The row is successfully created. The system recognizes this as the genuine first request and executes the Use Case (inventory reservation, payment gateway capture).
    • Concurrent In-Flight Request: If a subsequent request bearing the same key arrives while status is still IN_PROGRESS, the server immediately aborts with an HTTP 409 Conflict (or 425 Too Early), stating that processing is already underway.
    • Replayed Request after Completion (COMPLETED): If the key is already marked COMPLETED, the server completely short-circuits domain execution: no re-calculation, no secondary debit. It simply replays the cached response payload (response_code, response_body).
  4. Finalization: Once the business transaction commits, the status flips to COMPLETED alongside its cached JSON response body and an expiration TTL (24h to 7 days).

2. Idempotency Sequence Diagram

Idempotency-Key Deduplication Sequence 📱 Client (App / Browser) 🛡️ Idempotency Interceptor & Store ⚙️ Use Case & Core DB POST /v1/orders (Idempotency-Key: K1) Atomic lock on key K1 Status = 'IN_PROGRESS' Execute PlaceOrderUseCase() Created Order #CMD-42 Payment captured OK OrderCreated { id: 42, total: $99 } Cache response payload Status = 'COMPLETED', HTTP 201 HTTP 201 Created (Order confirmed) ⚡ Duplicate Request (Network timeout, retry, or double click) POST /v1/orders (Idempotency-Key: K1) 🔍 Key K1 already exists! Status: COMPLETED (Cache Hit) ⛔ SHORT-CIRCUIT DOMAIN LOGIC ❌ Zero domain re-execution HTTP 201 Created (Cached original response replayed) Strictly identical response, zero duplicate records or double charges

📮 Transactional Outbox Pattern

In event-driven and distributed architectures, a textbook pitfall is the Dual-Write Problem.

1. The Dual-Write Problem

When an Order Use Case completes, two operations must succeed together:

  1. Local Database Write: Persisting the order (INSERT INTO orders).
  2. Publish Domain Event: Notifying Kafka, RabbitMQ, or AWS EventBridge (publisher.send(new OrderPaidEvent())).

If the host crashes or the network partitions after the SQL commit but before message dispatch, the event is lost forever, stranding downstream services (invoicing, dispatch, warehouse). Conversely, if the message publishes first and the database commit fails, consumers process a phantom event that never truly existed.

The Transactional Outbox Pattern eliminates this risk by converting event publishing into an atomic local database write within the exact same ACID transaction.

2. Step-by-Step Mechanics

  1. Atomic Combined Write: In a single SQL transaction, the Use Case inserts both the business entity into orders and the event record into a local outbox_events table:
    BEGIN TRANSACTION;
      INSERT INTO orders (id, customer_id, total, status) 
      VALUES ('CMD-42', 'CUST-1', 99.00, 'PAID');
    
      INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload, status, created_at)
      VALUES (gen_random_uuid(), 'Order', 'CMD-42', 'OrderPaidEvent', '{"orderId":"CMD-42","amount":99.00}', 'PENDING', NOW());
    COMMIT;
    
    ACID Guarantee: Either both the order and the event are safely persisted, or neither is.
  2. Decoupled Publishing (Outbox Relayer / Message Relay): An asynchronous background worker (polling poller or Change Data Capture engine like Debezium tailing the database WAL/transaction log) scans for PENDING rows in outbox_events.
  3. Dispatch to Message Broker: The relayer dispatches the message to Kafka/RabbitMQ.
  4. Acknowledgment & Cleanup: Upon broker acknowledgment (ACK), the worker marks the outbox row as PUBLISHED (or purges it), delivering reliable At-Least-Once Delivery semantics.

3. Transactional Outbox Sequence Diagram

Transactional Outbox Sequence: Eliminating the Dual-Write Problem ⚙️ Use Case (Application) 🗄️ Local DB (Orders & Outbox) 🔄 Outbox Relayer (Worker / CDC) 📬 Message Broker (Kafka) PHASE 1: SINGLE LOCAL ACID TRANSACTION (ZERO EXTERNAL NETWORK DEPENDENCY) 1. BEGIN TRANSACTION 2. INSERT INTO orders (status: 'PAID') 3. INSERT INTO outbox_events ('OrderPaid', 'PENDING') 4. COMMIT TRANSACTION Transaction Committed ✅ Order & Event persisted HTTP 200/201 Success to Client (Instant) PHASE 2: ASYNCHRONOUS PUBLICATION & BROKER FAULT-TOLERANCE 5. SELECT WHERE status='PENDING' (or CDC log) Returns: [ Event #E1 (OrderPaidEvent) ] 6. producer.send("orders.events", E1) Message replicated Offset committed 7. Broker ACK (Receipt acknowledged) 8. UPDATE outbox_events SET status='PUBLISHED' WHERE id=E1 At-Least-Once Guarantee: Zero lost events even during server crashes or broker outages