QuickCart Stage 8: Splitting the Monolith Into Services
Split QuickCart into seven services using bounded contexts, ports and adapters, a gateway, rate limits and tenant isolation, with a safe strangler-fig path.
What you'll learn: By the end of this stage you will be able to decide when a monolith should be split, draw bounded contexts, structure a service with ports and adapters, place a gateway and service discovery, choose between REST, gRPC, GraphQL and WebSockets, design a paginated, idempotent API, apply rate-limiting algorithms by hand, isolate store tenants, and migrate with a strangler fig.
A small restaurant has one kitchen and everyone shares it. When the business grows into a food hall, each stall gets its own counter, its own cook and its own till, and a corridor joins them. Each stall now moves faster on its own, but someone has to run the corridor: signs at the entrance, rules about who may enter which stall, and a plan for the day one stall is closed.
QuickCart has reached that point. This stage is about drawing the stall boundaries in the right places, building the corridor, and moving the business across without closing the restaurant.
Where QuickCart is now
After seven stages QuickCart has a load balancer, caches, a sharded orders database, an order pipeline with a saga, a payment ledger, and search, notification and media components. The core application is still one deployable codebase. The satellite parts were pulled out because each had a technical reason (asynchronous work, a different engine), not a domain boundary.
The numbers from the brief show the strain. Browsing peaks at about 2,300 requests per second (2,000,000 daily users x 20 page views = 40,000,000 per day; 40,000,000 / 86,400 = about 463 average; times 5). Checkout peaks at about 58 per second (500,000 orders / 86,400 = about 5.8; times 10). All figures are illustrative assumptions. Browsing needs roughly 40 times the request capacity of checkout, yet both scale together because they ship together.
Assume, also illustratively, 24 engineers in four teams. Four pains follow:
Release coupling. A one-line fix to courier assignment waits behind a half-finished catalogue change, because there is one release train.
Mismatched scaling. Adding servers for browse traffic also adds copies of checkout code that mostly sit idle.
Noisy neighbours. A store re-uploading its whole price list competes with shoppers for the same database connections.
One word, four meanings. A single Product class serves the catalogue page, the stock counter, the cart line and the invoice, and every team edits it.
Figure 1. QuickCart before this stage: one deployable shared by every team.
How to read this: the ochre-outlined box is the problem. Every module reaches into the shared relational core, and everything ships together. The satellites on the right were split by technology, not by business meaning.
The core idea
A service boundary is a boundary of language and data ownership: one team, one vocabulary, one set of tables that nobody else reads. Find those boundaries first, enforce them inside the code, and only then turn the ones that pay for themselves into separate deployables. Put one front door and a small set of shared rules around the result.
Choosing the shape of the system
Monolith, modular monolith and microservices
A monolith is one deployable unit. A modular monolith is still one deployable, but its code is divided into modules with explicit public interfaces and private data, and the build or tests forbid reaching across. Microservices are independently deployable services, each owning its data and talking over the network.
Shape
Strength
Cost
Right when
Monolith
Simplest to build, test and run; in-process calls; one transaction
Everything couples to everything over time
A small team, unclear boundaries
Modular monolith
Clear boundaries, still one release and local transactions
Needs discipline and tooling to keep modules apart
Boundaries known, one deployment still acceptable
Microservices
Independent releases, scaling and failure isolation
The costs of distribution, below
Several teams blocked by shared releases, or parts with very different load
Teams underestimate the costs of distribution: a function call becomes a network call, with failure modes of its own.
Traces and correlation ids across services (observability)
One deployment to watch
Many pipelines, dashboards, on-call rotations
QuickCart's rule: modules first, a service only where independent release, scaling or isolation is worth those costs.
Figure 2. Decision: should this module become a service?
flowchart TD
accTitle: Decision flow for extracting a module as a service
accDescr: Four questions in order. Shared release pain, a clean data boundary, a need for separate scaling or isolation, and operating capacity. Any no stops at a cheaper option.
Q1{"Do shared releases block more than one team?"} -->|no| A["Stay a modular monolith"]
Q1 -->|yes| Q2{"Does the module own its data alone?"}
Q2 -->|no| B["Fix the boundary inside first"]
Q2 -->|yes| Q3{"Does it need its own scaling, release or isolation?"}
Q3 -->|no| A
Q3 -->|yes| Q4{"Can a team run and observe one more service?"}
Q4 -->|no| C["Invest in tooling first"]
Q4 -->|yes| D["Extract it as a service"]
How to read this: extraction is the last answer, not the first. Every other exit is cheaper than a new service. See also architecture styles.
Finding the boundaries
Domain-driven design and bounded contexts
Domain-driven design (DDD) organises software around the business domain. Its central pattern is the bounded context: a boundary inside which one model and one vocabulary hold. Martin Fowler's summary of Eric Evans's idea is that total unification of one model across a large system is not feasible or cost-effective, so each context gets its own consistent model.
The ubiquitous language is the vocabulary developers and domain experts share inside one context. The same word can legitimately mean different things in different contexts. QuickCart's product:
In Catalogue it is a sellable item: name, photos, category, current price.
In Inventory it is a stock-keeping unit at one store with a quantity on hand and a quantity reserved.
In Cart and Checkout it is a line item holding a price snapshot.
In Orders it is an ordered item with the price paid, frozen forever.
One Product class serving all four becomes a shared bottleneck. Each context keeps its own model and refers to others by id (a productId plus a storeId).
QuickCart's seven contexts, with ownership:
Context
Owns
Publishes (teaching names)
Catalogue
Descriptions, photos, prices, categories
ProductChanged, PriceChanged
Cart and Checkout
Carts, checkout sessions
Calls Orders to create an order
Orders
Orders and their status
OrderPlaced, OrderConfirmed
Payments
Payments, ledger entries
PaymentAuthorised, PaymentFailed
Inventory
Stock and reservations per store
StockReserved, StockChanged
Delivery
Couriers, assignments, tracking
CourierAssigned, OrderDelivered
Notifications
Templates, preferences, send log
Nothing; consumes events
The search and recommendation service from stage 7 is not an eighth context. It is a read-side projection of Catalogue data, rebuilt from its events.
An aggregate is a cluster of objects changed together under one root, forming one consistency boundary. The Order aggregate is the order plus its lines and status. Two rules follow: one transaction changes one aggregate, and aggregates refer to each other by id, never by object. Changing an order and a stock count at once is therefore two aggregates in two contexts, which is exactly why stage 5 used a saga.
A context map records how contexts relate. QuickCart's relationships:
Catalogue is upstream of Cart and Checkout: it supplies product data and the cart conforms to its changes through a published contract.
Orders orchestrates Payments, Inventory and Delivery through saga messages.
Payments wraps the external payment provider in an anti-corruption layer, a translation layer that stops the provider's vocabulary leaking inward.
Notifications is a conformist downstream: it takes the events as published.
Nobody shares a database or a model library. That would couple the contexts again.
Figure 3. QuickCart context map.
How to read this: solid arrows are request-response calls, flowing dashes are messages through the broker. Orders sits at the centre because it coordinates the saga, but it owns only order data.
Structure inside one service
Hexagonal and clean architecture
Hexagonal architecture (Alistair Cockburn, also called ports and adapters) keeps the business logic in the middle and talks to the outside world only through ports: interfaces that describe a purposeful conversation. An adapter implements a port for one technology, such as a REST controller, a SQL repository or a payment provider's HTTP API. Several adapters can fit one port, which is how a test replaces a real database with an in-memory fake. Clean architecture draws the same idea as concentric rings (entities, use cases, interface adapters, frameworks) under one rule: source-code dependencies point inward only.
The payoff for QuickCart is concrete. The Payments service must survive a change of provider and must be testable without calling one. The port belongs to the domain, so the domain defines it (Java 21 syntax):
// domain: the port, owned by Payments and named in its languagepublicinterfacePaymentGateway {
AuthorizationResult authorize(AuthorizationRequest request);
}
publicrecordAuthorizationRequest(String idempotencyKey, long amountMinorUnits,
String currency, String paymentMethodToken) {}
publicsealedinterfaceAuthorizationResult {
recordApproved(String providerReference)implementsAuthorizationResult {}
recordDeclined(String reasonCode)implementsAuthorizationResult {}
recordUnavailable(String detail)implementsAuthorizationResult {}
}
// application: the use case depends only on the portpublicclassAuthorizePayment {
privatefinal PaymentGateway gateway;
publicAuthorizePayment(PaymentGateway gateway) { this.gateway = gateway; }
public String handle(AuthorizationRequest request) {
returnswitch (gateway.authorize(request)) {
case AuthorizationResult.Approved a -> "approved:" + a.providerReference();
case AuthorizationResult.Declined d -> "declined:" + d.reasonCode();
case AuthorizationResult.Unavailable u -> "retry-later";
};
}
}
The adapter is the only place that knows the vendor. Pseudocode for the vendor part: ProviderClient, ProviderResponse and ProviderTimeoutException stand for whatever SDK or HTTP client the real provider supplies.
Two details matter. A timeout becomes the explicit Unavailable result, so the use case must decide what to do, and the idempotency key travels through the port so the provider can recognise a retry (stage 6). A test passes a fake PaymentGateway that returns a canned result and needs no network.
Figure 4. Ports and adapters inside the Payments service.
How to read this: arrows show runtime calls. Source-code dependencies point the other way for the outbound side: the adapters import the port the core defines, never the reverse.
The front door and the network between services
API gateway
An API gateway is the single entrance for outside callers (north-south traffic). It routes /v1/catalogue/... to Catalogue and /v1/orders/... to Orders, terminates TLS, verifies the caller's token once and passes a verified identity inward, applies rate limits and can aggregate several backend calls into one response for a mobile screen. The gateway lesson covers the mechanics (API gateway and service mesh); here are QuickCart's decisions.
Authentication offload, not authorisation. The gateway proves who the caller is. Each service still decides what that caller may do. The gateway must strip any identity header an outside client sends, or a client can forge one.
Aggregation only for read screens. The home screen combines Catalogue highlights and the shopper's active order into one response. Checkout is never aggregated.
A coarse limit at the edge. Per-IP and per-shopper limits live here; finer per-store limits live in the services.
The risk is the smart gateway: discount rules, delivery-fee maths or order validation creeping in because "it already sees every request." Then every team edits one deployable, and its outage takes down everything. QuickCart's rule: if removing a feature would change a business outcome, it does not belong in the gateway.
Figure 5. One checkout through the gateway.
sequenceDiagram
accTitle: One QuickCart checkout request through the gateway
accDescr: The shopper app posts a checkout with an idempotency key. The gateway verifies the token and rate limit, the checkout service re-reads prices from Catalogue, asks Orders to create a pending order, and the shopper gets 202 Accepted while the saga continues asynchronously.
participant S as Shopper app
participant G as Gateway
participant K as Checkout service
participant C as Catalogue service
participant O as Orders service
S->>G: POST /v1/checkouts with token and Idempotency-Key
G->>G: verify token, apply rate limit
G->>K: forward with verified identity
K->>C: get current prices, with a deadline
C-->>K: prices
K->>O: create order as pending
O-->>K: order id
K-->>G: 202 Accepted and order id
G-->>S: 202 Accepted and order id
Note over O: outbox row written with the order, saga continues asynchronously
How to read this: only the price lookup is a synchronous service-to-service call, and it has a deadline. Everything after "pending" (stock, payment, courier) happens through the order pipeline of stage 5.
Service discovery
Instances start, stop and move, so a caller cannot hard-code addresses. Service discovery answers: which healthy instances serve Inventory right now? There are three styles. DNS-based discovery resolves a name to addresses. In client-side discovery the caller asks a registry and chooses an instance itself, which saves a hop but puts discovery logic in every client. In server-side discovery the caller talks to a stable proxy or load balancer, which consults the registry.
Kubernetes (stage 10 will run QuickCart on it) takes the server-side route. Per its documentation, a Service gives a set of pods a stable virtual IP, the cluster IP, and a DNS name of the form <service>.<namespace>.svc.cluster-domain, where the default cluster domain is cluster.local. A controller keeps EndpointSlices in step with the pods that match the Service's selector, and kube-proxy on each node configures rules that redirect traffic for the cluster IP to one of those endpoints.
Callers in the shop namespace reach it as http://inventory, and from other namespaces as inventory.shop.svc.cluster.local (with the default domain). The documentation explains why Kubernetes does not rely on plain DNS round-robin: some DNS implementations ignore record TTLs, some applications cache a lookup forever, and very low TTLs would load the DNS servers. A headless Service (clusterIP: None) instead returns the set of pod addresses and leaves choosing to the client.
Discovery data is a cache and can be slightly stale. Callers therefore need timeouts and a retry to a different instance (stage 9).
Service mesh
A service mesh moves east-west networking concerns out of application code. A small proxy, the sidecar, runs beside every service instance and carries its traffic; a control plane pushes configuration and certificates to the proxies. A mesh typically gives mutual TLS (both sides present certificates), uniform retries and timeouts, traffic shifting (send 5 percent of requests to a new version) and per-call telemetry.
Istio's documentation describes the mechanism: each workload gets an X.509 identity tied to its Kubernetes service account, an agent beside each Envoy proxy requests and rotates certificates from istiod, and the PERMISSIVE mode accepts both plaintext and mTLS so a migration can move to STRICT gradually.
The costs are real: extra CPU and memory per pod, a control plane to upgrade and keep available, one more layer in every incident, and a retry policy that can multiply load if layered with application retries. For seven services and a small platform team, QuickCart defers the mesh. It relies on the gateway, TLS to each service and library-level timeouts, and revisits at stage 10 when security requirements and Kubernetes arrive together. See the gateway and mesh lesson.
Talking to each other
REST, gRPC, GraphQL and WebSockets
Each style fits a different conversation.
REST over HTTP and JSON models resources and uses standard verbs and status codes. It is cacheable, tool-friendly and the default for public APIs.
gRPC uses HTTP/2 and protocol buffers as its default interface definition language. It supports unary calls and three streaming kinds, and a client can set a deadline after which the call ends with DEADLINE_EXCEEDED. Browsers reach it through gRPC-Web and a proxy.
GraphQL exposes a typed schema, and the client asks for exactly the fields it needs. It reduces round trips for varied screens but moves cost control to the server: unbounded queries need depth or cost limits, and HTTP caching is harder.
WebSockets (RFC 6455) start as an HTTP request that upgrades to a long-lived two-way connection, so either side can send at any time. The price is per-connection state and more care in load balancers and deploys. Server-Sent Events are a simpler one-way option.
Style
Shape
Strength
Cost
QuickCart uses it for
REST + JSON
Request and response
Cacheable, universal
Chatty for rich screens
Public API, apps and store dashboards
gRPC
Request and response, streaming
Typed contracts, deadlines
Needs proxy for browsers
Service to service, such as price lookup
GraphQL
Client-shaped queries
Fewer round trips
Query cost control, caching
Not used yet (see below)
WebSocket
Long-lived two-way
Push without polling
Connection state
Live courier tracking
Events
Asynchronous messages
Decoupling
Eventual consistency
Order saga, notifications
QuickCart does not adopt GraphQL at this stage. The one screen that would benefit, the home screen, is served by a small aggregation endpoint. The decision is revisited if several client teams start needing different shapes of the same data.
API design
Versioning. Put the major version in the path (/v1/) and treat additive changes (a new optional field) as non-breaking. Removing or renaming a field is breaking and needs /v2/ plus a deprecation window.
Pagination. Offset pagination (?offset=99980&limit=20) makes the database find and discard 99,980 rows before returning 20, and rows can shift between pages if data changes. Cursor (keyset) pagination remembers the last row seen and asks for rows after it. For a shopper's order history sorted newest first:
With an index on (shopper_id, placed_at, order_id), PostgreSQL can walk to the cursor position instead of skipping rows. $2 and $3 come from an opaque cursor token returned with the previous page. Orders are sharded by shopper id (stage 4), so this query stays on one shard. The cost: no jumping to page 50 directly.
Idempotency. RFC 9110 defines PUT, DELETE and the safe methods such as GET as idempotent; POST is not. A checkout is a POST, and mobile networks retry. The caller sends an Idempotency-Key header, and the server stores the first result under that key, so a retry returns the same response instead of creating a second order. The same key with a different body is rejected as a conflict. At the time of writing, the header is described in an IETF draft (draft-ietf-httpapi-idempotency-key-header, an Internet-Draft rather than a published RFC), though it is already a common convention. Stage 6 shows how Payments applies the key.
Error format. RFC 9457 (which obsoletes RFC 7807) defines application/problem+json with type, title, status, detail and instance.
{"type":"https://quickcart.example/problems/out-of-stock","title":"Item out of stock","status":409,"detail":"Only 2 units of SKU 8841 remain at store 17.","instance":"/v1/checkouts/c_5521"}
One shape for every error lets clients and the gateway handle failures uniformly. Never put a stack trace or an internal hostname in detail.
Rate limiting
Rate limiting rejects callers that exceed an agreed rate, with 429 Too Many Requests (RFC 6585), optionally with a Retry-After header. Worked example, an illustrative policy of 5 checkout attempts per shopper per minute:
Fixed window. Count per calendar minute, reset at the boundary. Cheap, but a shopper sends 5 requests at 12:00:58 and 5 more at 12:01:02: ten in four seconds, all allowed.
Sliding window log. Store each request's timestamp, drop those older than 60 seconds, count the rest. At 12:01:02 the five from 12:00:58 still count, so the sixth is rejected. Exact, but memory grows with the limit for every key.
Sliding window counter. Keep two counters and weight the previous one. At 12:01:15 (25 percent into the window) with 5 in the previous minute and 1 now: 5 x 0.75 + 1 = 4.75, below 5, so allow. An approximation with tiny memory.
Token bucket. A bucket holds up to 5 tokens and gains one every 12 seconds. A request spends a token. Five requests at t = 0 pass (bucket empty), a sixth at t = 3 s is rejected (0.25 tokens), and one at t = 12 s passes. It allows bursts up to capacity and holds the long-run rate. Amazon API Gateway documents using this algorithm.
Leaky bucket. Requests queue in a bucket of 5 and are released one every 12 seconds, the first at t = 12 s. A burst of 5 at t = 0 fills it and a sixth at t = 1 s is rejected. Output is perfectly smooth, but queued requests wait.
QuickCart picks the token bucket at the gateway because shoppers legitimately click quickly in short bursts. Counters sit in a shared store (a Redis-style cache) so every gateway instance sees the same count. See rate limiting.
Where functions fit, and where they do not
Serverless and event-driven design
Serverless functions run your code on demand, scale with the number of events and bill by use, with no servers to manage. They are event-driven: something happens (an object is uploaded, a webhook arrives, a message is published) and a function reacts.
Two good fits in QuickCart:
Image resizing. A store uploads a product photo, the object store emits an event, and a function writes thumbnails. The work is bursty, stateless and not on a shopper's request path.
Webhooks. The payment provider and courier partners call QuickCart with status changes at unpredictable times. A function verifies the signature, writes the event to the broker and returns quickly, and the saga takes over.
Limits, stated generally. A cold start is the extra latency when the platform must prepare a new execution environment before running your handler. AWS's Lambda documentation describes the lifecycle (initialisation, invocation, shutdown), notes that environments are reused but must not be assumed to persist, and offers provisioned concurrency to pre-initialise environments. Functions also have maximum run times, and many concurrent instances can exhaust a relational database's connections. Platforms differ, so read your provider's current quotas.
So QuickCart does not run checkout in functions: it is latency-sensitive, steady and stateful. Event delivery is at least once, so every handler must be idempotent.
Many stores, one platform
Multi-tenancy
Multi-tenancy means one running system serves many customers, called tenants, whose data must stay apart. In QuickCart each of the 5,000 stores is a tenant. To keep the lesson simple, assume each cart and order belongs to one store (illustrative; a multi-store cart would become several orders).
Model
How
Strength
Cost
Pooled
Shared tables, a store_id column in every row
Cheapest, simplest to operate
A missing filter leaks data; noisy neighbours
Schema per tenant
One schema each in a shared database
Stronger separation
Thousands of schemas to migrate
Siloed
A database per tenant
Strongest isolation, per-tenant tuning
Highest cost and operations load
QuickCart is pooled by default with a siloed option later for a very large chain that demands it. Three rules make pooling safe:
The tenant id comes from the verified token, never from the request body. A store user's token carries a store_id claim.
Every row and every query carries it. Repositories take the tenant as a required argument.
The database enforces it too. PostgreSQL row-level security applies a policy to every query, as a second line of defence.
ALTER TABLE stock ENABLE ROW LEVEL SECURITY;
ALTER TABLE stock FORCE ROW LEVEL SECURITY;
CREATE POLICY store_isolation ON stock
USING (store_id = current_setting('app.store_id', true));
-- per request, inside the transaction:SELECT set_config('app.store_id', 'store_17', true);
Per the PostgreSQL documentation, table owners bypass policies unless FORCE is set, and superusers and roles with BYPASSRLS always bypass them, so the service must connect as an ordinary role. If the setting was never set, current_setting(..., true) returns NULL; a reused connection may return an empty string instead. Neither matches a store, so no rows show. The third argument true to set_config makes the value local to the transaction, which matters when connections are pooled.
Noisy neighbours are tenants whose load harms others. The defences are per-tenant rate limits, a separate queue and worker pool for bulk imports (a bulkhead, stage 9), per-tenant quotas on batch size, and metrics grouped by tenant tier rather than 5,000 separate labels.
Figure 6. Tenant identity from token to row.
How to read this: the store id is set once at the edge from a verified claim and enforced twice more: in the service code and by the database policy. A bug in any one layer does not expose another store's rows.
Migrating safely
The strangler-fig path from the monolith
Martin Fowler named the strangler fig pattern after vines that grow around a host tree and gradually replace it. You build new components alongside the legacy system, route traffic to them piece by piece and retire the old parts, delivering value at each step instead of in one big rewrite.
QuickCart's sequence:
Put the gateway in front of the monolith, routing everything to it. No behaviour changes, and the seam exists.
Make the modules real inside the monolith. Seven modules, no cross-module table access, enforced by an automated architecture test.
Extract the cheapest, most valuable context first. Catalogue: read-heavy, about 2,300 requests per second at peak, and already on its own document store from stage 3. Reads move before writes.
Shift traffic gradually. First shadow the traffic and compare responses, then route 1, 10, 50 and finally 100 percent by path or weight at the gateway. Rollback is a routing change.
Move data ownership, then delete. The new service owns its store; the monolith calls its API instead of reading its tables; then the old code is removed.
Repeat for Delivery, Inventory, Payments and Notifications. Orders goes last: it is the most coupled and the sharded one.
Figure 7. Mid-migration: Catalogue extracted, the rest still in the monolith.
Strangler fig mid-migration: the gateway routes catalogue paths to the new serviceApps and dashboards to API gateway; API gateway to Catalogue service (/catalogue); API gateway to QuickCart monolith (the rest); QuickCart monolith to Catalogue service (API, not tables); Catalogue service to Catalogue store; QuickCart monolith to Relational coreApps and dashboardsApps anddashboardsAPI gateway: routes by pathAPI gatewayroutes by pathCatalogue service: extractedCatalogueserviceextractedQuickCart monolith: shrinkingQuickCartmonolithshrinkingCatalogue store: its own dataCataloguestoreits own dataRelational coreRelationalcore/cataloguethe restAPI, not tables
How to read this: the monolith still serves most paths but no longer touches catalogue data directly. Each extraction moves one more path to the right and shrinks the ochre box.
Decisions and trade-offs
Option
Gain
Cost
Wrong choice when
Stay a modular monolith
One release, local transactions, low operations load
Shared releases, shared scaling
Teams are blocked on each other's releases
Split into seven services
Independent release and scaling, failure isolation
Network failures, saga complexity, more to operate
Boundaries are unclear or one team owns everything
Smart gateway with business rules
Fast to add features
One bottleneck and one blast radius
Almost always
Add a service mesh now
mTLS and telemetry for free in code
Control plane and sidecar cost
A handful of services and a small platform team
Pooled tenancy with row-level security
Low cost per store
Leak risk if a layer is wrong
A tenant has legal reasons for physical separation
GraphQL for the apps
Fewer round trips
Query cost control
One or two screens need aggregation
In practice: QuickCart at this stage
NOTE
This is a realistic scenario, not a case history. It is an educational reference design built from common production patterns, and every number is an illustrative assumption.
The situation
Twenty-four engineers in four teams ship QuickCart weekly from one release train. This week the delivery team needs a fix to courier assignment before the evening peak, but the catalogue team's half-finished category change is on the same branch. Meanwhile, large stores upload their full price lists overnight, and checkout latency rises while they run.
Starting architecture
The first step needs no new service. The gateway goes in front, and the monolith becomes seven enforced modules.
Figure 8. Starting point for the split: gateway in front, seven modules inside.
Starting point: a gateway in front of one deployable with seven enforced modulesApps and dashboards to API gateway; API gateway to QuickCart application; QuickCart application to Databases (own schema); QuickCart application to Search, media, notifyONE DEPLOYABLEApps and dashboardsApps anddashboardsAPI gateway: new in stage 8API gatewaynew in stage 8QuickCart application: seven modulesQuickCartapplicationseven modulesDatabases: one schema per moduleDatabasesone schema permoduleSearch, media, notifySearch, media,notifyown schema
How to read this: still one release, but each module owns its own schema and nothing reads another module's tables.
Options considered
Option
Fixes
Costs
Risk
A. Stay a modular monolith with per-team release flags
Release coupling, partly
Flags to manage
Scaling and noisy neighbours stay
B. Extract Catalogue, then Delivery and the rest as needed
Release and scaling pain where it hurts
Gateway, discovery, tracing, saga edges
Boundaries drawn wrongly are costly to redraw
C. Rewrite as seven services at once
Everything, on paper
A long freeze
Highest: big-bang cutover
The decision
Field
Decision record
Context
One release train blocks four teams, browse and checkout scale together, store imports disturb checkout.
Decision
Option B. Gateway first, modules enforced, then extract in order: Catalogue, Delivery, Inventory, Payments, Notifications, Orders last. Pooled tenancy with store_id and row-level security. No mesh and no GraphQL yet.
Good consequences
Independent releases, scaling per context, per-store limits.
Bad consequences
Network calls, eventual consistency, more services to run and trace.
Revisit when
Tracing shows a chain of more than three synchronous calls per request (an illustrative threshold), or two extracted services are always released together.
Rollout and verification
For each extraction: shadow the traffic and compare responses; route 1 percent, then 10, 50 and 100, watching error rate, p99 latency and the shadow mismatch count at every step; keep the monolith path for one release, then delete it. Verify tenancy with a test that logs in as store A and tries to read store B (it must return nothing), and confirm the application's database role is not a superuser. Dashboards: per-service latency and error rate, gateway 429 rate, per-store request rate.
The 2 a.m. failure
At 02:00 a large store starts its nightly import of a full price list. Imports go through the same Catalogue instances that serve price lookups, and the Checkout service calls Catalogue with no deadline. Catalogue slows, Checkout threads wait on it, and checkouts begin to fail with 5xx. The alert reads: "Checkout error rate above 5 percent for 5 minutes."
First three checks: the latency and error rate of the Checkout to Catalogue call; the import queue and the per-store request rate (one store dominates); and PostgreSQL pg_stat_activity for long-running statements from the import. Mitigation: pause the import consumer, and serve cached last-known prices for a short, stated window while Catalogue recovers. Follow-ups: a deadline on every inter-service call, a separate worker pool for imports, a per-store limit on import rate and batch size, and an alert on the per-store share of Catalogue load.
Architecture after this stage
Figure 9. QuickCart after stage 8: seven services behind a gateway.
QuickCart after stage 8: a gateway in front of seven services, events through the brokerApps and dashboards to API gateway; API gateway to Catalogue; API gateway to Cart and Checkout; API gateway to Orders; API gateway to Inventory; Orders to Message broker (events); Message broker to Payments; Message broker to Delivery; Message broker to NotificationsApps and dashboardsApps anddashboardsAPI gateway: auth, routing, limitsAPI gatewayauth, routing,limitsCatalogue: document storeCataloguedocument storeCart and CheckoutCart andCheckoutOrders: sharded databaseOrderssharded databaseInventory: row-level securityInventoryrow-levelsecurityMessage broker: from stage 5Message brokerfrom stage 5Payments: ledgerPaymentsledgerDelivery: live trackingDeliverylive trackingNotificationsNotificationsevents
How to read this: rust-outlined boxes are new in this stage. Payments, Delivery and Notifications are reached through the broker, and the gateway also fronts Delivery for tracking connections (not drawn, to keep the figure readable).
What QuickCart looks like now
Component
Why it exists
What it costs
API gateway
One front door for auth, routing, limits
One more hop, a component to keep available
Seven services
Independent release and scaling per context
Network failures, sagas, more operations
Service discovery (platform)
Finds healthy instances
Staleness handled by timeouts
Ports and adapters per service
Testable domain, swappable providers
A little more code up front
Per-store limits and row-level security
Tenant isolation and fair use
Discipline in every query
Event-driven functions (resize, webhooks)
Bursty work without idle servers
Cold starts, idempotent handlers
Architect's notes
Boundaries before deployables. Redrawing a module boundary is far cheaper than redrawing a service boundary.
Count synchronous hops. Each hop adds latency and a failure mode.
Ask who owns each table. If the answer is two teams, the boundary is wrong.
Defer the mesh and GraphQL. Pick them when a measured pain names them.
Test tenancy adversarially. A test that tries to read another store's data beats a policy review.
Common mistakes
Splitting by technical layer (a "database service", a "validation service") instead of by business context.
A shared database between services. It is a monolith with extra network hops.
Business logic in the gateway. Every team edits it and its failure takes everything down.
Synchronous chains. Service A calls B calls C calls D, and the slowest one sets everyone's latency.
Trusting a tenant id from the request body. Take it from the verified token.
Moving everything at once. There is no rollback for a big bang.
Key takeaways
Split by bounded context, where language and data ownership change, not by layer.
Modules first; a service only where release, scale or isolation pays for the costs of distribution.
Use ports and adapters so the domain survives a change of database or provider.
The gateway handles authentication, routing, limits and aggregation, and no business rules.
Choose the communication style per conversation: REST outside, gRPC between services, events for the saga, WebSockets for tracking.
Prefer cursor pagination, an idempotency key on POST and one error format (RFC 9457).
Token bucket tolerates bursts; fixed windows leak at the boundary.
Pooled tenancy needs the tenant id in the token, in every row and enforced by the database.
Migrate with a strangler fig: gateway first, one context at a time, with a rollback at every step.
Practice exercises
Exercise 1 (30 minutes): List what the word order means in Orders, Payments, Delivery and Notifications. Write one sentence for each and decide which fields each context really needs.
Exercise 2 (1 hour): For a token bucket of capacity 10 refilling 2 tokens per second, trace 15 requests arriving at t = 0 and then 1 request each second for 5 seconds. List which are allowed.
Exercise 3 (2 hours): Add Returns and Refunds as an eighth context. Place it on the context map, name what it owns, pick its relationships to Orders and Payments, and say whether it should be a module or a service on day one.
Exercise 4 (half day): Write a port and a fake adapter for Delivery's courier-assignment partner, then test the use case with no network.
Lesson 8 of 11 · Backend System Design Project: Build QuickCart
Split QuickCart into seven services using bounded contexts, ports and adapters, a gateway, rate limits and tenant isolation, with a safe strangler-fig path.
In the rest of this lesson
The front door and the network between services
Talking to each other
Where functions fit, and where they do not
Many stores, one platform
Migrating safely
Decisions and trade-offs
Enrolling is free. It unlocks every lesson in Backend System Design Project: Build QuickCart, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.