We already know which contexts exist at TechCorp and what each one stores about "product" and "customer". What remains is the decision that is hardest to make and has the most consequences: physically separating the data. As long as the six services keep reading and writing the single techcorp database, there will be no real autonomy: every schema change will be a negotiation between teams, every heavy catalog query will block an orders migration, and the "microservice" will be a distributed monolith with more latency.

This lesson develops the database-per-service pattern: why the shared database is the main anti-pattern, what degrees of isolation exist (separate schemas, separate instances, different technologies), how the cross foreign keys of the 01-05 schema are broken, what to do with the queries that are a JOIN today, who owns each piece of data, how data is migrated without stopping the store and, as a result, what concrete schema each TechCorp service will have (SQL for orders-service and inventory-service, the catalog document in MongoDB). We close by previewing the question of reporting. Consistency between those separate databases (sagas, CQRS, event sourcing) is the subject of the next lesson.

Contents

  1. The database-per-service pattern and the shared-database anti-pattern
  2. Degrees of isolation: from separate schemas to polyglot persistence
  3. Breaking the cross foreign keys
  4. The queries that used to be a JOIN
  5. Data ownership and lifecycle
  6. Migrating data without stopping the store
  7. The resulting schema of each TechCorp service
  8. Implications for reporting and analytics

  1. The database-per-service pattern and the shared-database anti-pattern

The pattern is stated in one line: each service is the only one that accesses its storage; the others only reach that data through its API or its events. "Storage" includes tables, collections, indexes, private queues and files. It is the materialization of the "decentralized data" principle from 02-01 and of "the contract as the only door".

Why the shared database is the main anti-pattern (and not just one among many):

With a shared database Consequence TechCorp problem it reproduces
Any service can read any table. Everyone's schema is everyone's contract: nobody can change a column without auditing the others. Notifications broke an Orders report by changing a query.
Any service can write any table. A context's invariants are "protected" by code spread across several services. createOrder writes to stock and payments.
A schema migration blocks everyone. A service's deployment depends on the state of the others' DB. The order_lines migration failed because of a Catalog query.
A service with heavy queries degrades everyone's DB. There is no performance isolation. Black Friday: the catalog took down charges.
One technology for everyone. The data model is forced to fit the chosen DB. Key-value product_attributes in PostgreSQL.

In short: with the shared database, all the couplings from 02-01 (data, deployment, temporal) hold at once. That is why lesson 01-01 defined it as "what a microservice is not".

What you pay in return, and what the rest of the lesson manages: you lose JOINs across contexts, you lose cross foreign keys and you lose the single transaction.

  1. Degrees of isolation: from separate schemas to polyglot persistence

Separating data is not all or nothing. There is a ladder, and climbing it rung by rung is a legitimate migration strategy:

Rung What is separated What it isolates What it does not isolate Use at TechCorp
0. Private tables by convention Nothing physical; only the rule "don't touch foreign tables". Write coupling, if everyone respects it. Nothing technically: a JOIN is still possible. First step inside the monolith (02-02, preliminary refactoring).
1. Separate schemas in the same instance One PostgreSQL SCHEMA per service (orders.*, inventory.*), with different users and permissions that prevent reading foreign schemas. Data coupling and schema deployment coupling (each service migrates its own). Performance (same CPU, disk and connections), availability (same instance), backups. Intermediate step for customers, inventory, orders and payments during the migration.
2. Separate instances One database server (or cluster) per service. All of the above plus performance and availability. Nothing relevant; higher operating cost. Target for orders and inventory as soon as the migration progresses; in Kubernetes, a StatefulSet or a managed service per DB (05-02).
3. Different technologies (polyglot persistence) Each service chooses the most suitable type of store. On top of that, the data model is no longer forced. Increases operational variety: more things to know how to operate. Catalog in MongoDB; the rest in PostgreSQL.
flowchart LR
    subgraph TODAY[Today: one instance, one schema]
        M[(techcorp<br/>public.*)]
    end
    subgraph STEP1[Intermediate step: one instance, separate schemas and users]
        P1[(pg-techcorp)]
        P1 --- S1[schema orders<br/>user svc_orders]
        P1 --- S2[schema inventory<br/>user svc_inventory]
        P1 --- S3[schema customers<br/>user svc_customers]
        P1 --- S4[schema payments<br/>user svc_payments]
    end
    subgraph TARGET[Target: instances and technologies per service]
        O1[(PG orders)]
        O2[(PG inventory)]
        O3[(PG customers)]
        O4[(PG payments)]
        O5[(MongoDB catalog)]
    end
    TODAY --> STEP1 --> TARGET

Rung 1 is cheap and already removes the most serious problem (schema coupling). How it is done in PostgreSQL:

-- On the current instance: one schema and one user per service.
CREATE SCHEMA orders;
CREATE ROLE svc_orders LOGIN PASSWORD '...';
GRANT USAGE, CREATE ON SCHEMA orders TO svc_orders;
-- And, above all: the orders user has NO permissions on the other schemas.
REVOKE ALL ON SCHEMA public FROM svc_orders;
-- Repeat for inventory, customers and payments.

With this, the SELECT ... FROM customers line in createOrder fails at runtime if the orders service uses svc_orders. It is an effective way of turning the rule "don't touch foreign tables" from a convention into a technical restriction.

2.1 Why the catalog goes to MongoDB and the rest to PostgreSQL

Polyglot persistence is not "using varied technologies because you can"; it is choosing based on the data model and the access pattern:

Criterion Catalog Orders, Inventory, Payments, Customers
Shape of the data Documents with variable attributes per category (voltage, size, compatibility): today, the awkward key-value table product_attributes. Regular rows with clear internal relationships (order-lines, stock-reservations).
Access pattern Massive reads per listing and search with attribute filters; infrequent writes. Transactional writes with invariants (quantity - reserved >= 0, order total, never charge twice).
Need for transactions Low: publishing a listing is a single-document write. High: reserving several lines of an order must be atomic within the service.
Scaling Horizontal read scaling (replicas, indexes on attributes, even text search). Vertical or with read replicas; manageable volume (3,000 orders/day).
Decision MongoDB: one document per product with attributes as a subdocument; indexes by category and by frequent attributes. PostgreSQL: ACID transactions within each service, constraints, exact numeric types for money.

And a rule of prudence Marta imposes: at most two database technologies in the system. Each new technology means different backups, monitoring, knowledge and on-call rotations. MongoDB is justified by problem 5 from 01-05; a third technology would need an equally concrete problem.

  1. Breaking the cross foreign keys

Let us take the schema from 01-05 again and assign an owner to each table, marking the foreign keys that cross boundaries:

Table Owner Current foreign key Crosses a boundary? What happens to it
customers Customers
products, product_attributes Catalog product_attributes.product_id → products No (internal) Disappears: they become a document.
stock Inventory stock.product_id → products Yes The FK is dropped; product_id remains as an opaque reference.
orders Orders orders.customer_id → customers Yes The FK is dropped; customer_id remains as an opaque reference.
order_lines Orders order_lines.order_id → orders No (internal) Kept: it is the root-line relationship of the aggregate.
order_lines Orders order_lines.product_id → products Yes The FK is dropped; copied columns are added (product_name, unit_price).
payments Payments payments.order_id → orders Yes The FK is dropped; order_id remains as an opaque reference.
notifications Notifications customer_id (already without FK today) Same.

A cross foreign key is incompatible with database-per-service by definition: PostgreSQL cannot check that orders.customer_id exists in a table that lives in another database (or on another server). So it is replaced by a reference by identifier: a column with the other context's id, with no integrity constraint in the database.

What is lost and how it is compensated:

Guarantee of the FK How it is recovered without the FK
You cannot insert an order for a nonexistent customer. The service validates through the contract on creation: orders-service queries GET /customers/{id} (or checks its local replica) before accepting the order. It is what step 1 of createOrder does today, but via API.
You cannot delete a customer with orders. Replaced by a business policy: customers are not deleted, they are deactivated (soft delete) or anonymized; and customers-service publishes customer.deleted so that others react (section 5).
Cheap JOIN to get the customer's name. Genuinely lost; see section 4.
Implicit index and type consistency. Created explicitly: CREATE INDEX on customer_id; identifiers become opaque strings (c-1024, p-501, ord-88213), not SERIALs that only make sense within a single database.

The last point deserves emphasis: as soon as an identifier travels between services, it can no longer be a local auto-increment integer. orders-service cannot generate order_id = 42 and have payments-service store 42, because if one day a backup is restored or the table is partitioned, 42 stops being unique. TechCorp adopts text identifiers generated by the owning service (in this course, with a readable prefix: ord-, c-, p-, pay-, res-; in production, a UUID or ULID with that prefix).

  1. The queries that used to be a JOIN

TechCorp's paradigmatic case: the admin panel shows "today's orders with the customer's name and the product names". Today:

-- Monolith: a JOIN across three areas, trivial with a single DB
SELECT o.id, c.name AS customer, pr.name AS product, l.quantity, o.total
FROM orders o
JOIN customers c     ON c.id = o.customer_id
JOIN order_lines l   ON l.order_id = o.id
JOIN products pr     ON pr.id = l.product_id
WHERE o.created_at::date = CURRENT_DATE;

With customers in one database, products in MongoDB and orders in yet another, this JOIN does not exist. There are three alternatives, and choosing well depends on frequency, tolerance to slightly stale data, and who owns the data:

Alternative How it works Advantages Drawbacks When to use it
API composition Whoever needs the composite data queries each service and merges the results in memory. No data duplication; always up to date. Temporal coupling (N calls); latency; the "N+1" problem; impossible to paginate/filter by foreign fields. Infrequent queries or queries with few elements (a detail screen).
Data replication via events The consuming service keeps a local read-only copy of the fields it needs, updated when it receives events from the owner (customer.updated, product.updated). Fast local queries, no runtime dependency; you can filter and paginate. Eventual consistency (seconds of delay); you have to manage the subscription and the initial load. Data that is read a lot, changes little and tolerates delay: customer name, product name.
Materialized view / read model (CQRS) A component builds, from the events of several services, a table or index designed exclusively for that query. Complex, fast queries; does not load the source services. Another piece to maintain; eventual consistency. Dashboards, listings with cross filters, searches. Developed in 02-05.

In code, API composition looks like this (skeleton; the real HTTP client, with timeouts and retries, is built in 04-04):

// API composition for "order detail with customer name"
// Runs wherever the composite view is needed (for example, an admin-panel BFF, 03-04).
async function orderDetailWithCustomer(orderId) {
  const order    = await ordersApi.get(orderId);              // GET /orders/ord-88213
  const customer = await customersApi.get(order.customerId);  // GET /customers/c-1024
  return {
    ...order,
    customer: { name: customer.name, email: customer.email }  // only what the view needs
  };
}
// For a listing of 200 orders, this is 200 calls to Customers: the "N+1".
// That is where the replica or the materialized view belongs.

And the local replica, in the orders-service schema:

-- Read-only replica, inside the orders DB, maintained by Customers events.
-- Only the fields Orders needs for its listings. Never written from the Orders API.
CREATE TABLE customers_ref (
  customer_id  TEXT PRIMARY KEY,
  name         TEXT NOT NULL,
  email        TEXT NOT NULL,
  updated_at   TIMESTAMPTZ NOT NULL      -- date of the event that updated it
);

TechCorp's decision for the order flow:

  • When creating an order, orders-service queries Catalog (current price and name) and Customers (existence, email, address) by composition: they are two calls, the data has to be current and the order cannot be created without them. It is the deliberate temporal coupling we pointed out in 02-02.
  • To list orders with the customer name, orders-service uses its customers_ref replica.
  • For the admin panel with cross filters, a materialized view fed by events (02-05).

Notice that the frozen copies in order_lines (product_name, unit_price) are not a replica: they are not updated when the catalog changes. They are order data. customers_ref is a replica: if the customer changes their name, it must reflect it.

  1. Data ownership and lifecycle

With the data separated, three questions have to be answered, piece of data by piece of data: who writes, who reads and who decides when it disappears.

Data Writes (single) Reads How the others read it Lifecycle
Product listing Catalog Everyone GET /products, product.updated event Catalog unpublishes it; it is never deleted if orders reference it (orders keep their copy).
Stock and reservations Inventory Orders (indirectly) stock.reserved / stock.released events; GET /stock/{productId} for the website Reservations expire or are consumed; Inventory decides.
Order and lines Orders Payments, Notifications, admin panel order.* events; GET /orders/{id} Legal retention (years); Orders decides.
Payment Payments Orders payment.confirmed event; GET /payments?orderId= Legal retention; card data is never stored (the payment provider has it).
Customer Customers Orders, Notifications GET /customers/{id}; customer.updated / customer.deleted events Deactivation or anonymization on request (GDPR).
Frozen name/price copy in lines Orders Orders Lives and dies with the order.
customers_ref replica Orders, only from the event consumer Orders Updated or deleted when Customers events are received.

Two cases the table resolves that were never even raised in the monolith:

  • The replica has two apparent "writers": the Orders API and the Orders event consumer. Only the second one writes customers_ref; it is advisable that the API's database user has no write permission on that table, or that the code makes it impossible by construction.
  • The right to be forgotten (GDPR): when a customer asks to be deleted, today a DELETE is enough (and it would fail because of the orders FKs). Tomorrow, customers-service anonymizes their row and publishes customer.deleted; orders-service deletes the customers_ref row and anonymizes the shipping address of the old orders the law forces it to keep; notifications-service has nothing left to delete because it never stored the customer. The absence of an FK forces this flow to be designed explicitly, which is an advantage: in the monolith it was implicit and badly solved.

  1. Migrating data without stopping the store

Separating the schema is one thing; moving the data that already exists (thousands of orders, the whole catalog) while the store keeps selling is another. The standard procedure, applied to the catalog extraction:

flowchart TB
    A[1. Create the new store<br/>MongoDB catalog, empty] --> B[2. Initial load<br/>script: products + product_attributes → documents]
    B --> C[3. Continuous synchronization<br/>changes in the monolith → events → MongoDB]
    C --> D[4. Reads to the new store<br/>feature flag REMOTE_CATALOG=true, by percentage]
    D --> E{Same data?<br/>shadow comparison}
    E -- no --> C
    E -- yes --> F[5. Writes to the new store<br/>the monolith stops writing products]
    F --> G[6. Retire the old table<br/>after a grace period]

The delicate points:

  • Step 3, synchronization. While the two copies coexist, the old one keeps receiving writes (the catalog team publishes listings daily). There are two ways to propagate them: have the monolith publish an event for every change (product.updated) that the new service consumes, or change data capture (CDC) reading PostgreSQL's transaction log with tools such as Debezium. TechCorp chooses events published from the monolith, because those same events will be needed later.
  • The dual write risk. The temptation is for the monolith to write directly to both databases ("I update the table and the document in the same function"). It is a classic mistake: there is no transaction spanning PostgreSQL and MongoDB, so on any failure between the two writes (crash, timeout, exception) the copies diverge without anyone noticing. There must be a single write (to the source of truth) and the propagation must be asynchronous and retryable (an event or an outbox, which we will see in 02-05).
  • Step 4, shadow comparison. Before trusting the new store, for a while reads are executed against both and the results are compared in a log. Discrepancies reveal load-script errors or lost events.
  • The feature flag. REMOTE_CATALOG is the branch by abstraction switch from 02-02: it lets you move 1%, 10%, 100% of reads to the new service and roll back in seconds.
  • Step 6, no rush. The old table is kept read-only for a few weeks. Deleting it on cutover day is the fastest way to discover that a monthly report was using it.

A fragment of the initial load script, illustrative:

// scripts/migrate-catalog.js  (runs once; illustrative)
// Reads products + product_attributes from the monolith and creates one document per product.
async function migrateCatalog(pg, mongo) {
  const { rows: products } = await pg.query('SELECT id, name, price, category, active FROM products');
  for (const p of products) {
    const { rows: attributes } = await pg.query(
      'SELECT key, value FROM product_attributes WHERE product_id = $1', [p.id]);
    await mongo.collection('products').updateOne(
      { productId: `p-${p.id}` },                                     // opaque id with prefix
      { $set: {
          productId: `p-${p.id}`, name: p.name, price: Number(p.price),
          category: p.category, published: p.active,
          attributes: Object.fromEntries(attributes.map(a => [a.key, a.value])),  // key-value -> subdocument
          migratedAt: new Date()
      } },
      { upsert: true });                                              // idempotent: can be re-run
  }
}

Note upsert: true: the script can be run several times without duplicating anything. It is the first practical appearance of the idempotency the next lesson turns into a rule.

  1. The resulting schema of each TechCorp service

With all of the above, this is how the stores end up. We only show in full the two that will play the biggest role in the saga; the rest, summarized.

7.1 orders-service (PostgreSQL, database orders)

-- Database: orders. User: svc_orders. Nobody else connects.
CREATE TABLE orders (
  order_id         TEXT PRIMARY KEY,                -- 'ord-88213', generated by this service
  customer_id      TEXT NOT NULL,                   -- opaque reference to Customers: NO foreign key
  status           TEXT NOT NULL CHECK (status IN ('PENDING','STOCK_RESERVED','PAID','CONFIRMED','CANCELLED')),
  total            NUMERIC(10,2) NOT NULL,
  shipping_address JSONB NOT NULL,                  -- frozen copy of the address at the time of the order
  cancellation_reason TEXT,                         -- 'OUT_OF_STOCK', 'PAYMENT_REJECTED', ... (filled in 02-05)
  created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_orders_customer ON orders (customer_id);        -- explicit: the FK no longer provides it

CREATE TABLE order_lines (
  order_id         TEXT NOT NULL REFERENCES orders(order_id),   -- INTERNAL FK: root -> line of the aggregate
  line             INT  NOT NULL,
  product_id       TEXT NOT NULL,                   -- opaque reference to Catalog: NO foreign key
  product_name     TEXT NOT NULL,                   -- frozen copy
  unit_price       NUMERIC(10,2) NOT NULL,          -- frozen copy
  quantity         INT  NOT NULL CHECK (quantity > 0),
  PRIMARY KEY (order_id, line)
);

-- Read-only replica of Customers (section 4), fed by events
CREATE TABLE customers_ref (
  customer_id  TEXT PRIMARY KEY,
  name         TEXT NOT NULL,
  email        TEXT NOT NULL,
  updated_at   TIMESTAMPTZ NOT NULL
);

Compare it with the orders / order_lines from 01-05: the FKs to customers and products are gone, the frozen copies and the address have appeared, the identifiers are text, and there are two new statuses (STOCK_RESERVED, PAID) that the saga in 02-05 will explain. In 02-05 one more table will be added to this database: outbox.

7.2 inventory-service (PostgreSQL, database inventory)

-- Database: inventory. User: svc_inventory.
CREATE TABLE stock (
  product_id    TEXT PRIMARY KEY,                    -- opaque reference to Catalog: NO foreign key
  quantity      INT NOT NULL CHECK (quantity >= 0),
  reserved      INT NOT NULL DEFAULT 0 CHECK (reserved >= 0),
  location      TEXT,
  restock_threshold INT NOT NULL DEFAULT 0,
  CHECK (reserved <= quantity)                       -- the context's invariant, protected by the DB
);

CREATE TABLE reservations (
  reservation_id TEXT PRIMARY KEY,                   -- 'res-40021'
  order_id       TEXT NOT NULL UNIQUE,               -- one reservation per order: the basis of idempotency (02-05)
  status         TEXT NOT NULL CHECK (status IN ('ACTIVE','CONSUMED','RELEASED')),
  created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at     TIMESTAMPTZ                         -- unconfirmed reservations expire
);

CREATE TABLE reservation_lines (
  reservation_id TEXT NOT NULL REFERENCES reservations(reservation_id),  -- internal FK
  product_id     TEXT NOT NULL,
  quantity       INT  NOT NULL CHECK (quantity > 0),
  PRIMARY KEY (reservation_id, product_id)
);

The important part: the invariant reserved <= quantity lives here and only here, as a database constraint. In the monolith, it was protected by the WHERE quantity - reserved >= $1 clause written in createOrder, that is, in the code of another context.

7.3 catalog-service (MongoDB, database catalog, collection products)

{
  "_id": "p-501",
  "productId": "p-501",
  "name": "BT X200 Headphones",
  "description": "Wireless headphones with noise cancellation...",
  "category": "audio",
  "price": 59.90,
  "published": true,
  "attributes": { "color": "black", "batteryHours": 30, "bluetooth": "5.3", "noiseCancelling": true },
  "images": ["x200-front.webp", "x200-side.webp"],
  "updatedAt": "2026-08-14T09:12:00Z"
}

The key-value table product_attributes (problem 5 from 01-05) disappears: each product carries the attributes that belong to it, and an index on category plus selective indexes on frequent attributes (attributes.color) solve filtered search. There is no reference to stock or orders.

7.4 The rest, one line each

  • payments-service (PostgreSQL payments): table payments (payment_id, order_id without FK, amount, status AUTHORIZED/CAPTURED/REJECTED/REFUNDED, provider_reference, created_at), with UNIQUE (order_id) so as never to charge twice.
  • customers-service (PostgreSQL customers): customers (customer_id, keycloak_sub, email UNIQUE, name, active) and addresses (address_id, customer_id internal FK, street, postal_code, city, is_default).
  • notifications-service: minimal log deliveries (delivery_id, order_id, type, recipient, sent_at, status) to avoid resending; it can be a small table or even the queue's own log.

  1. Implications for reporting and analytics

One question remains that Marta will ask as soon as she sees the design: "what about the sales report by category and city?". Today it is a four-table JOIN. Tomorrow the data lives in five databases, one of them MongoDB.

The architecture's answer is that analytics is not run against the services' operational databases, neither before nor after microservices (doing so was, in fact, one of the causes of the locks in problem 3). A separate read database is built (an analytical store or data warehouse) fed by the events the services already publish (order.confirmed with lines and address, product.updated with category) or by periodic exports. It is the same idea as the materialized view in section 4, taken to company scale: eventual consistency (minutes or hours of delay are acceptable for a report) in exchange for not touching the production services. The mechanics are developed in 02-05 (CQRS) and we will not return to it except in passing.

Common Mistakes and Tips

  • Separating the services and leaving the shared database "for now". That "now" lasts years. At the very least, separate schemas and users from the first extracted service.
  • Keeping the cross FKs "because they are free". They are not: they are the reason the catalog cannot move to MongoDB nor a service migrate its schema without coordinating.
  • Replicating the other service's whole model. The replica carries the fields you need, not the whole table. customers_ref has three columns, not twelve.
  • Confusing a frozen copy with a replica. The price of an order line must not be updated; the customer name in customers_ref must. Document which is which.
  • Doing dual write during the migration. One write, to the source of truth; the copy is propagated by events and verified in shadow mode.
  • Using SERIAL as an identifier that travels between services. Opaque identifiers, generated by the owner, globally unique.
  • Tip: for every monolith query that today JOINs across areas, note in a table: frequency, tolerance to delay, foreign fields it needs, and decide composition / replica / materialized view. That table is your data plan.

Exercises

Exercise 1: Classifying queries

For each monolith query, indicate the most suitable alternative (API composition, replication via events or materialized view) and justify in one line: (1) the order detail page for the customer, showing the customer's name and the lines; (2) the admin panel listing "today's orders" with customer name, paginated and filterable by city; (3) the confirmation email, which needs the customer's email and the product names; (4) the monthly sales report by product category.

Exercise 2: The missing FK

payments.order_id loses its foreign key to orders. Explain which guarantees are lost and how payments-service recovers them by design (think about: how it knows the order exists, what prevents two payments for the same order, and what happens if a stock.reserved arrives for an order Payments has never seen).

Exercise 3: A schema for Customers

Write the SQL for the customers database of customers-service (tables customers and addresses) following this lesson's conventions (opaque identifiers, no cross FKs, explicit internal constraints), and indicate which event the service should publish when a customer changes their email and which services would consume it.

Solutions

Exercise 1

  1. Composition (or even nothing: the order already stores the lines with frozen name and price; for the customer name, a call to GET /customers/{id} or the customers_ref replica). It is a single-element query and must show the current status.
  2. Replica (customers_ref) for the name, and probably a materialized view if you filter by shipping-address city and combine with data from other contexts: pagination and cross filters do not work with composition (N+1).
  3. None of the three in Notifications: the data travels in the event order.confirmed (email, name, lines with product name). Notifications neither queries nor replicates; it is conformist (02-03).
  4. Materialized view / analytical store fed by events: an aggregate query, tolerant to delay, that must not load the production services (section 8).

Exercise 2

What is lost: (a) the guarantee that the order exists; (b) the deletion lock; (c) the JOIN. How Payments recovers them: (a) Payments does not create payments on its own initiative: it reacts to stock.reserved, which carries orderId and amount, and that event only exists if Orders created the order; optionally it validates with GET /orders/{id} when in doubt. (b) The UNIQUE (order_id) constraint on its own payments table prevents two charges for the same order even if the event arrives duplicated (idempotency, 02-05). (c) If a stock.reserved arrives for an order unknown to Payments, that is the normal situation: Payments does not need to have "seen" the order before; the event is the only input. What it must do is reject malformed events or events with an invalid amount and log the case. And if an order is voided, it is not deleted: Payments will receive order.cancelled and will refund if it had charged.

Exercise 3

CREATE TABLE customers (
  customer_id   TEXT PRIMARY KEY,                 -- 'c-1024', generated by this service
  keycloak_sub  TEXT NOT NULL UNIQUE,             -- opaque reference to the user in Keycloak (external)
  email         TEXT NOT NULL UNIQUE,             -- context invariant: unique email
  name          TEXT NOT NULL,
  active        BOOLEAN NOT NULL DEFAULT TRUE,    -- soft delete instead of DELETE
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE addresses (
  address_id     TEXT PRIMARY KEY,
  customer_id    TEXT NOT NULL REFERENCES customers(customer_id),  -- internal FK to the Customer aggregate
  street TEXT NOT NULL, postal_code TEXT NOT NULL, city TEXT NOT NULL,
  is_default     BOOLEAN NOT NULL DEFAULT FALSE
);

Event: customer.updated with { customerId, email, name, updatedAt }. It would be consumed by orders-service to update customers_ref (replica). notifications-service does not need it (it receives the email in every order event) and neither does payments-service (it knows nothing about customers). An additional customer.deleted would trigger the anonymization described in section 5.

Conclusion

We have taken the physical step that truly separates the services: one database per service. We have seen why the shared database concentrates every kind of coupling, the isolation ladder (separate schemas and users as an intermediate step, separate instances as the target, MongoDB for the catalog and PostgreSQL for the rest based on their data model and access pattern, with the two-technology limit), how the cross foreign keys are broken (orders.customer_id, order_lines.product_id, stock.product_id, payments.order_id become opaque text references, with validation through the contract and identifiers generated by the owner), what to do with the lost JOINs (API composition for the detail, the customers_ref replica fed by events for listings, a materialized view for the admin panel), who writes each piece of data and how its lifecycle is managed (including GDPR deletion), how to migrate without dual write (idempotent initial load, synchronization by events, shadow reads, feature flag) and the resulting schema of orders-service, inventory-service and catalog-service.

That schema deliberately leaves one question open: orders.status allows STOCK_RESERVED and PAID, and reservations has expires_at. They are the footprints of what no longer exists: the single transaction of createOrder. The next lesson replaces it with a saga, with its compensations, its state machine, the outbox pattern that guarantees events go out if and only if the change was saved, consumer idempotency, and CQRS read views; and it will decide whether TechCorp needs event sourcing (spoiler: for now, no).

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