# Patterns — Architecting Service Boundaries ## Contents - Split/don't-split decision table - Modular monolith enforcement - Saga with compensations (order placement) - Local read model via events - Strangler extraction plan - Distributed-monolith smell test - Gotchas ## Split/don't-split decision table | Signal | Verdict | |---|---| | Module needs 10× the replicas of the rest | Split (scaling trigger) | | Module ships weekly while the rest ships daily and blocks it | Split (cadence trigger) | | Two teams keep merge-conflicting in one module | Split (ownership trigger) | | "It would be cleaner as a service" | Don't split | | Junior team, no platform/on-call maturity | Don't split yet | | Module is called by everything, in-process, latency-sensitive | Don't split | ## Modular monolith enforcement Enforce boundaries inside one deployable so extraction stays cheap: ``` app/ ├── billing/ # public API: billing/api.py only ├── catalog/ ├── fulfillment/ └── shared/ # pure utilities only — no business logic ``` - Each module exposes one façade (`billing/api.py`); cross-module imports of internals fail CI (import-linter / ArchUnit / eslint-boundaries). - Each module gets its own schema/namespace in the database from day one — table ownership is then already settled when a split comes. ## Saga with compensations (order placement) ``` Step Compensation 1. reserve inventory → release reservation 2. charge payment → refund payment 3. create shipment → cancel shipment 4. send confirmation → (terminal; deliberately last — cannot be unsent) ``` ```python SAGA = [ (reserve_inventory, release_reservation), (charge_payment, refund_payment), (create_shipment, cancel_shipment), (send_confirmation, None), # irreversible step goes last ] def run_saga(order): done = [] for step, compensate in SAGA: try: step(order) done.append(compensate) except StepFailed: for comp in reversed(done): # unwind in reverse order if comp: comp(order) raise ``` Orchestration (one coordinator, above) is the default — easier to trace and test. Choreography (each service reacts to events) is the escape hatch when the coordinator itself becomes a coupling point. ## Local read model via events Instead of `fulfillment` calling `catalog` on every shipment: ``` catalog --publishes--> product.updated {id, weight, dims} fulfillment --consumes--> upserts into its own product_dimensions table ``` Fulfillment reads locally (fast, survives catalog downtime); staleness is bounded by event lag and must be acceptable for the use case — if it isn't, the data wasn't yours to cache and the boundary may be wrong. ## Strangler extraction plan 1. Freeze feature work in the monolith module being extracted. 2. Stand up the new service; dual-write or replay events to fill its store. 3. Route N% of read traffic via a routing layer flag; compare responses (shadow diff). 4. Ramp N → 100 with the rollback switch live at every step. 5. Cut writes over behind the same flag; verify; delete monolith code path within a sprint (a dormant duplicate path rots into rule-8 territory). ## Distributed-monolith smell test Answer for each service; any "yes" means redraw: - Must it deploy in the same release train as another service? - Does another service read or write its tables? - Does one service's outage take it fully down (not degraded)? - Do contract changes require synchronized PRs across repos? ## Gotchas - **Entity-shaped services** (`user-service` that everyone calls for everything) recreate the shared database over HTTP; split by capability (identity vs profile vs preferences). - **Shared libraries with business logic** are a hidden shared schema — a change forces lockstep upgrades. Keep shared libs to pure utilities. - **Sagas are not ACID**: intermediate states are visible. Model them explicitly (`PENDING_PAYMENT`), don't pretend isolation. - **Compensation ≠ undo**: refund is a new transaction, not an erasure — books must show both. - **Event schemas are contracts too** — version them like APIs (rule 6), with consumers tolerant of unknown fields. - **The routing flag is load-bearing** during strangler cuts; it needs tests and an owner like any production code.