Skip to content

    System Design

    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.

    By Published 24 min read

    Share

    This lesson is part of Backend System Design Project: Build QuickCart. Enroll for free, then sign in, to read it in full.

    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.

    QuickCart before stage 8: one application deployable shared by all teamsShoppers and couriers to Load balancer; Store dashboards to Load balancer; Load balancer to QuickCart application; QuickCart application to Relational core (any module); QuickCart application to Catalogue store; QuickCart application to Search service; QuickCart application to Notification serviceShoppers and couriersShoppers andcouriersStore dashboardsStoredashboardsLoad balancerLoad balancerQuickCart application: one release trainQuickCartapplicationone release trainRelational coreRelationalcoreCatalogue storeCataloguestoreSearch serviceSearch serviceNotification serviceNotificationserviceany module

    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.

    In one process Across services
    A call returns or throws A call can time out with the outcome unknown
    One database transaction No shared transaction: use sagas and accept eventual consistency (distributed transactions)
    One log, one debugger 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.

    QuickCart context map: seven bounded contexts and their relationshipsCatalogue to Cart and Checkout (prices); Cart and Checkout to Orders (order); Orders to Payments (charge); Orders to Inventory (reserve); Orders to Delivery (assign); Orders to Notifications (events); Delivery to Notifications (events)Catalogue: upstream supplierCatalogueupstream supplierCart and CheckoutCart andCheckoutOrders: orchestrates the sagaOrdersorchestrates thesagaPayments: anti-corruption layerPaymentsanti-corruptionlayerInventoryInventoryDeliveryDeliveryNotifications: conformistNotificationsconformistpricesorderchargereserveassigneventsevents

    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 language
    public interface PaymentGateway {
        AuthorizationResult authorize(AuthorizationRequest request);
    }
    
    public record AuthorizationRequest(String idempotencyKey, long amountMinorUnits,
                                       String currency, String paymentMethodToken) {}
    
    public sealed interface AuthorizationResult {
        record Approved(String providerReference) implements AuthorizationResult {}
        record Declined(String reasonCode) implements AuthorizationResult {}
        record Unavailable(String detail) implements AuthorizationResult {}
    }
    
    // application: the use case depends only on the port
    public class AuthorizePayment {
        private final PaymentGateway gateway;
        public AuthorizePayment(PaymentGateway gateway) { this.gateway = gateway; }
    
        public String handle(AuthorizationRequest request) {
            return switch (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.

    // infrastructure: an adapter (the vendor-facing names are Pseudocode)
    public class ProviderPaymentGateway implements PaymentGateway {
        private final ProviderClient client;
        public ProviderPaymentGateway(ProviderClient client) { this.client = client; }
    
        public AuthorizationResult authorize(AuthorizationRequest r) {
            try {
                ProviderResponse resp = client.charge(r.idempotencyKey(), r.amountMinorUnits(),
                                                      r.currency(), r.paymentMethodToken());
                return resp.approved()
                    ? new AuthorizationResult.Approved(resp.id())
                    : new AuthorizationResult.Declined(resp.code());
            } catch (ProviderTimeoutException e) {
                return new AuthorizationResult.Unavailable("provider timeout");
            }
        }
    }

    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.

    Payments service: inbound adapters, a core with ports, and outbound adaptersREST controller to Application and domain (use case); Event consumer to Application and domain (use case); Application and domain to Provider adapter (port); Application and domain to In-memory fake (port); Application and domain to Ledger repository (port)DEPENDS ON NOTHING OUTSIDEREST controller: inbound adapterRESTcontrollerinbound adapterEvent consumer: inbound adapterEvent consumerinbound adapterApplication and domain: defines the portsApplicationand domaindefines the portsProvider adapter: vendor-facingProvideradaptervendor-facingIn-memory fake: same port, for testsIn-memory fakesame port, fortestsLedger repository: outbound adapterLedgerrepositoryoutbound adapteruse caseuse caseportportport

    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.

    apiVersion: v1
    kind: Service
    metadata:
      name: inventory
      namespace: shop
    spec:
      selector:
        app: inventory
      ports:
        - port: 80
          targetPort: 8080

    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:

    SELECT order_id, placed_at, total_minor
    FROM orders
    WHERE shopper_id = $1
      AND (placed_at, order_id) < ($2, $3)
    ORDER BY placed_at DESC, order_id DESC
    LIMIT 20;

    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:

    1. 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.
    2. 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:

    1. The tenant id comes from the verified token, never from the request body. A store user's token carries a store_id claim.
    2. Every row and every query carries it. Repositories take the tenant as a required argument.
    3. 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.

    Tenant isolation: store id taken from the token and enforced by the databaseStore dashboard to API gateway (token); API gateway to Per-store limiter (check); API gateway to Inventory service (identity); Inventory service to PostgreSQL (tenant)TENANT BOUNDARYStore dashboard: store_id claimStoredashboardstore_id claimAPI gateway: verifies the tokenAPI gatewayverifies thetokenPer-store limiter: token bucketPer-storelimitertoken bucketInventory service: store id requiredInventoryservicestore id requiredPostgreSQL: row-level securityPostgreSQLrow-levelsecuritytokencheckidentitytenant

    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:

    1. Put the gateway in front of the monolith, routing everything to it. No behaviour changes, and the seam exists.
    2. Make the modules real inside the monolith. Seven modules, no cross-module table access, enforced by an automated architecture test.
    3. 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.
    4. 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.
    5. 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.
    6. 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.

    Official documentation

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments