Microservices
a microservice architecture decomposes one application into many independently deployable services, each owning its own data, talking over a network. the honest framing: you are trading a code organisation problem for a distributed systems problem. π that trade is sometimes worth it. it is not worth it nearly as often as conference talks suggest.
the trade, not the hype
what you actually buy
- independent deployment: team a ships without waiting for team b’s release train. this is the real prize β everything else is secondary.
- independent scaling: scale the search service to 40 replicas while billing idles on 2.
- fault isolation: a memory leak in recommendations does not take down checkout β if you also build the resilience machinery below. isolation is earned, not free.
- technology heterogeneity: the ml service can be python while the ledger is jvm. (also a curse: n stacks to patch.)
- organisational scaling: conway’s law working for you β service boundaries that mirror team boundaries let teams own code end to end.
what you actually pay
- every in-process function call that crosses a new service boundary becomes a network call: it can now fail independently, time out, arrive twice, or arrive late.
- transactions that were a single
BEGIN...COMMITbecome sagas (below) β you give up atomicity across boundaries and must design compensation by hand. - refactoring across service boundaries is an order of magnitude harder than moving code between modules β the boundary is now an api contract with independent release cadences (Fowler, Martin, 2018).
- operational surface explodes: per-service ci, dashboards, alerts, on-call, versioned contracts, backwards-compatible migrations.
when a modular monolith wins
- a modular monolith β one deployable, strictly enforced internal module boundaries, one database with schema-per-module discipline β captures most of the design benefit at a fraction of the operational cost.
- you get cheap refactoring while the domain boundaries are still wrong (they always start wrong), real stack traces, local transactions, and one thing to deploy.
- the sane migration path is monolith-first: find the boundaries by living with them in-process, then extract the one or two services that genuinely need independent deployment or scaling β the strangler fig approach of routing traffic incrementally to extracted pieces. π
- rule of thumb: if one team can hold the whole system in its head and deploys are not blocked on other teams, microservices solve a problem you do not have.
decomposing a system
bounded contexts (ddd-lite)
the useful sliver of domain-driven design:
- a bounded context is a region of the system inside which a term means exactly one thing. “customer” in billing (a payment method and an abn) is a different object from “customer” in support (a ticket history) β forcing them into one canonical model couples everything to everything.
- service boundaries should follow bounded contexts, not entities. “user service, order service, product service” carved from database tables produces chatty, anaemic crud wrappers; carving by business capability (checkout, fulfilment, pricing) produces services that can actually change independently.
- heuristics for a good boundary: a change request usually lands in exactly one service; the interface is small relative to what it hides (deep module, not shallow); the team owning it can deploy without a meeting. this is coupling and cohesion at system scale β see design principles.
database-per-service
- each service owns its schema; nobody else reads its tables. the database is the encapsulation boundary β shared databases silently re-fuse the monolith, because a schema migration now needs sign-off from every reader.
- consequence 1: no cross-service joins. data needed for a screen is composed at the gateway/bff layer, or denormalised into a read model (cqrs β see event driven).
- consequence 2: no cross-service transactions. consistency becomes eventual, and the machinery for that is the saga (below).
- reference patterns for all of this are catalogued at microservices.io. π
communication
sync: rest and grpc
- rest/http: ubiquitous, debuggable with curl, cache-friendly. json serialisation and http/1.1 head-of-line blocking cost latency; contracts drift unless openapi schemas are enforced in ci. see api design.
- grpc: protobuf over http/2 β binary, typed, streaming, generated clients. the natural choice for internal service-to-service calls; the cost is browser-hostility and a codegen toolchain.
- the deeper problem with sync either way: temporal coupling. if a needs b to be up to answer, availability multiplies: compose 5 services serially at 99.9% each and the chain delivers \(0.999^5 \approx 0.995\) β you built a system less available than any part.
async: messaging
- a publishes an event to a broker and moves on; b consumes when it can. availability decouples, load spikes buffer in the queue, and new consumers attach without touching the producer β the observer pattern stretched over a network.
- costs: eventual consistency, duplicate delivery (consumers must be idempotent), harder-to-follow control flow, and a broker to operate. the full treatment lives in event driven.
the fallacies of distributed computing
deutsch’s eight assumptions that every distributed design quietly makes and every network eventually violates: π
- the network is reliable
- latency is zero
- bandwidth is infinite
- the network is secure
- topology doesn’t change
- there is one administrator
- transport cost is zero
- the network is homogeneous
every resilience pattern on this page is a countermeasure to fallacy 1 or 2; service discovery exists because of fallacy 5; mtls and zero-trust because of fallacy 4. the underlying transport realities are covered in internet networks.
data consistency: sagas
the problem
placing an order must (a) reserve stock, (b) charge the card, (c) create the shipment. in the monolith this is one acid transaction. across three services there is no distributed COMMIT worth using
π
β so instead: a saga is a sequence of local transactions, each publishing an event that triggers the next, with a compensating transaction per step to semantically undo it if a later step fails.
worked order flow
forward path, and what happens when payment is declined:
- t1 order service: create order in state
PENDING, emitOrderCreated - t2 inventory service: reserve stock, emit
StockReserved - t3 payment service: charge card β fails, emit
PaymentFailed - c2 inventory service: release the reservation (compensates t2)
- c1 order service: mark order
CANCELLED(compensates t1)
compensation is semantic undo, not rollback: you cannot un-send an email, so the compensating action is sending the “sorry, cancelled” email. order the steps so the hardest-to-compensate action (charging money, shipping goods) happens last.
choreography vs orchestration
| style | flow logic lives | shines when | rots when |
|---|---|---|---|
| choreography | in the events, no owner | 2β4 steps, stable flow | flow smeared across services |
| orchestration | in one orchestrator | long, branching flows | orchestrator turns mini-monolith |
- choreography: each service reacts to events; nobody owns the flow. finding out “what state is this saga in?” requires archaeology across consumers.
- orchestration: a saga orchestrator commands each step and tracks state explicitly β the mediator pattern; choreography is observer all the way down. pick per-saga, not per-company.
the outbox pattern
the lurking bug in every saga: a service commits its local transaction, then crashes before publishing the event β db and message stream now disagree. writing to the db and the broker is a distributed write, and you cannot make it atomic directly. the fix:
- transactional outbox: within the local transaction, also insert the event into an
outboxtable β one acid commit covers both. - a relay process (polling, or tailing the write-ahead log β “change data capture”) reads the outbox and publishes to the broker, marking rows sent.
- delivery becomes at-least-once; consumers deduplicate by event id. exactly-once processing is built from at-least-once delivery plus idempotency, never assumed from the transport.
service discovery and the gateway
- discovery: instances are ephemeral (autoscaling, redeploys), so callers cannot hardcode addresses. either client-side (consult a registry like consul/eureka, pick an instance, load-balance in the client) or server-side (dns/virtual ip in front; kubernetes services do this β the registry is the platform). server-side via the platform is the modern default; run a registry yourself only when you must.
- api gateway: single entry point that routes to services and centralises the cross-cutting stack β tls termination, authn, rate limiting, request logging. it is the faΓ§ade pattern at network scale (Gamma, Erich and Helm, Richard and Johnson, Ralph and Vlissides, John, 1994), and pairs with bff (backend-for-frontend): one gateway shaped per client type, so the mobile app is not stitching six calls together on a flaky radio.
- the gateway is on every request path: keep it dumb (routing + policy), never let business logic leak in, and make it highly available before anything else.
resilience
the network will fail (fallacy 1). resilience patterns bound the blast radius.
timeouts, retries, jitter
- every network call gets a timeout. “no default in your http client” is not a decision β it is an unbounded queue of hung threads waiting to happen.
- retry only idempotent operations, only on transient errors, with exponential backoff plus jitter: attempt \(k\) sleeps \(\mathrm{rand}\big(0,\ \min(\mathrm{cap},\ b \cdot 2^{k})\big)\). the randomness matters: deterministic backoff synchronises clients into retry waves that re-kill the recovering service. π
- retries multiply load downstream β a retry storm three layers deep turns one failure into \(3^3 = 27\) requests. budget retries (e.g. a fraction of live traffic) and never retry a circuit-open error.
circuit breaker
stop calling a service that is down; fail fast and give it room to recover.
- closed (normal): calls pass through; count consecutive failures.
- open (tripped): after \(N\) consecutive failures, fail immediately without calling β protects the caller’s thread pool and the callee’s recovery.
- half-open (probing): after a recovery timeout, let one probe through. success closes the breaker; failure re-opens it.
a minimal implementation β the state pattern earning its keep β driven with a fake clock against an upstream that dies at \(t=0\) and recovers at \(t=9\):
import enum, time
class State(enum.Enum):
CLOSED = "closed"; OPEN = "open"; HALF_OPEN = "half-open"
class CircuitOpenError(Exception):
pass
class CircuitBreaker:
def __init__(self, threshold=3, recovery_s=5.0, clock=time.monotonic):
self.threshold, self.recovery_s, self.clock = threshold, recovery_s, clock
self.state, self.failures, self.opened_at = State.CLOSED, 0, None
def call(self, fn, *args):
if self.state is State.OPEN:
if self.clock() - self.opened_at >= self.recovery_s:
self.state = State.HALF_OPEN # allow one probe through
else:
raise CircuitOpenError("failing fast")
try:
result = fn(*args)
except Exception:
self._on_failure()
raise
self._on_success()
return result
def _on_failure(self):
if self.state is State.HALF_OPEN: # probe failed: re-open
self._trip()
else:
self.failures += 1
if self.failures >= self.threshold:
self._trip()
def _on_success(self):
self.state, self.failures = State.CLOSED, 0
def _trip(self):
self.state, self.opened_at = State.OPEN, self.clock()
# --- drive it with a fake clock and a service that dies then recovers ---
now = [0.0]
cb = CircuitBreaker(threshold=3, recovery_s=5.0, clock=lambda: now[0])
healthy = [False]
def flaky():
if not healthy[0]:
raise ConnectionError("upstream dead")
return "200 ok"
script = [(t, "call") for t in (0, 1, 2, 3, 4, 7)] + [(9, "recover"), (12, "call"), (13, "call")]
for t, action in script:
now[0] = float(t)
if action == "recover":
healthy[0] = True
print(f"t={t:>2} upstream recovers")
continue
try:
r = cb.call(flaky)
print(f"t={t:>2} call -> {r:<12} state={cb.state.value}")
except CircuitOpenError:
print(f"t={t:>2} call -> fast fail state={cb.state.value}")
except ConnectionError:
print(f"t={t:>2} call -> upstream err state={cb.state.value}")
t= 0 call -> upstream err state=closed
t= 1 call -> upstream err state=closed
t= 2 call -> upstream err state=open
t= 3 call -> fast fail state=open
t= 4 call -> fast fail state=open
t= 7 call -> upstream err state=open
t= 9 upstream recovers
t=12 call -> 200 ok state=closed
t=13 call -> 200 ok state=closed
read the trace: the third failure at \(t=2\) trips the breaker; \(t=3,4\) fail fast without touching the upstream; at \(t=7\) the recovery timeout has elapsed so a probe goes through, fails, and re-opens the breaker (resetting the timer); the \(t=12\) probe succeeds and the breaker closes.
bulkhead
- partition resources so one failing dependency cannot exhaust them all: separate connection pools / thread pools / semaphores per downstream, named after ship compartments.
- without it: service c hangs, all 200 of your worker threads end up blocked waiting on c, and now you are down for requests that never needed c. with a 20-thread bulkhead for c, the other 180 keep serving.
observability
- with one process you read a stack trace; with thirty services you reconstruct a story from fragments. the fix is correlation: the gateway assigns a request id, every service logs it and propagates it on every outgoing call (the w3c
traceparentheader), so one grep reassembles the request’s whole life. - distributed tracing (opentelemetry; jaeger/zipkin lineage): each unit of work is a span (name, start, duration, parent), spans share a trace id, and the ui renders the tree as a flame graph across services β where the latency actually went, which calls fanned out serially that should have been parallel.
- the three pillars: structured logs (searchable, carrying trace ids), metrics (cheap aggregates for alerting β rate, errors, duration per endpoint), traces (expensive, sampled, for debugging). instrument at the platform layer once, not per-service by hand.
deployment topology
- one service instance per container, grouped and scheduled by an orchestrator β mechanics in containers.
- sidecar pattern: a per-instance helper container handles mtls, retries and telemetry; a fleet of sidecars plus a control plane is a service mesh (istio/linkerd lineage) β the resilience section above, moved out of application code into infrastructure. adopt when the polyglot service count makes per-language client libraries untenable, not before.
- rollout strategies: rolling (replace instances gradually), blue-green (two full environments, flip the router), canary (small traffic slice to the new version, watch error rates, ramp). canary plus good metrics is the only one that catches the bugs your tests missed β see testing for what tests do catch.
- schema changes deploy in expand/contract phases: add the new column/contract, migrate readers, remove the old β never a breaking change in one step, because you cannot deploy thirty services atomically.
see also
- event driven β the async half of this page, done properly
- api design β the contracts these services expose
- containers β the unit of deployment
- design patterns β faΓ§ade, mediator, observer and state, all reappearing at network scale
- design principles β coupling and cohesion, the whole game
References
Fowler, Martin (2018). Refactoring: Improving the Design of Existing Code, Addison-Wesley.
Gamma, Erich and Helm, Richard and Johnson, Ralph and Vlissides, John (1994). Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley Professional.
Backlinks (6)
1. API Design /wiki/se/architecture-design/api/
an api is a promise you cannot take back: the moment a second team depends on it, every quirk you shipped is load-bearing. π this page is the checklist for making promises deliberately β resources and verbs, status codes, pagination, versioning, errors, auth, and rate limiting, with the http machinery treated as the application-layer protocol it is (Kurose, James F. and Ross, Keith W., 2020).
2. Build Systems /wiki/se/implementation/build-systems/
every build system β make, ninja, bazel, cargo, even npm scripts pretending otherwise β is the same machine: a directed acyclic graph of files and commands, plus a policy for deciding which part of the graph is stale. everything else is syntax.
the model: a dag of targets
- a target is a file the build can produce; a rule says how (a command) and from what (its dependencies).
- dependencies point from outputs to inputs; because outputs of one rule are inputs to another, the whole thing is a dag β a cycle would mean “to build a you must first build a”.
- a build is then two steps:
- mark dirty: a target is dirty if it is missing, if any dependency is dirty, or if the staleness policy (timestamps or hashes, below) says an input changed. dirtiness propagates along edges β one flipped source poisons its whole downstream cone.
- evaluate in topological order: run each dirty target’s command after all its dependencies are up to date (Cormen, Thomas H. and Leiserson, Charles E. and Rivest, Ronald L. and Stein, Clifford, 2009). independent dirty targets can run in parallel (
-j8) β the dag is the parallelism plan, which is why builds are embarrassingly parallel until the link step serialises everything.
- the entire correctness contract: the graph must be complete. every undeclared dependency is a future “works after
make clean” bug β the build system faithfully skips rebuilding things it was never told could change.
make: the actual semantics
make’s whole rebuild rule fits in one sentence: a target is rebuilt if it does not exist, or if any prerequisite’s mtime is newer than the target’s mtime. everything else is macro expansion.
3. Event Driven /wiki/se/architecture-design/event-driven/
event-driven architecture inverts the direction of knowledge: instead of the caller knowing who must react, the reactor knows what it cares about. producers announce facts; consumers subscribe. the whole style is the observer pattern with a broker in the middle and a network underneath β which is exactly where the interesting failure modes come from.
events, commands, queries
three message species, constantly confused:
- event: a fact about the past, named in past tense β
OrderPlaced,PaymentFailed. immutable, owned by the producer, zero expectation about who (if anyone) reacts. broadcasting is safe because nothing is being asked. - command: a request for the future β
PlaceOrder. imperative mood, addressed to exactly one handler, can be rejected. commands express intent; events record outcome. compare the command pattern, which reifies exactly this. - query: a question, side-effect free, wants an answer now.
the classic smell is the command in event’s clothing: SendWelcomeEmailRequested published as an “event” that exactly one consumer must process, or the producer silently depends on a specific reaction β you have built rpc with extra steps and none of rpc’s error handling.
π
4. Containers /wiki/se/implementation/containers/
a container is not a small virtual machine. it is an ordinary linux process (tree) that the kernel has been told to lie to β about what processes exist, what the filesystem looks like, what the network is, who root is β plus an accountant capping what it may consume. the lying is namespaces, the accounting is cgroups, and everything else (images, registries, orchestrators) is packaging around those two syscall families (Tanenbaum, Andrew S., 2008).
5. Wiki /wiki/
Knowledge is a paradox. The more one understand, the more one realises the vastness of his ignorance.