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:
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").
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€).
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».
📐 Invariant Rules & Transactional Boundaries
As depicted in the domain class diagram:
- Cart $\rightarrow$ Order Lifecycle (
CartvsOrder):- 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).
- The Shopping Cart (
- Decoupling from the Product Catalog (
CatalogProduct):- The
OrderLineandCartItementities only reference the product by itsProductIdand copy the instant unit price into an immutableMoneyValue Object. If the catalog team adjusts product pricing later, historical orders remain untouched.
- The
- Strong Internal Encapsulation:
- Internal entities (
OrderLine,CartItem,AppliedPromotion) can only be queried or modified through their respective aggregate roots (Order,Cart).
- Internal entities (
- 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.
- Relationships across bounded contexts never share direct in-memory object references, but rely strictly on strongly typed identifiers (
🎯 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.
🔒 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
- Initial Read: Every aggregate root (e.g.,
Order,Cart,InventoryItem) carries an attributeversion: numberpersisted in the database (starting at1). When a query loads the entity, it captures the current version (e.g.,version = 1). - In-Memory Mutation: The Use Case executes business rules and increments the expected version (
version = 2). - 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; - Conflict Detection:
- Winning Transaction (Request A): The record matches (
row_count = 1), the update commits, and the database version increments to2. - Concurrent Transaction (Request B): Because the row version is now
2, the conditionWHERE version = 1matches zero rows (row_count = 0). The database rejects the update without blocking other threads.
- Winning Transaction (Request A): The record matches (
- 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 Conflictresponse to the caller with the latest state.
2. Optimistic Locking Sequence Diagram
🔑 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
- 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. - 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; - 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 HTTP409 Conflict(or425 Too Early), stating that processing is already underway. - Replayed Request after Completion (
COMPLETED): If the key is already markedCOMPLETED, the server completely short-circuits domain execution: no re-calculation, no secondary debit. It simply replays the cached response payload (response_code,response_body).
- Initial Request (
- Finalization: Once the business transaction commits, the status flips to
COMPLETEDalongside its cached JSON response body and an expiration TTL (24h to 7 days).
2. Idempotency Sequence Diagram
📮 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:
- Local Database Write: Persisting the order (
INSERT INTO orders). - 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
- Atomic Combined Write: In a single SQL transaction, the Use Case inserts both the business entity into
ordersand the event record into a localoutbox_eventstable:
ACID Guarantee: Either both the order and the event are safely persisted, or neither is.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; - 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
PENDINGrows inoutbox_events. - Dispatch to Message Broker: The relayer dispatches the message to Kafka/RabbitMQ.
- Acknowledgment & Cleanup:
Upon broker acknowledgment (ACK), the worker marks the outbox row as
PUBLISHED(or purges it), delivering reliable At-Least-Once Delivery semantics.