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:
text
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)
text
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)
raiseOrchestration (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:
text
catalog --publishes--> product.updated {id, weight, dims}
fulfillment --consumes--> upserts into its own product_dimensions tableFulfillment 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
- Freeze feature work in the monolith module being extracted.
- Stand up the new service; dual-write or replay events to fill its store.
- Route N% of read traffic via a routing layer flag; compare responses (shadow diff).
- Ramp N → 100 with the rollback switch live at every step.
- 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-servicethat 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.