Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 8

    Domain-Driven Design Fundamentals

    On TechLearningPro’s retail floor — courses, books, and exam vouchers — the word “order” means three different things before lunch. Checkout means the shopper’s purchase, with a to

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Domain-Driven Design Fundamentals

    Advanced System Design & Software Architecture · Lesson 08

    Lesson Overview

    On TechLearningPro’s retail floor — courses, books, and exam vouchers — the word “order” means three different things before lunch. Checkout means the shopper’s purchase, with a total and a payment result. The warehouse means a pick list, with bins and a carrier. Finance means a ledger posting, with a tax code and a settlement date. A single Order class with sixty fields tried to be all three. Every change to voucher expiry risked the pick-list printer. Every warehouse status enum value leaked into the shopper’s API. The team had already split packages into modules in Layered and Modular Monolith Architecture. The packages were clean and the language was not. People still said “just add it to the order.”

    Domain-driven design, in the sense Eric Evans set down and Vaughn Vernon made operational, is a way to make the language and the model match the business, and to draw a boundary where the language changes. A ubiquitous language is the shared vocabulary of one team and its code. A bounded context is the boundary inside which a word has one meaning. An aggregate is the cluster of objects you commit together so an invariant holds. None of these require a framework. They require a glossary, a context map, and Java types whose methods refuse illegal states.

    This lesson builds that model for the retail business: catalog, checkout, payment, fulfillment, and finance as contexts; Order as an aggregate that owns its lines and references a product by SKU; Money as a value object; a repository that loads and saves the aggregate. It stays practical. Event sourcing, CQRS, and a message bus are later lessons. The enterprise track goes deeper on each building block: Ubiquitous Language, Bounded Contexts, Aggregates, Entities, Value Objects, Repositories, Domain Events, and Strategic Design.

    Learning Objectives

    By the end of this lesson, you should be able to:

    1. Write a ubiquitous-language glossary for one context and show a word that must not be shared with the neighboring context.
    2. Classify catalog, checkout, payment, fulfillment, and finance as bounded contexts, and say whether each is core, supporting, or generic for TechLearningPro’s retail business.
    3. Distinguish an entity from a value object using OrderId and Money, including equality and where identity comes from.
    4. Draw an Order aggregate that contains lines, enforces a place-order invariant in code, and references product and payment by identity.
    5. Define a repository as the collection of one aggregate, and map it to a Spring-backed adapter without putting @Entity on the domain type.
    6. Separate an application service (load, call, save, transaction) from a domain service (a rule that belongs to no single entity).
    7. Place an anti-corruption layer where a legacy ERP’s “sales order” enters fulfillment.
    8. Reject an anemic Order whose only methods are setters, and reject a single enterprise Order class used by warehouse and finance.

    Prerequisites

    • Lesson 7, modular monolith: a Spring Modulith module is a good mechanical home for a bounded context. This lesson decides the context; lesson 7 enforces the package.
    • Lesson 6, ports and adapters: the repository and the payment gateway are ports. DDD names what those ports persist and protect.
    • SOLID Principles in Practice: small domain types with behavior, closed against casual field writes.
    • Java 21 records for value objects, and ordinary classes for aggregates that change over time.
    • A willingness to talk to a domain expert — merchandising, warehouse, finance — and to change a type name when they correct you. The code review is not the only review.

    Detailed Core Concepts

    The glossary is a design artifact

    A ubiquitous language is not a glossary pasted into a wiki and forgotten. It is the set of words that appear in conversation, in tests, and in type names, with the same meaning. For checkout at TechLearningPro the glossary starts like this:

    TermMeaning in checkoutNot this
    OrderA shopper’s commitment to buy one or more lines at a snapshotted priceA warehouse pick list; a ledger document
    LineOne SKU, a quantity, a unit price, inside one orderA shipment parcel
    PlaceThe transition from draft to placed, when invariants are checked and the total is fixedThe HTTP POST itself
    ShopperThe person or account being chargedA warehouse user
    AuthorizationA reference to a payment decision owned by the payment contextThe card number

    If a warehouse developer says “order” and means the pick list, the checkout team repeats the sentence with shipment or pick list and does not add binLocation to Order. Renaming is the design move. A bounded context exists so both words can be correct in their own homes.

    Write the glossary in the same repository as the code, short, and update it in the pull request that changes the language. A term with two definitions in one context means the context is doing two jobs.

    Domain, subdomain, and what is core

    The domain is the whole retail business. A subdomain is a slice of that business problem, independent of how you deploy it.

    SubdomainKind for this companyWhy
    Checkout and pricing promisesCoreHow TechLearningPro sells bundles, vouchers, and seats is the differentiator
    Catalog of recordSupportingNecessary, and the rules are real, but a well-run catalog is not why shoppers stay
    Payment authorizationGenericThe acquirer and the schemes define most of the behavior; you must do it well and you will not patent it
    FulfillmentSupportingWarehouse execution matters; the differentiator is not a new pick algorithm this year
    Finance postingGenericThe ledger must be correct and mostly follows accounting practice
    IdentityGenericBuy or integrate

    “Core” does not mean “the module we like.” It means where a custom model pays for itself. Payment stays explicit and well tested, and it stays a thin policy over an acquirer rather than a homegrown scheme. Checkout gets the deepest modeling. This distinction stops a team from inventing a novel aggregate for a problem a standard ledger already solves, and it stops them from treating checkout as CRUD because a framework made CRUD easy.

    Bounded context

    A bounded context is a boundary with one language and one model. Inside checkout, Order has the fields and methods in this lesson. Inside fulfillment, the corresponding concept is a PickList or Shipment with bins and carrier codes. Inside finance, it is a SalesPosting. They share identifiers — an order id printed on the pick list and on the posting — and they do not share a class.

    A context is not “a microservice” and it is not “a package” by definition. At TechLearningPro today, each context is one Spring Modulith module in one deployable (lesson 7). Payment could become its own deployable without changing the language boundary. Splitting checkout across two services while both services mutate the same Order aggregate would not create two contexts; it would create a distributed aggregate, which is the problem aggregates exist to prevent.

    Context relationships, in the vocabulary of strategic design, describe how models meet:

    RelationshipRetail exampleWhat you actually build
    Customer–supplierCheckout supplies OrderAuthorized facts; fulfillment consumes themA published event type, versioned, with fulfillment as a customer who can ask for fields
    Anticorruption layerFulfillment translates a legacy ERP “sales order” fileA translator at the edge; ERP column names never enter PickList
    ConformistFinance accepts the tax engine’s codes as-isYou map into those codes and do not pretend to redesign the tax vendor
    Shared kernelMoney, Sku, ShopperIdA tiny package, no I/O, owned in review by every consumer
    Separate waysRetail reviews and the learning-progress trackerNo shared types; integration only if a product requirement appears
    Open host / published languageCheckout’s public event and APIA stable contract for several consumers

    You do not need every relationship on the map. You need the ones where teams already argue. The enterprise chapter Strategic Design expands the full set. This lesson uses the map to decide ownership, not to decorate a slide.

    Entities and value objects

    An entity is equal to another if their identities match, even when some attributes differ. Order is an entity. Two orders with the same OrderId are the same order. A value object is equal when all its attributes match, and it is replaced rather than mutated. Money of USD 10.00 equals another USD 10.00. You do not give a Money an id and update its amount in place; you compute a new Money.

    java
    public record OrderId(UUID value) {
    public OrderId {
    if (value == null) {
    throw new IllegalArgumentException("id");
    }
    }
    }
    public record Money(BigDecimal amount, Currency currency) {
    public Money {
    if (amount == null || currency == null) {
    throw new IllegalArgumentException("money");
    }
    if (amount.signum() < 0) {
    throw new IllegalArgumentException("negative");
    }
    }
    }

    OrderId is a value object that names an entity. The entity is Order. Mixing them up produces either ids on every tiny type, or a Money you mutate through setters and then cannot safely share.

    Prefer records for value objects so equality and immutability come from the language. Keep the aggregate as a class because it has a lifecycle: draft, placed, authorized, cancelled. A record that you copy on every status change can also work in Java; a class with private fields and intention-revealing methods is easier to discuss with most Spring teams. Either way, the fields are not a public bean.

    Aggregates and the consistency boundary

    An aggregate is a cluster of entities and value objects with one root. Outside code may hold a reference to the root’s id, not to an internal entity it then mutates. The repository loads and saves the whole aggregate in one transaction. Invariants that must be true after every commit are enforced inside the root.

    Checkout’s Order aggregate:

    • Root: Order, identity OrderId.
    • Inside: OrderLine entities, local ids unique within the order, each with SKU, quantity, unit Money.
    • Inside: status, shopper id, total, timestamps.
    • Outside, by id only: the product (catalog context), the authorization (payment context), the reservation (fulfillment context).

    Invariants the root enforces:

    • At least one line before place().
    • Every line shares one currency.
    • Quantity is at least 1.
    • place() runs only from DRAFT, and it fixes the total as the sum of line totals.
    • addLine throws once the status is no longer DRAFT.
    • markAuthorized runs only from PENDING_PAYMENT and requires a non-blank authorization id.
    • Cancelling an authorized order is a method cancel(Reason) that checks the current status and records a domain fact. It does not silently set a field.

    Why product is not inside the aggregate: a product lives longer than any order, is edited by another team, and a change to its description must not require locking open orders. The order stores a snapshot of title and unit price at place time (lesson 7). Why lines are inside: a total that does not equal the lines is a corrupt order, and there is no use case that edits a line without the order. A separate OrderLineRepository would let a caller save a line the root never checked.

    Aggregate size is a performance and correctness choice. One order and its lines, typically a handful, is a comfortable transaction. “The shopper and all of their orders and all payments” is not an aggregate; a single shopper lock would serialize checkout, and the invariants do not actually span that graph. When you are unsure, ask which rule would be false for a moment if two transactions committed independently. If no such rule exists, you have two aggregates.

    Repositories

    A repository is the illusion of an in-memory collection of aggregates. For each aggregate type there is one repository. Checkout needs OrderRepository, not OrderLineRepository and not OrderSpringDataRepository exposed to the application service with JPA types.

    java
    public interface OrderRepository {
    Optional<Order> find(OrderId id);
    void save(Order order);
    }

    The Spring adapter (JdbcClient or JPA) maps rows to Order and back, inside the module’s infrastructure package. Spring Data’s JpaRepository is an adapter technology. It is not the definition of a repository. If you use it, the interface the application service depends on still speaks Order, and the JPA entity stays in the adapter. Repositories on the enterprise track discusses the pattern’s history; the contract above is the one this codebase can test with a HashMap.

    Queries that do not load a full aggregate for a write — “list this shopper’s order summaries” — can be a separate read port. Forcing them through OrderRepository.find and then mapping in memory is how repositories grow fifty methods and start returning projections the aggregate never needed. Keep the write repository small. A read model can be a SQL query in the same module until a real CQRS requirement appears (CQRS and Event Sourcing).

    Application service versus domain service

    The application service is the use case from lesson 6: PlaceOrderService loads or creates an Order, calls place(), calls ports, saves. It owns the transaction boundary. It does not own the rule “currency must match.”

    A domain service is a stateless rule that uses domain types and does not naturally sit on one entity. Tax calculation that needs a policy table and the order’s lines can be a TaxPolicy domain service invoked by the aggregate or by the application service, with the result passed into order.applyTax(Money). If the method needs only the order’s own lines, it should be on Order, not in a service named OrderManager. The smell of a domain service is a class that holds no state and encodes a business rule. The smell of a procedural relapse is a service that reads and writes entity fields because the entity has no methods.

    Domain events inside the model

    When markAuthorized succeeds, the aggregate records an in-memory OrderAuthorized (order id, shopper id, line snapshots). The application service reads order.domainEvents() after the call and passes them to a port. The aggregate does not autowire ApplicationEventPublisher. Publishing is an application and adapter concern. Lesson 9 turns the fact into a message other contexts consume. Domain Events covers the pattern’s variants. Here the event is simply a fact the model already believes, captured so the use case does not re-derive it from a status diff.

    What DDD is not asking you to do

    Do not build a generic AggregateRoot framework before the second aggregate exists, and do not rename every service so the word “domain” appears in the class name. A feature-flag screen can stay CRUD. One PostgreSQL instance does not make one context. Event sourcing is a later lesson; the invariants here are state plus a few facts, stored as rows. Use the building blocks where a language bug or an invariant bug has already hurt you, in a core subdomain or in a supporting one with real rules.

    Architecture and Design Diagrams

    mermaid
    flowchart LR
    subgraph checkout [Checkout context]
    O[Order aggregate]
    OL[OrderLine]
    O --> OL
    end
    subgraph catalog [Catalog context]
    P[Product]
    end
    subgraph payment [Payment context]
    A[Authorization aggregate]
    end
    subgraph fulfillment [Fulfillment context]
    S[Shipment / PickList]
    end
    subgraph finance [Finance context]
    L[SalesPosting]
    end
    O -. sku + price snapshot .-> P
    O -. authorization id .-> A
    O -. OrderAuthorized .-> S
    O -. OrderAuthorized .-> L
    mermaid
    flowchart TB
    UI[Driving adapter] --> APP[PlaceOrder application service]
    APP --> AGG[Order.addLine / place / markAuthorized]
    APP --> TAX[TaxPolicy domain service]
    APP --> REPO[OrderRepository]
    REPO --> DB[(orders schema)]
    AGG --> EV[In-memory OrderAuthorized]
    APP --> PORT[Event port]
    EV --> APP
    mermaid
    classDiagram
    class Order {
    OrderId id
    OrderStatus status
    place()
    addLine(sku, qty, unitPrice)
    markAuthorized(authorizationId)
    }
    class OrderLine {
    LineId id
    Sku sku
    int quantity
    Money unitPrice
    }
    class Money {
    BigDecimal amount
    Currency currency
    }
    Order "1" --> "*" OrderLine
    OrderLine --> Money

    The class diagram is the consistency boundary. Anything not drawn inside Order is another aggregate or another context, reached by id or by a snapshot copied at the moment of place().

    Technical Explanation

    A place operation, told in the language

    Merchandising confirms that a voucher line and a book line may share an order, prices come from catalog at place time, and a placed order’s total does not change if catalog changes an hour later. The application service asks CatalogPrices for each SKU, then:

    java
    Order order = Order.draft(ids.next(), command.shopperId(), clock.instant());
    for (RequestedLine line : command.lines()) {
    Money unit = prices.priceOf(line.sku());
    order.addLine(line.sku(), line.quantity(), unit);
    }
    order.place();

    addLine rejects a second currency and a non-positive quantity. place() rejects an empty order, sets the total, moves DRAFT to PLACED, then the surrounding use case persists PENDING_PAYMENT as in lesson 6. The status names are the glossary, not a private enum the UI invents a second time. The HTTP adapter shows “Payment pending” by mapping PENDING_PAYMENT, and the mapping table lives in the adapter so a copywriter can change a label without changing place().

    Identity and equality in practice

    Order.equals compares OrderId only. Two instances loaded by two requests are the same order if the ids match, even if one is stale. That is why you do not use equals to detect “did the total change?” Value objects do that: total.equals(otherTotal).

    OrderLine has a LineId because the shopper can remove “the second line” and because a later refund may name a line. The id is unique within the aggregate. It is not a global product id. A line’s equality inside the aggregate is by LineId. Replacing a line means the root removes one LineId and adds another. Callers outside the aggregate never receive a line and save it themselves; they call order.removeLine(LineId).

    The invariant test is the design

    java
    @Test
    void placeFixesTotalAndRejectsAnotherLine() {
    Order order = Order.draft(OrderId.random(), shopper, Instant.parse("2026-11-06T12:00:00Z"));
    order.addLine(sku("BOOK-1"), 2, Money.of("10.00", "USD"));
    order.place();
    assertEquals(Money.of("20.00", "USD"), order.total());
    assertThrows(OrderClosed.class, () -> order.addLine(sku("BOOK-2"), 1, Money.of("5.00", "USD")));
    }

    OrderClosed is a domain exception for a broken use of the model, not a declined payment. Decline is a payment result (lesson 6), not an exception. Tests like this one run without Spring. If you cannot write them because every rule sits in OrderService as if statements on getters, the model is anemic: the class is a struct and the language lives in a procedure the warehouse context cannot reuse and the checkout context can bypass with a new setter.

    Referencing other aggregates

    Order stores authorizationId as a string or as a small value type AuthorizationId defined in the shared kernel or in the payment API package. It does not store an Authorization object graph. Loading payment’s aggregate inside checkout’s transaction would lock across contexts and would pull payment’s invariants into checkout’s session. The application service calls the payment port, receives an id, and calls order.markAuthorized(id).

    The same rule applies to the shopper. Checkout stores ShopperId. Profile edits happen in identity. An order does not update an email address inside place().

    Anticorruption at the ERP edge

    Fulfillment receives a nightly file from a legacy ERP. Columns are named SO_HDR_ID, QTY_ORD, and ITEM_CD. An adapter parses the file and calls PickListImporter with SKUs and quantities. PickList never mentions SO_HDR_ID. A test feeds a sample file and expects a pick list. When the ERP is retired, the adapter is deleted. A correlation table may store the external key; the aggregate does not.

    Transactions and concurrency

    One aggregate, one transaction, one repository save. A version column detects a conflicting cancel. Map that conflict to HTTP 409. Shrink the aggregate before you reach for a pessimistic lock. Seat inventory is fulfillment’s reservation aggregate (lesson 7). Checkout stores the reservation id and does not join fulfillment’s table inside OrderRepository.save.

    Security in the model

    ShopperId is set from the authenticated principal, not from a JSON field. addLine does not take a client-supplied price. The application service obtains Money from catalog; domain tests may pass a Money directly. An HTTP test must show that a unitPrice property in JSON is ignored.

    Practical Implementation or Design Walkthrough

    Problem. Model TechLearningPro retail checkout so an order cannot be placed empty, cannot mix currencies, cannot change lines after place, and can record an authorization id from payment without loading the payment aggregate.

    Assumptions. One checkout bounded context, one orders schema, Java 21, Spring Boot, JdbcClient adapter from lesson 6. Catalog and payment are other contexts behind ports. Tax for this walkthrough is a TaxPolicy that returns Money for a placed total and a ship-to country; the country is a value object CountryCode.

    1. Value objects

    Sku, ShopperId, LineId, Money, and CountryCode reject null or blank in the constructor. Money.plus and Money.times(int) return new values and refuse a currency mismatch. Round with HALF_UP only when merchandising has written that rule; otherwise refuse a scale the currency cannot represent, as lesson 6 did.

    2. The aggregate

    java
    public final class Order {
    private final OrderId id;
    private final ShopperId shopperId;
    private final List<OrderLine> lines = new ArrayList<>();
    private final List<Object> events = new ArrayList<>();
    private OrderStatus status = OrderStatus.DRAFT;
    private Money total;
    private String authorizationId;
    private int version;
    public void addLine(Sku sku, int quantity, Money unitPrice) {
    ensureDraft();
    if (quantity < 1) {
    throw new IllegalArgumentException("quantity");
    }
    if (!lines.isEmpty() && !lines.get(0).unitPrice().currency().equals(unitPrice.currency())) {
    throw new CurrencyMismatch();
    }
    lines.add(new OrderLine(LineId.random(), sku, quantity, unitPrice));
    }
    public void place() {
    ensureDraft();
    if (lines.isEmpty()) {
    throw new OrderRejected("at least one line");
    }
    Currency currency = lines.get(0).unitPrice().currency();
    this.total = lines.stream().map(OrderLine::lineTotal).reduce(Money.zero(currency), Money::plus);
    this.status = OrderStatus.PLACED;
    }
    public void markAuthorized(String authorizationId) {
    if (status != OrderStatus.PENDING_PAYMENT) {
    throw new OrderRejected("not pending");
    }
    if (authorizationId == null || authorizationId.isBlank()) {
    throw new IllegalArgumentException("authorization");
    }
    this.status = OrderStatus.AUTHORIZED;
    this.authorizationId = authorizationId;
    events.add(new OrderAuthorized(id, shopperId, lineSnapshots()));
    }
    }

    PENDING_PAYMENT is set by the application service after a successful place(), via order.markPendingPayment(), so the domain keeps the sequence explicit. The repository persists status, lines, total, version. lineSnapshots() copies SKU, quantity, and money into immutable records so a later clear() of an internal list cannot change an event already handed to the publisher.

    3. Domain service for tax

    java
    public final class TaxPolicy {
    public Money taxOn(Money merchandiseTotal, CountryCode shipTo) {
    // A table-driven rate lives behind this method once finance publishes it.
    // The first version uses a single configured rate per country code.
    return rates.compute(merchandiseTotal, shipTo);
    }
    }

    The application service calls taxPolicy.taxOn(order.total(), command.shipTo()) after place() and then order.applyTax(tax), which adds tax into the total and refuses a currency mismatch. The rate table is infrastructure. The decision to call it between place and payment is application policy. The refusal to mix currencies stays in Money.

    4. Repository adapter

    The adapter writes orders.checkout_order and orders.checkout_line in the transaction the application service opened. It increments version with update … where id = :id and version = :version and throws a conflict if the count is 0. It does not expose OrderLine as a top-level save. Reconstruction (find) builds an Order through a package-visible reconstituting factory so the adapter can restore AUTHORIZED orders without calling place() again. That factory is the one hole in “only methods change state,” and it lives next to the aggregate, named Order.restore(...), documented as persistence-only. The HTTP layer cannot see it if it is package-visible and the adapter shares the package, or it is public and review forbids calling it from a use case. Prefer package-visible in domain and an adapter in the same package, or a persistence-specific factory interface implemented beside Order. Do not @JsonCreator the restore path onto the public API.

    5. Application service

    The service from lesson 6 stays the orchestration: idempotency, catalog prices, reservation, payment, two short transactions. The difference after this lesson is that place, currency, and total live on Order, and the service stops computing totals itself. A reviewer can delete a total calculation from the service and the domain test still holds.

    6. Context map you can leave in the repo

    One markdown file contexts.md next to the glossary:

    • Checkout owns Order. Core subdomain.
    • Catalog owns Product and current price. Supporting. Checkout copies price at place.
    • Payment owns Authorization. Generic. Checkout stores the authorization id.
    • Fulfillment owns Reservation and PickList. Supporting. Consumes OrderAuthorized. Translates ERP files through an anti-corruption adapter.
    • Finance owns SalesPosting. Generic. Consumes the same event. Conformist toward the external tax codes inside TaxPolicy’s adapter.

    Expected result. Domain tests cover empty place, mixed currency, extra line after place, and authorization recording. A HashMap repository round-trips an aggregate. No domain type imports Spring. The glossary contains “order,” “pick list,” and “posting” as different terms. A pull request that adds binLocation to Order fails review because the glossary says that word is fulfillment’s.

    Enterprise Real-World Scenarios

    Scenario 1: One retail business, five languages

    TechLearningPro sells in three countries. Merchandising defines a bundle: a course plus a printed book with a single promotional price. Warehouse defines a bundle as “two parcels if the book ships from another node.” Finance defines a bundle as two revenue accounts. One Bundle class will be wrong for two of them.

    The context map gives merchandising’s bundle to catalog and checkout: a SKU whose price is a package price, exploded into component SKUs on the order line as snapshots labeled componentOf. Fulfillment receives component SKUs and quantities on OrderAuthorized and builds parcels with its own rules. Finance receives the same event and posts revenue with its own account map. Nobody imports another context’s Bundle type. The shared kernel carries Sku and Money only. When finance asks for the promotional narrative string, checkout adds it to the event as a published-language field with a version, instead of giving finance a database login to orders.checkout_order. That request is a customer–supplier conversation: finance is the customer, checkout the supplier, and the event schema is the contract. Bounded Contexts shows larger maps; the discipline here is the refusal to unify the noun.

    Scenario 2: The legacy ERP and the anti-corruption layer

    One warehouse still runs an ERP that emits fixed-width “sales orders” every fifteen minutes and expects a status code SHIP or BACK in return. Replacing the ERP is a two-year program. Fulfillment cannot wait, and checkout must not learn the file format.

    The anti-corruption adapter parses the file into ImportedDemand values (SKU, quantity, external reference). PickList plans picks from either OrderAuthorized or ImportedDemand through one application service that already speaks fulfillment’s language. The return file is produced by a second adapter that reads pick-list status and writes SHIP or BACK. When the ERP sends a negative quantity, the adapter rejects the record to a quarantine table and does not call PickList. Quarantine is an adapter concern. The aggregate’s invariant — quantity at least 1 — stays intact because bad input never reaches it. A conformist approach, letting PickList store SO_HDR_ID and negative quantities “because the ERP is the source of truth,” would make every future consumer of pick lists defend against ERP quirks. The warehouse’s core workflow is supporting but still yours; the file format is theirs.

    Scenario 3: An anemic model that passed every layering check

    The modular monolith’s verify() was green. Order had private fields, a JPA adapter, and no invariant methods. OrderService contained place, currency checks, and total math, plus a new method adjustLine added for a support tool that used setters through reflection in a one-off script. A support agent “fixed” a total by editing a line after payment. The ledger and the shopper receipt diverged.

    The repair was not a new service. It was moving adjustLine into the aggregate as correctLine(LineId, Money), allowed only in PLACED before authorization, writing a domain event OrderCorrected, and deleting the reflective script. The application service for support calls the same aggregate method the tests call. Green Modulith checks proved package direction. They did not prove the model had behavior. DDD’s contribution in a codebase that already has modules is exactly that second standard.

    Best Practices

    • Keep a glossary per bounded context in the repository, and use its nouns as type names.
    • Make value objects immutable. Put equality on their attributes. Put entity equality on identity.
    • Enforce invariants in aggregate methods. Prefer a failed test over a comment that says “remember to check currency.”
    • One repository per aggregate. Save the whole aggregate. Use optimistic version checks on update.
    • Reference other aggregates by id. Copy the attributes you must freeze, and name them snapshots.
    • Keep application services thin: authorization of the caller, transactions, port calls, save.
    • Put a rule on the entity when the data it needs is already on the entity.
    • Record domain events on the aggregate and publish them in the application service after you know the save succeeded, or via the outbox in lesson 9 when another context must not miss them.
    • Translate external models at an anti-corruption boundary. Quarantine input that violates your invariants.
    • Limit the shared kernel to types whose meaning is identical in every consumer.
    • Reconstitute aggregates from persistence through an explicit restore path that does not re-run place().
    • Review “core versus generic” when someone proposes a custom engine for payments or ledgers. Build custom models where the business is different.

    Common Mistakes and Anti-Patterns

    Anemic domain model. Entities with getters and setters, and a service that contains every rule. Any new use case can bypass a rule by setting fields. Move the rule onto the aggregate and delete the setter. Spring’s data-binding habits encourage this because JSON maps naturally onto beans. Map JSON onto a request DTO in the adapter, then call methods.

    One Order for the enterprise. Warehouse, finance, and checkout share a table and a class so “we have a single source of truth.” The class becomes optional-field soup, and every context’s migration blocks the others. Split the language first. Integrate with ids and events. A single database is not a single model.

    Aggregate as the entire object graph. Order contains Shopper, Product, Authorization, and Shipment. Transactions lock unrelated hot rows, and contexts collapse. Keep the graph to one consistency boundary. Load other data through ports before you call the aggregate, and pass values in.

    Repository per table. OrderLineRepository.save(line) lets a line exist that the root would reject. Persist lines in OrderRepository.save only.

    Domain events as an excuse to skip the model. A stream of LineAdded events with no aggregate can be right later, in an event-sourced design. As a first step it usually duplicates the invariant in every projector. Store the state that must be consistent, and emit the fact as well, until lesson 42’s criteria for event sourcing are met.

    Ubiquitous language as renaming theater. Calling a DAO OrderRepository while it returns a JOIN of four contexts does not create a repository. Calling a DTO OrderAggregate does not create an aggregate. The test is behavioral: illegal states do not construct, and the type’s name matches the glossary a merchandiser would use.

    Shared kernel as a dumping ground. Money belongs there. OrderUtils does not. A shared type with a status enum containing both PICKED and AUTHORIZED is two languages welded together.

    Anti-corruption layer that leaks “just one field.” PickList.erpCode starts as a convenience for support and becomes a branch in every method. Keep the external key in the adapter’s correlation table: external_ref → pick_list_id.

    Troubleshooting and Design Review

    SymptomLikely causeWhat to checkCorrection
    Totals disagree with linesTotal stored independently and editedDomain tests; support tools with SQLCompute total in place / applyTax; remove the setter
    Currency bugs only in productionCheck lives in one service method, a second entry point skips itCall sites of setTotal or field assignmentInvariant inside addLine
    Deadlocks on product during checkoutProduct row locked inside the order transactionLock and transaction logsSnapshot price; do not join product into the order write
    Version conflicts on every cancelAggregate includes a frequently updated child that does not share the invariantWhat else is in the same saveSplit the aggregate
    ERP codes in the shopper APIMissing anti-corruption boundaryResponse DTO fieldsTranslator and correlation table
    Finance and checkout argue about “order status”One enum, two languagesGlossarySeparate status models; map at the event
    Cannot unit test without SpringAggregate annotated as an entity, logic in a @ServiceImports of the domain packagePlain class, methods, adapter outside
    verify() green, invariants absentModules enforced, model anemicWhether illegal states have testsBehavior on the aggregate, as in scenario 3

    Design review questions:

    • Which context owns this noun, and where is the glossary line?
    • What invariant is now impossible to bypass, and which test demonstrates it?
    • What did you refuse to put inside the aggregate?
    • Is the repository’s save the only write path for these tables?
    • If this type crossed a context boundary, is it an id, a snapshot, or a leak?
    • Does the application service contain a business if that should be a method on the aggregate?

    Interview Questions and Answers

    Q1. What is a ubiquitous language, and what does it change in code?

    It is the shared vocabulary of one team and the model they maintain. The words in the glossary are the words in class names, method names, and tests. In checkout, “place” means the aggregate transition that fixes the total, and the HTTP adapter does not invent a second verb for the same rule. The practical change is refusal: when warehouse says “order” and means a pick list, you do not add bin fields to Order. You introduce PickList in the fulfillment context. A glossary that disagrees with the code is a bug in one of them. Renames are design work, done in the same pull request as the behavior, not a cosmetic pass at the end.

    Q2. How do you tell an entity from a value object?

    An entity has a stable identity. Two Order instances with the same OrderId are the same order even if a stale copy has an old total. A value object has no identity you care about; Money equality is amount plus currency, and you replace it instead of mutating it. OrderId itself is a value object that names an entity. Use records for money, SKU, and country code. Keep the order as a class with a lifecycle if that makes the transitions clearer. If you find yourself giving Money a database id and updating it in place, you have turned a value into an accidental entity and invited shared mutable state.

    Q3. Why are order lines inside the Order aggregate, while Product is not?

    The total must equal the lines after every commit, and there is no use case that saves a line on its own. The root checks currency, quantity, and whether the order is still a draft. A product has a different lifecycle, a different team, and edits that must not lock or rewrite open orders. Checkout stores SKU, title snapshot, and unit price on the line. Catalog remains the authority for the current price of a SKU. Putting Product inside Order creates a cross-context aggregate and a hot lock. Leaving lines outside as their own aggregate allows a total that never saw the line.

    Q4. How large should an aggregate be?

    Large enough to protect one consistency rule, and no larger. Order plus its lines fits a single short transaction and a single version column. “Shopper plus every order plus every payment” serializes unrelated work and mixes contexts. A useful test: if two changes can commit in either order and no business rule is broken, they are not one aggregate. Seat inventory fails that test against a naive order line, so inventory is a reservation aggregate in fulfillment, and the order stores the reservation id. Optimistic versioning on the order is enough for cancel versus a rare double-submit. If conflicts are constant, the aggregate is probably too wide.

    Q5. What is a repository in DDD, and where does Spring Data fit?

    A repository loads and saves one aggregate type, hiding persistence. OrderRepository.find and save are the contract. Spring Data JPA can implement an adapter behind that contract, or you can use JdbcClient as lesson 6 does. The application service should not depend on JpaRepository<OrderEntity, UUID> if OrderEntity is a persistence model full of annotations. One repository per aggregate, not per table: lines are saved with the order. Read-only screens can use a separate query port so the write repository stays a collection of aggregates rather than a report factory.

    Q6. How is a bounded context different from a module and from a microservice?

    A bounded context is a language and model boundary. A module in lesson 7 is the code structure that enforces dependencies inside one deployable. A microservice is a deployable with its own process and usually its own data store. They can line up one-to-one, and at TechLearningPro they do for checkout, catalog, payment, and fulfillment inside one modular monolith. You can split a microservice later along an existing context without renaming the business. You can also, unwisely, put two languages in one service or split one aggregate across two services. The context is the design decision. The module and the service are packaging and operations. Start from the language. Bounded Contexts and Microservices Architecture Principles cover the ends of that spectrum.

    Q7. What is an anemic domain model, and why do modular packages not prevent it?

    The entities are data holders. Services perform every rule. Any code path with a setter, a reflection script, or a new service can skip a check. Package verification stops orders from calling catalog’s JDBC. It does not stop OrderService from being the only place that understands an order. The fix is methods on Order — place, addLine, markAuthorized — and tests that do not start Spring. DTOs at the edge can stay anemic; they are not the model. The model is the type that makes illegal states throw.

    Q8. What is an anti-corruption layer, and when would you conform instead?

    An anti-corruption layer translates an external model into yours so their names and quirks stay at the edge. Fulfillment’s ERP file adapter turns ITEM_CD into Sku and quarantines negative quantities. You conform when the external model is the standard you accept, such as a tax vendor’s codes: map into those codes inside the tax adapter and do not invent a parallel tax universe you will have to reconcile by hand. Conformism is a choice to follow. An anti-corruption layer is a choice to keep your language. Both are explicit. The failure mode is an accidental mix: ERP column names on PickList plus a half-implemented internal status.

    Q9. How do an application service and a domain service differ?

    The application service coordinates a use case: identity of the caller, transaction, calls to other contexts, repository save. PlaceOrderService is one. A domain service holds a domain rule that does not sit naturally on a single entity, such as TaxPolicy.taxOn(total, country) when the rate comes from a policy table. It does not publish HTTP, open transactions, or call save. If the rule uses only fields already on Order, put it on Order. A domain service that accepts ten entities and sets their fields is the anemic model wearing a new name.

    Q10. How should checkout refer to a payment or a product?

    By identity and by snapshots, not by object reference. Order stores AuthorizationId after the payment port returns, and stores SKU plus unit price taken at place. It does not hold an Authorization aggregate or a live Product. The application service talks to those contexts through ports (lesson 6) defined by their APIs (lesson 7). This keeps transactions inside one aggregate and keeps payment’s schema out of checkout’s save. A foreign key from orders.checkout_order to payment.authorization re-couples what the id reference, backed by a port, kept loose.

    Q11. When would you skip this modeling?

    When the subdomain is simple CRUD with no invariant you can state, and no second language is colliding with it. A feature-flag admin screen does not need aggregates. When the subdomain is generic and a product already implements it, integrate and conform rather than modeling a poorer copy. When the team cannot name a domain expert or a glossary, a context map drawn in private will be fiction; spend the time on the conversation first. DDD pays for itself in core checkout, where a wrong total or a shared “order” noun has already caused defects. It is overhead when applied as a template to every table in the system.

    Hands-On Exercises

    Exercise 1: A bounded-context map for the retail business

    Problem. TechLearningPro retail includes catalog, checkout, payment, fulfillment, and finance. Produce a context map and the checkout aggregate.

    Tasks.

    1. Write a glossary of at least eight terms for checkout, and show the fulfillment and finance words you will not reuse.
    2. Classify each context as core, supporting, or generic, with one sentence each.
    3. Draw the map with at least one customer–supplier event, one id reference, and one anti-corruption boundary.
    4. Specify Order, its internal entities, its value objects, and the invariants place enforces.
    5. Define OrderRepository and state what is not a repository method.
    6. Show how a product price and an authorization id are stored.
    7. Add one domain test list: empty order, mixed currency, line added after place, version conflict on save.
    8. Note one concept you left as CRUD on purpose.

    Expected deliverables. Glossary, context map, aggregate sketch, repository signature, test list.

    Acceptance. A reader can see that “order,” “pick list,” and “posting” are different types. The aggregate sketch contains no Product entity and no card number. Invariants are methods, not comments.

    Exercise 2: Repair the shared Order

    You are given one Order table used by checkout, warehouse, and finance, with nullable columns for bin, tax account, and card token. Propose the target contexts, what remains in the checkout aggregate, what becomes a snapshot or an id, and the first translator you would write. Keep one database for this quarter if you want; do not keep one class.

    Rubric.

    AreaWeight
    Glossary and context boundaries15%
    Aggregate invariants and size25%
    Repository and identity rules20%
    Cross-context references and anti-corruption20%
    What was left generic or CRUD, and why10%
    Clarity of names a domain expert would recognize10%

    Lesson Summary

    Domain-driven design gives TechLearningPro’s retail system a language per context and a place where each invariant lives. Checkout’s Order is an aggregate: lines inside, product and payment outside by id and snapshot, total fixed by place(), repository saving the whole cluster. Money is a value object. PlaceOrderService is an application service. TaxPolicy is a domain service only because the rule is not the order’s private data. Fulfillment and finance consume facts; they do not share the class. A legacy ERP meets the model through an anti-corruption adapter. Modules and ports from the previous lessons are how this model sits in Spring. They do not replace it. An anemic Order inside a perfect package boundary will still let a script edit a paid total. The next lesson takes OrderAuthorized off the aggregate’s in-memory list and asks how another context can rely on that fact when the process crashes — events, choreography, orchestration, and the outbox.

    Further Reading

    Ready to mark this lesson complete?Track your journey across the entire course.