Don't Start with Microservices: Decompose the Database Write Path First
A monolith does not reach its write ceiling because it has too few services. It reaches the ceiling because too many business domains still require one database to serialize their writes, enforce their constraints, and absorb every failure.
Situation
RetailCo began with one application and one relational database. That was the correct architecture when the company was small. Checkout could create an order, decrement inventory, record a payment attempt, and schedule fulfillment inside one transaction. Foreign keys caught broken references. Unique constraints stopped obvious duplicates. One backup contained the operational state of the company.
RetailCo is a reference architecture, not a claimed customer deployment. Its brands, tenant count, workload, domain boundaries, and migration below are illustrative until replaced by an observed system inventory and lab evidence.
Growth changed the workload without changing the ownership model. Four brands now share the same platform. Thousands of stores and tenants create orders, reserve inventory, capture payments, update shipments, and maintain customer profiles. Read replicas absorbed reporting and customer-history traffic, but every mutation still converges on one writer.
flowchart TD
Web[web and mobile clients] --> App[RetailCo monolith]
Stores[store systems] --> App
Jobs[batch and support jobs] --> App
App --> DB[(shared relational writer)]
DB --> Customer[customer tables]
DB --> Orders[order tables]
DB --> Inventory[inventory tables]
DB --> Payment[payment tables]
DB --> Shipping[shipping tables]
DB --> Replicas[read replicas]
The database is not merely large. It is the meeting point for unrelated write pressure:
- a promotion produces a burst of order inserts;
- warehouse reconciliation updates inventory reservations;
- a payment provider sends delayed callbacks;
- shipment tracking produces frequent status changes;
- customer and support tools correct operational data;
- schema migrations and backfills compete with all of the above.
The single-writer ceiling explains why replicas do not remove this bottleneck. Sharding can distribute tenants, as the Citus and MySQL designs show. But sharding the whole schema preserves the same domain coupling inside every shard. RetailCo still cannot scale or recover payments independently from inventory, and every tenant move carries all domains together.
The architectural question is therefore not “How many microservices should RetailCo create?” It is which business facts must remain in one ACID boundary, which can become separate write domains, and how can ownership move without corrupting live orders?
The Problem
Splitting the application first creates an attractive diagram and a dangerous database.
Suppose RetailCo deploys separate Order, Inventory, Payment, and Shipping services while all four retain credentials to the shared schema. The deployment units changed, but the write topology did not. A Payment service release can still alter an order column. Inventory still joins customer and catalog tables. A transaction opened by one service can block another. One database incident still stops the company.
That is a distributed application around a centralized ownership model. It adds network failure, versioned APIs, more deployment pipelines, and harder tracing without adding another independent write domain.
The opposite shortcut is equally dangerous: give every proposed service a database immediately. The old checkout transaction then becomes four local commits:
create order
reserve inventory
authorize payment
request fulfillment
There is no atomic commit across those databases. Inventory can be reserved after payment is declined. Payment can succeed while the order response times out. A retry can execute a step twice. A customer can cancel while fulfillment is already moving forward. The database constraints that once made these states impossible no longer cross the new boundary.
Three distinctions decide whether decomposition improves the system.
A table boundary is not necessarily a business boundary
orders, order_items, and order_status_history probably belong together. A payment attempt references an order, but its core invariants concern authorization, capture, refund, provider idempotency, and ledger state. Inventory concerns stock positions and reservations. A foreign key shows a relationship; it does not prove that both sides need one owner or one scaling policy.
Data ownership means one writer
An ownership label in a diagram is insufficient if old code, support scripts, and batch jobs can still update the table. During the transition RetailCo needs a source-of-truth matrix and database privileges that answer two questions for every business fact:
- Which component is authorized to change it?
- Which database is authoritative in this migration phase?
Multiple readers are normal. Multiple uncoordinated writers are a correctness defect.
Compensation is not rollback
A relational rollback removes uncommitted changes. A distributed compensation creates new committed business facts. Releasing an inventory reservation does not erase the reservation history. Refunding a captured payment does not make the capture disappear. It creates a refund that can itself fail, retry, or require review.
AWS describes a saga as a sequence of local transactions with forward recovery and compensating transactions when later steps fail (saga guidance). That is a useful workflow model, but it is not a substitute for deciding where strong consistency is mandatory.
Decompose Ownership Before Deployments
The recommended target is a modular monolith with selected domain-owned databases, not an immediate fleet of services. RetailCo keeps one deployable application while creating enforceable module boundaries, separate connection pools, and one writer for each extracted domain.
flowchart TD
Clients[RetailCo clients] --> App[modular monolith — one deployment]
App --> OrderModule[order module — command owner]
App --> InventoryModule[inventory module — command owner]
App --> PaymentModule[payment module — command owner]
App --> ShippingModule[shipping module — command owner]
OrderModule --> OrderDB[(orders database)]
InventoryModule --> InventoryDB[(inventory database)]
PaymentModule --> PaymentDB[(payments database)]
ShippingModule --> ShippingDB[(shipping database)]
OrderDB --> Events[event transport]
InventoryDB --> Events
PaymentDB --> Events
ShippingDB --> Events
Events --> Views[local projections and read models]
This creates four independent write domains without requiring four independent runtime deployments on day one. Inventory can scale on its own database. Payment recovery can have a smaller security and blast-radius boundary. Order schema changes no longer require every domain to share the same migration window. If a module later needs independent deployment, its persistence contract already exists.
The separation should be selective. Customer preferences may not justify another database. Product catalog data may remain a replicated read model. Order and order-item state should stay together if nearly every command changes both. The goal is not database-per-table or database-per-class. The goal is a small number of cohesive write domains.
Start with an actor and invariant map
Before moving data, build an inventory from database evidence rather than architecture memory:
| Artifact | Question it answers |
|---|---|
| Actor-to-table CRUD matrix | Which applications, jobs, users, and integrations read or write each table? |
| Transaction trace | Which statements actually commit together under production-shaped workflows? |
| Constraint inventory | Which foreign keys, unique indexes, checks, triggers, and procedures enforce correctness? |
| Query and join map | Which data must be read together, at what latency and freshness? |
| Lock and resource profile | Which domain creates CPU, WAL or binlog, I/O, lock, and connection pressure? |
| Recovery inventory | Which facts must be restored together to preserve a valid business state? |
AWS’s current database-decomposition guidance similarly begins with actor analysis, CRUD activity analysis, dependency mapping, and consistency requirements (scope guidance). Schema shape alone cannot reveal a nightly writer, a support tool, or a business invariant implemented in application code.
For RetailCo, a first ownership matrix might look like this:
| Domain | Authoritative facts | Local atomic boundary | Cross-domain references |
|---|---|---|---|
| Order | order identity, items, totals, order state | Create order and items; legal order transition | customer ID, reservation ID, payment ID |
| Inventory | stock position, reservation, release | Reserve or release units for one stock policy | order ID, product ID, location ID |
| Payment | intent, attempt, authorization, capture, refund | Persist one idempotent money transition | order ID, customer payment reference |
| Shipping | fulfillment request, package, tracking state | Create and advance one fulfillment | order ID, address snapshot |
| Customer | identity, preferences, consent | Change customer-owned attributes | stable customer ID |
The word snapshot matters. Shipping should not call Customer during every label operation to rediscover the address used when the order was accepted. The order or fulfillment boundary should store the address version required for that business fact, subject to the company’s privacy and correction rules.
Enforce ownership inside the monolith
Create one public command interface per domain. Other modules may call reserve_inventory, but they cannot update inventory tables directly. Give the module a database role that can write its schema, and remove those grants from general application, batch, and support roles. Log rejected legacy access so hidden writers become visible.
The code may still run in one process, but the boundary is real because:
- only the owner can issue mutations;
- callers use commands with idempotency keys;
- local constraints remain inside the owning database;
- changes leave through a versioned event contract;
- read copies have declared freshness and provenance.
Shopify publicly documented choosing a modular monolith to strengthen domain boundaries without multiplying deployment units (Deconstructing the Monolith). Its later account describes the practical value of explicit stewardship while also acknowledging that strong boundaries in legacy code take sustained work (Under Deconstruction). The relevant lesson is not to copy Shopify’s code structure. It is that deployment topology and domain modularity can change on different schedules.
Use a local transaction and an outbox
When Order accepts a command, it must not commit the order and then make a best-effort publish. A process crash between those actions creates a valid order that Inventory never sees. Publishing first creates the opposite failure: downstream work for an order that rolled back.
Write the domain change and an outbox record in the same local transaction:
BEGIN;
INSERT INTO orders (
order_id, tenant_id, state, state_version, total_amount
) VALUES (
:order_id, :tenant_id, 'pending_inventory', 1, :total_amount
);
INSERT INTO order_outbox (
event_id, aggregate_type, aggregate_id, event_type,
aggregate_version, payload, occurred_at
) VALUES (
:event_id, 'order', :order_id, 'OrderPlaced',
1, :payload, CURRENT_TIMESTAMP
);
COMMIT;
An outbox relay or CDC connector publishes committed outbox rows. AWS documents the pattern as a way to avoid the inconsistent dual write between a database and a message broker, while warning that consumers must handle duplicates and ordering (transactional outbox guidance). Debezium’s outbox router emits an event ID that consumers can use for deduplication and uses the aggregate ID as the message key, which helps preserve aggregate order within a partition (Debezium outbox router).
The guarantee is intentionally narrow:
The order state and the intent to publish
OrderPlacedcommit together.
It does not guarantee that the broker, every consumer, and every consumer database commit exactly once. Debezium documents that recovery can re-emit changes after a fault; consumers should expect duplicates and use source metadata or event identity to converge (PostgreSQL connector failure behavior). Every handler therefore needs a processed-event record or a business idempotency constraint.
Turn checkout into an explicit workflow
Once Order, Inventory, and Payment have separate databases, checkout becomes a persisted state machine. The order is not immediately complete; it moves through states that expose uncertainty.
flowchart TD
Start[checkout command — idempotency key] --> OrderTx[order transaction — pending inventory and outbox]
OrderTx --> Reserve[inventory command — reserve once]
Reserve -->|reserved| Payment[payment command — authorize once]
Reserve -->|rejected| Reject[order transition — rejected]
Payment -->|authorized| Confirm[order transition — confirmed]
Payment -->|declined| Release[inventory command — release once]
Payment -->|unknown| Review[order transition — payment uncertain]
Release --> Reject
Confirm --> Fulfill[shipping command — create fulfillment]
Review --> Reconcile[payment reconciliation — query provider facts]
Each arrow is a durable command or event, not an in-memory callback. Each local database records its command identity and result. The workflow knows which steps completed, which can retry, and which require compensation or manual review.
For the first extraction, central orchestration is usually easier to operate than a long chain of anonymous event reactions. An order workflow record can contain the current step, version, deadline, last command ID, retry policy, and failure class. Choreography may remain useful for non-critical reactions such as analytics or notification, but the money-and-inventory path benefits from one visible coordinator.
Do not automatically compensate every timeout. A timeout after payment authorization is an ambiguous outcome, not proof of failure. The workflow should reconcile by idempotency key or provider transaction ID before retrying or releasing inventory. This is why payment_uncertain is a real state, not an exception to hide.
Replace cross-database joins deliberately
Separating writers does not eliminate cross-domain reads. The order-history page still needs fulfillment and payment summaries. Store operations need product, price, and stock. Support needs a coherent customer timeline.
Choose the read path by freshness and failure semantics:
flowchart TD
Request[read request] --> Choice{freshness and fanout requirement}
Choice -->|fresh owner fact| API[owner query or API composition]
Choice -->|fast operational view| Projection[local projection — event maintained]
Choice -->|wide historical analysis| Warehouse[warehouse or lakehouse]
API --> Response[response with partial failure policy]
Projection --> Response
Warehouse --> Analytics[analytical result]
- Use owner queries or API composition when current data matters and the number of dependencies is bounded.
- Use a local projection for high-volume screens that tolerate named lag.
- Use a warehouse or search index for broad analytical access.
- Store stable identifiers across domains, but do not pretend they are cross-database foreign keys.
AWS’s decomposition guidance lists denormalization, reference-by-key, CQRS-style read models, and event-based synchronization as alternatives to direct cross-database joins (decoupling table relationships). Every copied field needs an owner, update contract, freshness objective, rebuild path, and behavior when its feed is unavailable.
Move one domain through a controlled ownership state machine
RetailCo should extract one high-value domain, not all five at once. Inventory is a plausible first candidate when it causes independent contention and has a clear reservation API. Payment may deserve earlier isolation when security and blast radius dominate. The actual choice comes from the actor, invariant, lock, and recovery maps.
flowchart TD
Map[map actors writes constraints and invariants] --> Owner[route all legacy mutations through owner module]
Owner --> Enforce[remove direct database write grants]
Enforce --> Target[create target database and schema]
Target --> Backfill[backfill in bounded restartable chunks]
Backfill --> CDC[apply committed changes through CDC]
CDC --> Compare[shadow reads and reconcile business invariants]
Compare --> Gate{cutover gates pass}
Gate -->|no| Repair[repair mapping lag or data]
Repair --> Compare
Gate -->|yes| Fence[fence old-domain writes]
Fence --> Drain[drain CDC and record final source position]
Drain --> Flip[make target the authoritative writer]
Flip --> Remove[remove legacy reads writes and schema]
The target is only a candidate copy until the ownership flip. Before target-only writes, RetailCo can route back to the source if the source remains authoritative. After target-only writes, “rollback” requires proven reverse propagation or another migration. Redeploying the old application is not data rollback.
This workflow intentionally reuses the repository’s detailed database cutover protocol. The additional rule for decomposition is that cutover must move command ownership, not only rows. The old module, scheduled jobs, support scripts, and direct credentials all lose mutation authority together.
In Practice
This article describes a reference architecture, not a claimed RetailCo production result. The RetailCo workload, table boundaries, and migration sequence are proposed. No throughput gain, migration duration, event-lag objective, defect rate, RPO, or RTO is presented as measured evidence.
What the documented patterns establish
Context. Shopify chose a modular monolith because it wanted stronger domain boundaries without immediately taking on the network, deployment, and data-access costs of many services. Its follow-up described a long-running effort in which ownership became useful before every boundary became strong.
Action. RetailCo first assigns modules and data owners inside one deployment. Database roles and public command interfaces turn those labels into controls. Independent deployment becomes an option after persistence boundaries and operational ownership work.
Result. The derived architectural result is that write ownership can be separated from service deployment. RetailCo can create an independently scalable inventory database without also requiring a separately deployed Inventory service in the same release.
Learning. Microservices are one possible destination, not the first unit of decomposition.
Context. AWS’s transactional-outbox guidance documents the failure in updating a database and publishing a notification as two separate actions. Debezium documents how a connector can capture an outbox table and route messages by aggregate, and that fault recovery can produce duplicates.
Action. Commit each domain mutation with its outbox row, publish from the commit log, key messages by aggregate, and make consumers idempotent by event ID and business command ID.
Result. The derived guarantee is atomic local intent and replayable propagation. A connector outage delays downstream work instead of losing the committed intent; duplicate delivery converges instead of repeating the business action.
Learning. Outbox and CDC close one dual-write gap. They do not create an end-to-end exactly-once transaction.
Context. PostgreSQL logical replication identifies changed rows through replica identity, normally a primary key. Current documentation states that published UPDATE and DELETE operations require a suitable replica identity; DDL is a separate concern (PostgreSQL publication documentation).
Action. Before relying on PostgreSQL CDC for an extraction, inventory keys, publications, schema changes, WAL retention, replication slots, connector offsets, and target apply behavior. MySQL-based migrations need the equivalent binary-log and primary-key review.
Result. The migration treats CDC as an operated data product with lag, retention, schema, replay, and recovery constraints—not as an invisible pipe.
Learning. A decomposition plan that says only “use CDC” has not specified the safety mechanism.
Proposed lab — no empirical results
Use the RetailCo order, inventory, and payment schema to compare the current shared transaction with the first extracted-domain design.
Baseline
- one application deployment;
- one relational writer;
- local order, inventory, and payment tables;
- checkout transaction boundaries captured from the existing implementation.
Candidate
- one modular application deployment;
- Order and Inventory on separate databases;
- local outbox in each write domain;
- CDC-backed event transport;
- persisted order workflow;
- idempotent inventory reservation and release commands;
- local order-history projection.
Capture request throughput, commit latency, P50, P95, P99, database CPU, I/O, WAL or binlog rate, lock waits, connection utilization, outbox age, CDC lag, command retries, duplicate deliveries, reconciliation differences, and workflow age by state. Report shared-writer and candidate results separately. Do not attribute an improvement to decomposition unless the same workload, dataset, durability, and failure policy were used.
The failure lab matters more than a clean throughput chart:
- stop the CDC connector while orders continue;
- redeliver an event and prove the consumer converges;
- deliver an older aggregate version after a newer one;
- time out payment after the provider accepted authorization;
- make the Inventory database unavailable before and after reservation;
- attempt a direct legacy write with revoked credentials;
- pause the target during backfill and resume from a checkpoint;
- change a source schema incompatibly while CDC is running;
- restore one domain database and reconcile event positions and projections;
- cut ownership over, then prove the documented rollback boundary.
Archive commands, configuration, source positions, event IDs, reconciliation output, and recovery timelines before replacing PROPOSED LAB with observed conclusions.
Where It Breaks
| Failure mode | What actually broke | Required control |
|---|---|---|
| Boundary follows tables, not invariants | Commands still require synchronous cross-database updates | Keep the invariant together or redesign the business workflow |
| Old writers survive extraction | Two components can mutate the same business fact | Command inventory, database grants, rejected-write monitoring, final fencing |
| Application dual writes | One database succeeds while the other fails | Local transaction with outbox; single authority per phase |
| Duplicate or reordered events | Retry or partitioning replays an older fact | Event IDs, aggregate versions, idempotent conditional handlers |
| CDC stops | Downstream state becomes stale while source commits continue | Lag and retained-log alerts, capacity controls, tested connector recovery |
| Projection becomes a secret authority | A read copy accepts corrections or business decisions | Read-only access and visible provenance, version, and freshness |
| Saga compensation fails | A refund or reservation release is itself unavailable | Persisted workflow, retry budget, manual queue, reconciliation |
| Cross-domain API fanout grows | One page now depends on many live owners | Purpose-built local projection with an explicit freshness objective |
| Database restore ignores events | Restored state and consumer offsets describe different histories | Recovery manifest with backups, outbox state, source offsets, and replay plan |
| Every module gets a database | Operational count grows faster than value | Extract only boundaries with clear scaling, ownership, risk, or change benefit |
| Teams split before ownership | Service and database incidents have different owners | One accountable owner for commands, schema, data quality, and recovery |
When I would not use this architecture
Do not decompose the write path merely because the monolith is unfashionable. Keep one database when the workload fits comfortably, the important invariants are genuinely cohesive, one team can operate the system, and cross-domain consistency would be more valuable than independent scaling.
Do not choose this path when the organization cannot operate event delivery, reconciliation, multiple backups, schema contracts, and partial failure. Vertical scaling, query repair, native partitioning, or tenant sharding may buy safer headroom first.
Do not split an invariant that has no acceptable compensation. If a regulation or financial control requires one serializable decision across a set of facts, those facts may belong in one transactional owner—or in a database technology that provides the required distributed transaction semantics after explicit validation.
Scenario verdict
| Decision | RetailCo conclusion |
|---|---|
| Good fit | Order, inventory, payment, or shipping has distinct write pressure, ownership, recovery, and scaling needs |
| Poor fit | A small cohesive system where most critical commands require the same immediate invariant |
| Recommended first move | Modularize commands and enforce one writer before moving a database |
| Recommended target | One deployment with a small number of domain-owned databases, outbox events, and explicit workflows |
| Main advantage | Independent write capacity and blast-radius control without a company-wide service rewrite |
| Main limitation | Cross-domain operations become eventually consistent workflows with reconciliation |
| Operational complexity | High—multiple backups, CDC, event contracts, projections, and recovery positions |
| Migration complexity | High—hidden writers and implicit constraints must be discovered before cutover |
For RetailCo, I would extract one measured bottleneck—likely Inventory if reservation contention dominates, or Payment if security and recovery isolation dominate—while keeping the application deployment intact. I would not split Customer, Order, Inventory, Payment, and Shipping simultaneously. The first extraction is successful only when the old writer is gone, the new owner can recover independently, and business invariants converge under duplicate delivery, delay, and partial failure.
What to Do Next
- Problem: One shared writer couples RetailCo’s order, inventory, payment, and shipping capacity, releases, and incidents.
- Solution: Establish enforceable domain ownership inside the monolith, then move one domain to its own database with a local outbox and an explicit cross-domain workflow.
- Proof: Build the actor and invariant maps, revoke legacy writes, reconcile source and target, and run failure tests for duplicate events, CDC outage, ambiguous payment, target failure, and restore before claiming scalability or resilience.
- Action: Select the first domain from measured lock, CPU, log, growth, security, and recovery pressure. Keep one deployment for the first cutover; earn the data boundary before deciding whether another service is useful.