The decomposition plan from the previous lesson leaves six services with boundaries drawn with a broad brush. What is missing is what separates an acceptable design from a good one: deciding precisely where the meaning of each word ends. In TechCorp's monolith, "product" is a row in the products table and everybody uses it; but what the catalog understands by product (a listing with description, images and attributes) is not what inventory understands (an identifier with quantities), nor what orders understands (a name and a price frozen at the moment of purchase). While the three areas shared a table, the ambiguity was tolerated. As soon as they are services, ambiguity turns into coupling.

Domain-Driven Design (DDD) calls this strategic DDD and offers the tools to do it well: subdomains, ubiquitous language, bounded contexts, context map and aggregates. This lesson applies them one by one to TechCorp: we will classify the subdomains, look at concrete examples of ambiguity, define the six contexts and their relationship to the services, draw the context map with its relationship patterns, define the Order aggregate (and why stock is not part of it) and pin down which "product" and "customer" fields each context stores. How that materializes as physically separate databases is the subject of 02-04.

Contents

  1. Strategic DDD in a nutshell
  2. TechCorp's subdomains: core, supporting and generic
  3. Ubiquitous language: when "product" and "customer" do not mean the same thing
  4. What a bounded context is and how it relates to a microservice
  5. The context map and its relationship patterns
  6. Aggregates and aggregate roots: the transactional boundary
  7. The data model of each context

  1. Strategic DDD in a nutshell

DDD (Eric Evans, 2003) has two halves. The tactical half is about how to write the code of a model (entities, value objects, repositories, domain services). The strategic half is about how to divide a big problem into models that can evolve separately. For designing microservices, the strategic half is the one that matters, and it rests on five ideas:

Idea What it is Question it answers
Domain The company's area of knowledge and activity. For TechCorp, "selling consumer electronics online". What is the business about?
Subdomain A part of the domain with its own set of problems. Catalog, inventory, orders... What parts does the problem divide into?
Ubiquitous language The precise vocabulary shared by business and engineering, within a context. What exactly does each word mean here?
Bounded context The boundary within which a model and its language are valid and coherent. How far does this model reach?
Context map The diagram of how contexts relate and who is in charge in each relationship. How do they talk to each other, and who adapts to whom?

The distinction that causes the most confusion: a subdomain is a piece of the problem (it exists even without software); a bounded context is a piece of the solution (a model we build). Ideally they match one to one, and at TechCorp they will, but it is worth being clear that they are different things.

  1. TechCorp's subdomains: core, supporting and generic

DDD classifies subdomains by their competitive value, and that classification dictates how much design effort each deserves:

  • Core: where the company differentiates itself. It is what must be designed with the most care and the best team, and what is never bought off the shelf.
  • Supporting: necessary for the business, specific to the company, but not differentiating. Designed well, without obsession.
  • Generic: a problem solved the same way in every company. It is bought, an external service is used, or it is implemented in the simplest possible way.
TechCorp subdomain Type Rationale Design consequence
Orders (order lifecycle, purchase flow) Core It is where TechCorp wins or loses sales; the shopping experience and the reliability of the flow set it apart from the competition. Luis's team; richest model; saga designed with care (02-05); never an entity service.
Catalog (listings, search, prices) Core The way electronics products with highly variable attributes are presented and searched is part of the value proposition. Deserves its own technology (MongoDB) and being the first to scale.
Inventory (stock, reservations) Supporting Essential and specific (reservation rules, warehouses), but it does not differentiate TechCorp. Simple, correct model; its invariant (never sell what is not there) is sacred.
Customers (profile, addresses) Supporting Necessary, specific in small details (addresses, preferences). Simple model; authentication, which is generic, is delegated.
Payments (charges, refunds) Supporting, with a generic core Charging is generic (the external payment provider does it); when and how to charge depending on the order's status is specific. Payment provider adapter isolated behind an anti-corruption layer (section 5).
Notifications (email, SMS) Generic Sending an email is the same everywhere. Minimal implementation; external provider; no business logic inside.
Identity (authentication) Generic Login, passwords, tokens: a solved problem. Fully delegated to Keycloak (07-01); TechCorp has no service for it.

This table justifies decisions we had already made by intuition: why the catalog and orders concentrate the investment, why identity does not appear on the service map, and why Notifications should be boring.

  1. Ubiquitous language: when "product" and "customer" do not mean the same thing

The ubiquitous language is the vocabulary business and development use without translation, and which appears as-is in the code (which is why the identifiers in this course are business words: createOrder, OrderRepository, customerId). DDD's key observation is that the ubiquitous language is only coherent within a context: the same word changes meaning when it crosses the boundary, and aiming for a single "product" model for the whole company produces a giant model that serves nobody well (it is TechCorp's products + product_attributes table).

3.1 "Product"

Context What a "product" is Attributes it cares about What it does not care about
Catalog A commercial listing that is displayed and searched. Name, long description, category, images, variable attributes (voltage, compatibility, color), current selling price, whether it is published. How much stock there is; which orders it appears in.
Inventory A stockable reference of which there are units. productId, physical quantity, reserved quantity, warehouse location, restock threshold. The description, the images, the price.
Orders An order line: what the customer bought, at the price they bought it. productId, name at the time of purchase, unit price at the time of purchase, quantity. Whether the price changed afterwards; current stock; the attributes.

The most important practical consequence is in the third row: if the catalog changes the price of p-501 tomorrow, today's order ord-88213 must not change. That is why orders stores a frozen copy of name and price in order_lines, and why the "Price changed" event from the event storming in 02-02 is of no interest to it. It is not accidental duplication: they are two different concepts that share an identifier.

3.2 "Customer"

Context What a "customer" is Attributes it cares about
Customers A registered person with a profile and addresses. customerId, email, name, saved addresses, contact preferences, sign-up date, Keycloak identifier.
Orders Whoever places the order and where it ships to. customerId (reference), shipping address of this order (copy; if the customer changes their address later, the order in progress must not move).
Notifications A recipient. Email and name for the greeting. Nothing else. And it receives them in each event; it does not store them as master data.
Payments Barely exists: a payment is associated with an order, not a customer. orderId, amount. The "cardholder" is known to the payment provider, not to TechCorp.

3.3 Other words with a double meaning

  • "Status": in Orders, PENDING / CONFIRMED / CANCELLED; in Payments, AUTHORIZED / CAPTURED / REFUNDED; in Inventory, a reservation is ACTIVE / CONSUMED / RELEASED. Three different state machines that today coexist in unrelated status TEXT columns.
  • "Cancel": for Orders it means closing the order without completing it; for Payments it means not capturing an authorized charge; for Inventory it means releasing a reservation. The saga in 02-05 will chain the three, but each context uses its own verb.
  • "Available": in Catalog it means published; in Inventory it means quantity - reserved > 0. A product can be available in the catalog and not in inventory, and vice versa.

Detecting these collisions is, in practice, the most reliable way of finding boundaries: wherever a word changes meaning, there is a seam.

  1. What a bounded context is and how it relates to a microservice

A bounded context is the explicit boundary within which a domain model and its ubiquitous language are coherent. Inside, "product" means one thing only; outside, it may mean something else. Each context has its own model, its own code and (as we will see in 02-04) its own data.

The relationship to microservices:

Relationship When Example Assessment
1 context = 1 service The usual and recommended starting point. TechCorp's six contexts → the six services of the target map. Maximum clarity: the model boundary is the deployment and data boundary.
1 context = several services When parts within a context have very different scaling or pace of change. If catalog search needed an indexing engine separate from listing management, both would still speak the same language ("listing", "attribute") but would be two deployments. Acceptable; the two services share language but not database or business code.
Several contexts = 1 service In early stages or in small domains. The modular monolith from 01-03; or TechCorp's "residual monolith" while Orders and Customers have not yet been extracted. Valid as an intermediate step if the contexts are separated as modules and do not share tables by design.
1 service covers half a context Never. An order-lines-service separate from orders-service. Breaks the model in half: the order's invariants (section 6) end up split across two processes.

TechCorp's rule: one context, one service, one owning team. And the admitted exception: during the migration, the residual monolith contains several contexts, each in its own module, with the abstractions from 02-02 marking where the boundaries are.

  1. The context map and its relationship patterns

Contexts do not live in isolation: Orders needs prices from Catalog, Notifications needs to know that an order has been confirmed. The context map draws those relationships and, above all, who is in charge in each one: in any relationship there is an upstream context (its model has influence) and a downstream one (it adapts or protects itself). DDD catalogs the relationship patterns:

Pattern What it means Who is in charge When it is used
Customer-Supplier The upstream (supplier) serves the needs of the downstream (customer); there is negotiation: the customer can ask for fields, the supplier plans. Upstream, but it listens. Teams that collaborate in good faith.
Conformist The downstream accepts the upstream's model as-is, without translating it or influencing it. Upstream entirely. When the upstream is not going to change for you (or translating is not worth it).
Anticorruption Layer, ACL The downstream puts in a layer that translates the upstream's model into its own language, so the foreign model does not "contaminate" its own. Upstream, but the downstream protects itself. External systems, legacy systems, very different models.
Shared Kernel Two contexts share a piece of model (code or schema) and agree to change it together. Both, by mutual agreement. Only between very close teams; it is accepted coupling.
Open Host Service, OHS The upstream offers a public, stable protocol for anyone to consume, instead of bespoke integrations. Upstream defines the protocol. Contexts with many consumers.
Published Language, PL The exchange format is documented and versioned (JSON schema, event contract). It usually accompanies OHS. Upstream publishes; everyone understands it. Almost always, together with OHS or events.
Partnership Two contexts evolve together out of mutual need, coordinating plans. Neither; equals. Contexts of the same team or with a strong bidirectional dependency.
Separate Ways No relationship; if something were needed, it is duplicated. — When integrating costs more than not integrating.

Applied to TechCorp:

flowchart LR
    CAT[Catalog<br/>OHS + PL]
    CUS[Customers<br/>OHS + PL]
    ORD[Orders<br/>core]
    INV[Inventory]
    PAY[Payments]
    NOT[Notifications]
    KC[Keycloak<br/>external]
    PSP[Payment provider<br/>external]

    CAT -- "U: prices and names<br/>D: ACL 'productTranslator'" --> ORD
    CUS -- "U: existence, email, address<br/>D: Customer-Supplier" --> ORD
    ORD <-->|"Partnership<br/>events order.created / stock.reserved"| INV
    ORD -- "U: events (PL)<br/>D: Customer-Supplier" --> PAY
    PAY -- "U: payment.confirmed (PL)" --> ORD
    ORD -- "U: order.confirmed / cancelled (PL)<br/>D: Conformist" --> NOT
    KC -- "U: OIDC tokens<br/>D: Conformist" --> CUS
    PSP -- "U: proprietary API<br/>D: ACL" --> PAY

Reading it relationship by relationship (U = upstream, D = downstream):

  • Catalog → Orders: OHS + PL with an ACL in Orders. Catalog publishes an open contract (GET /products?ids=..., documented JSON format) for all consumers (Orders, the website, future services). Orders does not bring the "catalog listing" model into its domain: a small layer (productTranslator) converts { productId, name, price, description, attributes... } into the only thing Orders understands, an OrderLine with frozen name and price. That way, if Catalog changes its attributes, Orders does not notice.
  • Customers → Orders: Customer-Supplier. Orders needs to know that the customer exists and to obtain email and shipping address. It is a negotiated relationship: the Orders team can ask Customers to expose a GET /customers/{id} with exactly those fields.
  • Orders ↔ Inventory: Partnership. Both belong to Luis's team and evolve together: Orders publishes order.created, Inventory answers with stock.reserved (or its refusal). The contracts of those events are designed at the same table. Note that Partnership does not mean a shared database: it means shared planning.
  • Orders → Payments → Orders: Customer-Supplier over events, with PL. Payments consumes stock.reserved and publishes payment.confirmed (or its rejection). The published language is the schemas of those events.
  • Orders → Notifications: Conformist. Notifications accepts the Orders events as-is, without asking for changes: it is a generic subdomain and its job is to react. If order.confirmed carries email and name, it uses them; it has no customer model of its own to protect.
  • Keycloak → Customers: Conformist. Keycloak is external and standard (OpenID Connect); Customers adapts to its tokens and stores the Keycloak identifier (sub) alongside the customerId.
  • Payment provider → Payments: ACL. The payment provider has its own API, its own statuses and its own errors. payments-service wraps it in an adapter (services/paymentProvider.js, inherited from the monolith and now exclusive to it) that translates into Payments' concepts: AUTHORIZED, CAPTURED, REJECTED, REFUNDED. Switching payment provider only touches that layer.
  • Shared Kernel: none, by decision. The temptation would be to share "identifiers", "money" or "the event envelope" as a common domain library. TechCorp avoids it: identifiers are opaque strings and the event envelope format is published language (documented), not shared code. It is the same rule about shared utilities from 02-02.
  • Separate Ways: Catalog and Notifications, Inventory and Customers. They do not talk to each other. If Notifications ever needed a product's name for an email, it would receive it in the event rather than integrate with Catalog.

  1. Aggregates and aggregate roots: the transactional boundary

We go down one level. Within a context, the model is organized into aggregates: groups of objects treated as a unit for state changes. Each aggregate has a root (the only entity accessed from outside) and defines a consistency boundary: the invariants (rules that must always hold) are guaranteed inside the aggregate, in a single transaction; between aggregates, consistency is eventual.

Practical rules for designing aggregates:

  1. One transaction modifies a single aggregate. If you need to modify two, either the design is wrong or you have to accept eventual consistency between them (02-05).
  2. Aggregates reference each other by identifier, never by object: an Order stores customerId, not a Customer object.
  3. Small aggregates. A big aggregate locks a lot and scales badly.
  4. The root protects the invariants. Nobody modifies an order line "from outside"; you ask the Order to add it, and the Order checks that it can.

6.1 The Order aggregate

In the Orders context, the aggregate is Order (root) with its OrderLines (internal entities) and its ShippingAddress (value object, a copy). Its invariants:

  • The total is always the sum of unitPrice × quantity of its lines.
  • An order has at least one line and no line has quantity ≤ 0.
  • A CONFIRMED or CANCELLED order does not allow changes to its lines.
  • Status transitions follow the state machine we will see in 02-05.
// domain/Order.js  (Orders context) - illustrative skeleton of the aggregate, no persistence
class Order {
  #lines = [];                                   // only the root touches the lines
  constructor({ orderId, customerId, shippingAddress }) {
    this.orderId = orderId;                      // 'ord-88213'
    this.customerId = customerId;                // reference by id: NOT a Customer object
    this.shippingAddress = { ...shippingAddress }; // frozen copy (value object)
    this.status = 'PENDING';
  }

  addLine({ productId, name, unitPrice, quantity }) {
    if (this.status !== 'PENDING') throw new Error('ORDER_NOT_MODIFIABLE');
    if (quantity <= 0) throw new Error('INVALID_QUANTITY');
    // name and unitPrice arrive already translated from Catalog (ACL) and are frozen here
    this.#lines.push({ productId, name, unitPrice, quantity });
  }

  get total() {                                  // invariant: always derived from the lines
    return this.#lines.reduce((sum, l) => sum + l.unitPrice * l.quantity, 0);
  }

  get lines() { return this.#lines.map(l => ({ ...l })); }   // copy: nobody modifies from outside
}
module.exports = { Order };

What is not in this aggregate, and why:

  • No stock. The invariant "never reserve more than there is" (quantity - reserved >= 0) belongs to the Inventory context and to its aggregate (a Reservation associated with an orderId, or the Stock per product itself). Putting stock inside the Order would have three problems: (1) the Order would have to know an invariant that is not its own; (2) a stock row is touched by hundreds of concurrent orders, so the Order aggregate would "span" rows shared by all orders and the transactions would block each other (exactly what happened in createOrder with the payment provider inside the transaction); (3) stock changes for reasons unrelated to the order (warehouse receipts, shrinkage). That is why, in the target design, Orders asks for the reservation and the answer arrives as an event.
  • No Customer object and no Product object. Only customerId and productId, plus the value copies (address, name, price) the order needs to be self-sufficient.
  • No payment. Payment is an aggregate of another context; the Order only knows that its status moved to PAID when the event arrived.

And in the other contexts, the main aggregates:

Context Aggregate (root) Contains Main invariant
Orders Order Lines, shipping address, status, total Total = sum of lines; valid transitions.
Inventory Stock per product and Reservation Quantity, reserved; reservation: orderId, lines, status quantity - reserved >= 0; a reservation is not consumed twice.
Catalog Product (listing) Description, attributes, images, current price, published A published product has a name and a price.
Payments Payment orderId, amount, status, payment provider reference Never capture the same order twice.
Customers Customer Profile, addresses Unique email.
Notifications Delivery (minimal) Recipient, template, status Never resend the same notice.

  1. The data model of each context

Let us pin down what each context stores about the shared concepts. We are not yet talking about physical databases (02-04), but about which fields exist in each model.

7.1 "Product" in the three contexts

Field Catalog Inventory Orders (order_lines)
productId Yes (generates it) Yes (reference) Yes (reference)
name Yes (master) No Yes, frozen copy
description, images, category Yes No No
Variable attributes (voltage, color...) Yes (flexible document) No No
price Yes (current price) No Yes, frozen copy as unit_price
published / active Yes No No
quantity, reserved No Yes (master) No
location, restock_threshold No Yes No
Ordered quantity No In the reservation Yes

Example of the same product seen from each context (illustrative format; the physical schema comes in 02-04):

// Catalog: the listing (flexible document)
{ "productId": "p-501", "name": "BT X200 Headphones", "price": 59.90,
  "category": "audio", "published": true,
  "attributes": { "color": "black", "batteryHours": 30, "bluetooth": "5.3" },
  "images": ["x200-front.webp", "x200-side.webp"] }

// Inventory: the stockable reference
{ "productId": "p-501", "quantity": 120, "reserved": 7, "location": "A-03-2", "restockThreshold": 20 }

// Orders: the line of order ord-88213 (price on the day of purchase, even if it changes tomorrow)
{ "productId": "p-501", "name": "BT X200 Headphones", "unitPrice": 59.90, "quantity": 1 }

7.2 "Customer" in the contexts that use it

Field Customers Orders Notifications
customerId Yes (generates it) Yes (reference) Only if it comes in the event
email, name Yes (master) Does not store them as master; obtains them through the contract when creating the order and includes them in the events Receives them in each event; does not store them as master
Saved addresses[] Yes No No
The order's shippingAddress No Yes, frozen copy per order No
keycloakSub, preferences, sign-up date Yes No No

7.3 The complete order as its context sees it

{
  "orderId": "ord-88213",
  "customerId": "c-1024",
  "status": "PENDING",
  "shippingAddress": { "street": "Gran Vía 12", "postalCode": "28013", "city": "Madrid" },
  "lines": [
    { "productId": "p-501", "name": "BT X200 Headphones", "unitPrice": 59.90, "quantity": 1 },
    { "productId": "p-777", "name": "USB-C Cable 2 m",    "unitPrice": 9.90,  "quantity": 2 }
  ],
  "total": 79.70,
  "createdAt": "2026-08-15T10:42:00Z"
}

It is the same structure GET /orders/{id} returned in 01-01, now explained: everything inside belongs to the Orders context (references by id + value copies); nothing inside forces a query to another context in order to read the order.

Common Mistakes and Tips

  • Looking for the company-wide "canonical model" of product. That is the road back to the products + product_attributes table. Each context has its own product; they share an identifier, not a model.
  • Confusing a bounded context with a technical module. A "persistence context" or an "API context" are not bounded contexts: they have no business language of their own.
  • Huge aggregates. An Order that contains the Customer, the Products and the Stock "to have everything at hand" locks half the company on every transaction. References by id and value copies.
  • Copying data "just in case" rather than for semantic reasons. Orders copies name and price because the business says the order keeps the purchase price, not for performance. If you copy something that semantically must always be up to date (for example, the contact email), you will have stale data; there you should query or subscribe to changes (02-04).
  • Ignoring the ACL with external systems. Letting the payment provider's statuses leak into the Payments model (and from there into the events) makes switching payment provider a company-wide change.
  • Tip: keep a glossary per context (a table: term, meaning in this context, field in the code). When two glossaries disagree about a word, do not resolve it: it is a confirmed boundary.

Exercises

Exercise 1: Classifying subdomains and choosing a pattern

TechCorp is considering adding two capabilities: (a) product reviews by customers (stars and comments on the listing) and (b) billing (issuing legal invoices for each confirmed order). For each one: classify it as core, supporting or generic; indicate which existing contexts it would be downstream of and which relationship pattern you would use with each.

Exercise 2: Spotting the ambiguous word

In a meeting, the Inventory team says "product p-501 is not available" and the Catalog team replies "yes it is, I published it yesterday". Explain the ambiguity using this lesson's vocabulary, indicate how you would resolve it in the contracts (which field each context exposes) and what the website should show the customer in that case.

Exercise 3: Inside or outside the aggregate?

For each element, decide whether it is part of the Order aggregate, is its own aggregate within the Orders context, or belongs to another context, and justify using the invariants rule: (1) the discount coupon applied to an order; (2) the order's status change history; (3) the review the customer leaves about the order after receiving it; (4) the order's stock reservation.

Solutions

Exercise 1

(a) Reviews: a supporting subdomain (it contributes to the experience, but it is neither the sales core nor a generic problem). It would be downstream of Catalog (it needs to know the product exists: OHS + PL, consuming the public contract) and of Orders or Customers to verify that the reviewer bought the product (Customer-Supplier: it would ask for a GET /orders?customerId=...&productId=... or an order.delivered event). Its "product" model would be minimal: productId and name for display. It could live inside the Catalog context if it is very simple; as its own service if write volume or the team justify it. (b) Billing: a supporting subdomain with a strong generic component (tax rules are the same for everyone; many companies use a provider). Downstream of Orders (Conformist or Customer-Supplier over order.confirmed, from which it needs lines, amounts and taxes) and of Customers (tax details: Customer-Supplier, asking for a taxDetails field). If an external billing provider is used, an ACL in front of its API. It would never share tables with Orders even though "almost everything it needs is in orders".

Exercise 2

"Available" has two meanings: in Catalog, published (the listing is displayed); in Inventory, with free units (quantity - reserved > 0). Both are right within their context. In the contracts, each exposes its concept under its own name: Catalog, published: true/false; Inventory, available: 113 (free units) or hasStock: true/false. The website (or a BFF, 03-04) composes both: it shows the listing because it is published, and the "buy" button disabled with the text "out of stock" because Inventory says 0. What must not be done is adding a manually maintained stock field to the catalog listing: it is data from another context (if displayed, it is obtained through the contract or via event replication, 02-04).

Exercise 3

  1. Applied coupon: inside the Order aggregate as value data (discountApplied, code and amount), because it affects the total invariant; the coupon's definition (validity, conditions) belongs to another context (Promotions), referenced by code.
  2. Status history: it can be part of the aggregate (a list of transitions with dates) if it is small and is used to validate transitions; if it grows or only serves auditing, its own aggregate or simply an event log of the Orders context. In no case another context.
  3. Order review: outside the aggregate and probably outside the context: it affects no order invariant and has its own lifecycle (it arrives days later, it can be edited). Reference by orderId.
  4. Stock reservation: another context (Inventory), Reservation aggregate with orderId. Its invariant (quantity - reserved >= 0) does not belong to the Order, and putting it inside would make every order lock rows shared by all of them.

Conclusion

With strategic DDD we have turned TechCorp's six areas into six well-defined bounded contexts: we have classified their subdomains (Orders and Catalog as core; Inventory, Customers and Payments as supporting; Notifications and Identity as generic, the latter delegated to Keycloak), we have verified that "product", "customer", "status", "cancel" and "available" mean different things in each context and that this difference marks the seams, we have drawn the context map with its patterns (Catalog as OHS + PL with an ACL in Orders; Customers → Orders as Customer-Supplier; Orders ↔ Inventory as Partnership; Orders → Notifications as Conformist; ACL in front of the payment provider; no shared kernel by decision) and we have defined the Order aggregate with its lines and its frozen address, leaving out stock, customer and payment for reasons of invariants and contention. Finally, we have fixed which fields each context stores about the shared concepts: the catalog has the full listing; inventory, productId and quantities; orders, frozen copies of name and price in its lines.

All of this is still a model. The next lesson makes it physical: one database per service. We will see why the shared database is the main anti-pattern, how the cross foreign keys of the 01-05 schema (orders.customer_id, order_lines.product_id) are broken, what alternatives exist for the queries that today are a JOIN, how data is migrated without stopping the store, and what concrete schema each service will have, including the catalog in MongoDB.

Microservices Course

Module 1: Introduction to Microservices

Module 2: Microservice Design

Module 3: Communication between Microservices

Module 4: Implementing Microservices

Module 5: Deployment and Orchestration

Module 6: Monitoring and Maintenance

Module 7: Security in Microservices

Module 8: Case Studies and Practical Examples

© Copyright 2026. All rights reserved