Monolith, microservices, CQRS and event sourcing¶
TL;DR¶
- An architecture style is a decision about deployable units and data ownership; split along team boundaries only when independent deployment pays for the network and operations it costs.
- Microservices need discovery, a gateway, timeouts and tracing, and database-per-service to be worth it; skip that step and you own a distributed monolith.
- CQRS separates the write model from query-shaped read models; event sourcing stores the events that produced the state, so you can replay, audit and add read models.
Core concepts¶
Every style answers one question: how many deployable units, and which unit owns which data. Discovery, gateways, meshes, sagas and CQRS are the bill for answering "more than one".
Monolith, modular monolith, microservices¶
A monolith is one deployable on one database: calls are in-process (~100 ns against ~500 µs for a same-datacenter round trip, 5,000x), a business operation is one transaction, and it scales horizontally for years. It stops scaling when the team does: fifty engineers merging into one release, deploys blocked by the slowest team. A modular monolith keeps the single deployable and enforces boundaries inside it — packages with public interfaces, a schema per module, no cross-module table access — for the ownership benefit without the network. Microservices make each module a separately deployed service owning its data.
Split for a concrete pressure: teams blocking each other's deploys, a different scaling profile (video transcoding beside a CRUD API), a blast-radius or compliance boundary (payments under PCI), a different release cadence. Conway's law says the system mirrors the organisation's communication structure, so draw team boundaries first and let services follow. Split on business capability, never on technical layer: a "validation service" is a network hop with no owner.
The moving parts a request meets once a monolith has been split.
flowchart LR
web["Web client"] --> gw["API gateway<br/>TLS, authn, rate limit"]
mob["Mobile client"] --> bff["Mobile BFF<br/>composes one screen"]
bff --> gw
gw -.->|"resolve orders"| reg[("Service registry<br/>Consul, etcd, k8s API")]
subgraph pod_o["orders pod"]
sc_o["Envoy sidecar"] --> svc_o["orders service"]
end
subgraph pod_p["payments pod"]
sc_p["Envoy sidecar"] --> svc_p["payments service"]
end
gw --> sc_o
svc_o -->|"sync gRPC via the sidecar"| sc_p
svc_o --> db_o[("orders DB")]
svc_p --> db_p[("payments DB")]
svc_o -.->|"OrderPlaced"| bus[["Kafka"]]
bus -.-> svc_n["notifications service"]
bus -.-> svc_a["analytics projector"]
cp["Mesh control plane (Istio)"] -.->|"routes, mTLS, retries"| sc_o
cp -.-> sc_p
Service discovery¶
Instances appear and vanish with every deploy and autoscale, so an address is a query, not a config value. A registry (Consul, etcd, ZooKeeper, the Kubernetes API) maps a service name to its healthy instances, kept fresh by heartbeats or health checks. Client-side: the caller's library queries the registry and balances load itself — no extra hop, but a library per language and stale caches that hit dead instances. Server-side: the caller hits a stable virtual IP that consults the registry (a Kubernetes Service, an ALB target group) — one extra hop, ~500 µs, nothing in clients. DNS with short TTLs is simplest and weakest: resolvers cache and pooled connections never re-resolve. Let the orchestrator register instances; a crashed process cannot deregister itself.
API gateway vs BFF¶
The gateway is the single entry point for cross-cutting policy — TLS termination, authentication, rate limiting, path routing — whose duties belong to Load balancing, reverse proxies and API gateways. A backend-for-frontend is a gateway per client type, owned by that client's team: it composes a screen from several services, so a mobile home screen costs one round trip instead of five serial calls at ~150 ms, 750 ms before any work. Policy in the gateway, client-shaped composition in the BFF, business logic in neither.
Sidecar and service mesh¶
A sidecar is a proxy (Envoy) beside every instance: the application talks to localhost and the sidecar does mTLS, discovery, retries, timeouts, circuit breaking, load balancing, metrics and trace propagation, identically in every language. A mesh (Istio, Linkerd) adds a control plane pushing routing rules, canary splits and policies to all sidecars. The price: two proxy hops per call, memory per pod, a platform to operate — worth it past a few dozen services in several languages, while five in one language get the same from a shared library.
Sync vs async communication¶
A synchronous call (REST, gRPC) couples availability: five services at 99.9% in series are 0.999^5 ~ 99.5% — 0.005 x 8,760 h = ~44 hours of downtime a year instead of ~9 — with a latency floor of 5 x 0.5 ms = 2.5 ms before any work, so timeouts, retry budgets and circuit breakers from day one. An asynchronous message through a broker decouples in time: the producer learns "accepted", bursts become lag, new consumers subscribe unseen (Messaging, queues and Kafka internals). It costs eventual consistency, at-least-once duplicates (hence idempotent consumers) and harder debugging (hence correlation ids and tracing, see Observability, SLOs and error budgets). Rule: synchronous for a query this request needs answered (is the card valid), asynchronous for a state change others react to (order placed: notify, reserve, count).
One order: a synchronous charge through the sidecar, then an event for everyone else.
sequenceDiagram
autonumber
participant C as Mobile BFF
participant G as API gateway
participant O as Orders service
participant E as Orders sidecar (Envoy)
participant P as Payments service
participant K as Kafka
participant N as Notifications service
C->>G: POST /orders with JWT and idempotency key
G->>G: authenticate, rate limit, route by path
G->>O: create order
O->>E: charge card (localhost)
E->>E: resolve payments, mTLS, timeout, one retry
alt payments healthy
E->>P: POST /charges
P-->>E: 201 charged
E-->>O: 201
O->>O: order row + outbox row in one transaction
O-->>G: 201 order id
G-->>C: 201
O-)K: OrderPlaced (relayed from the outbox)
K-)N: OrderPlaced
N->>N: send receipt, idempotent on order id
else payments times out twice
E-->>O: 503, circuit opens
O-->>G: 503 with Retry-After
G-->>C: 503, retry later with the same key
end
Database-per-service and the distributed monolith¶
Each service owns its schema; others get its data through its API or events, never its tables. Three consequences: no cross-service joins (denormalise from events, or compose in a BFF); no cross-service transactions (sagas and the transactional outbox, see Transactions, 2PC, sagas and idempotency); no ad-hoc reporting (a read model or a warehouse fed by events).
Skip the data split and you get the distributed monolith: services share a schema, a feature needs four pull requests deployed in order, every request crosses six synchronous hops — the network, the operations and the partial failures for none of the independence. The fix is rarely more services; it is merging back or redrawing data-ownership boundaries.
CQRS¶
Command Query Responsibility Segregation separates the model that changes state from the models that answer queries. The write model is small, normalised and enforces invariants; each read model is denormalised for one query — a table per screen, a search index, a cache — and kept current from the write model's events, so reads lag by the projector's backlog: milliseconds when healthy. The motivation is asymmetry: reads outnumber writes 10:1 in a social product and 100:1 in a URL shortener, and want other indexes. The costs are two models to keep in sync and a read-your-writes gap. A CRUD admin screen gains nothing from either.
The write side appends facts; the read side is rebuilt from them.
flowchart LR
cmd["Command<br/>withdraw 30.00"] --> handler["Command handler"]
handler -->|"load snapshot + events after it"| es[("Event store<br/>one stream per account")]
handler --> agg["BankAccount aggregate<br/>replay, validate, raise event"]
agg -->|"append v5 if the stream is at v4"| es
es -->|"global log with positions"| proj_s["Summary projector<br/>checkpoint = position"]
es --> proj_a["Audit projector<br/>added later, rebuilt from position 0"]
proj_s --> rm_s[("Read model<br/>one row per account")]
proj_a --> rm_a[("Read model<br/>large movements")]
qry["Query<br/>balance of acc-1"] --> rm_s
es -.->|"every 100 events"| snap[("Snapshots")]
snap -.-> handler
Event sourcing: store, snapshots, projections, replay¶
Event sourcing stores what happened to an aggregate — AccountOpened, MoneyDeposited — in an append-only stream and folds the events into state; the balance is a computation, not a column. Writes are optimistic, as the diagram above shows: replay the stream, validate the command, append events naming the version you expect, reload and retry if another writer got there first. The store needs a uniqueness constraint on (stream_id, version) and a global position for subscribers: a relational table does it, Kafka does not.
Snapshots bound the load cost: persist state every N events (100 in the demo) and a load is one snapshot plus the tail. Projections are CQRS read models fed by the global log, each with a checkpoint so redelivery is harmless, and a new question — every movement over 50.00 — is a new projector replayed from position 0: the audit trail, the temporal query and the bug fix in one mechanism. Size it: 10M accounts x 50 events a year x ~200 B is 100 GB a year, so a decade fits one server's 2-20 TB disk and a year replays from SSD at ~2 GB/s in ~50 s. The costs: events are immutable, so mistakes get compensating events, never edits; schemas are versioned and upcast; privacy deletion needs crypto-shredding; and the write side answers no ad-hoc queries.
Strangler fig and serverless¶
Migrate a monolith behind a routing facade: move one capability at a time into a new service, shift its traffic — a percentage, then all — and delete the old copy. Data is the hard part: the new service must own its tables, so dual writes or change data capture until cutover. Serverless functions (Lambda, Cloud Functions) are the far end: a function per event, scaled to zero, billed per invocation, suiting bursty, short, stateless work — thumbnails, webhooks, glue — at the cost of cold starts, execution limits, per-invocation price at volume and connection-pool pressure on databases.
Trade-offs¶
| Style | Deploy unit | Internal call | Data and transactions | Team scaling | Operations | Best fit |
|---|---|---|---|---|---|---|
| Monolith | one | in-process, ~100 ns | one database, ACID | one codebase, merge contention | one service to run | new products, small teams |
| Modular monolith | one, enforced module boundaries | in-process | one database, a schema per module | module ownership per team | one service to run | most teams under ~50 engineers |
| Microservices | one per service | network, ~500 µs plus serialisation | database per service, sagas and outbox | independent deploys per team | discovery, gateway, mesh, tracing, on-call per service | many teams, different scaling profiles |
| Serverless functions | one per function | network or events | managed stores, no long transactions | tiny units, fast iteration | no servers, but limits and cold starts | bursty, short, stateless work |
Start as a modular monolith and say so: one deployable, strict module boundaries, a schema per module, events on an in-process bus. That is one step from microservices — move the module whose team, load or blast radius demands it when the pressure is real. Choose microservices from day one only when the organisation already is one: several teams, separate on-call and release trains. When you split, split the data with the code, use synchronous calls for queries and events for state changes, and budget for the platform: discovery, gateway, timeouts, retries, tracing, common defaults. Apply CQRS where one aggregate is read in several shapes at volume — a product page, a timeline, a dashboard — and event sourcing where history is the product: ledgers, orders, inventory movements. For plain CRUD a table with an updated_at column is the right answer, and the interviewer knows it.
Python implementation¶
code/hld/event_sourcing.py is a bank account stored as events — frozen dataclasses, with StoredEvent adding stream version and global position:
@dataclass(frozen=True, slots=True)
class AccountOpened:
owner: str
@dataclass(frozen=True, slots=True)
class MoneyDeposited:
amount: Money
@dataclass(frozen=True, slots=True)
class MoneyWithdrawn:
amount: Money
@dataclass(frozen=True, slots=True)
class AccountClosed:
reason: str
type DomainEvent = AccountOpened | MoneyDeposited | MoneyWithdrawn | AccountClosed
@dataclass(frozen=True, slots=True)
class StoredEvent:
"""An event as the store keeps it: stream-local version plus a global position."""
stream_id: str
version: int # 1-based position inside the stream
position: int # 1-based position in the global log, the projections' checkpoint
timestamp: float
event: DomainEvent
def describe(event: DomainEvent) -> str:
match event:
case AccountOpened(owner=owner):
return f"Opened({owner})"
case MoneyDeposited(amount=amount):
return f"Deposited({amount.cents / 100:.2f})"
case MoneyWithdrawn(amount=amount):
return f"Withdrawn({amount.cents / 100:.2f})"
case AccountClosed(reason=reason):
return f"Closed({reason})"
BankAccount keeps commands and state changes apart: deposit, withdraw and close validate the invariant and raise an event, _apply alone mutates state, so replay rebuilds the aggregate from a snapshot plus later events:
@dataclass(frozen=True, slots=True)
class AccountSnapshot:
"""The aggregate's state at ``version``; loading starts here instead of at event 1."""
account_id: str
version: int
owner: str
balance: Money
closed: bool
@dataclass(slots=True)
class BankAccount:
"""The aggregate. Commands validate and raise events; ``_apply`` only mutates state.
``version`` counts applied events, so after loading it equals the stream version, which
is the number the repository hands to the store as the expected version on save.
"""
account_id: str
owner: str = ""
balance: Money = field(default_factory=lambda: Money(0))
closed: bool = False
version: int = 0
_pending: list[DomainEvent] = field(default_factory=list)
# -- commands: validate the invariant, then raise the event --------------------------
@classmethod
def open(cls, account_id: str, owner: str) -> BankAccount:
if not account_id or not owner:
raise ValidationError("account id and owner must be non-empty")
account = cls(account_id)
account._raise(AccountOpened(owner))
return account
def deposit(self, amount: Money) -> None:
self._require_open()
self._require_positive(amount)
self._raise(MoneyDeposited(amount))
def withdraw(self, amount: Money) -> None:
self._require_open()
self._require_positive(amount)
if amount > self.balance:
raise InvalidStateError(f"insufficient funds: balance {self.balance}")
self._raise(MoneyWithdrawn(amount))
def close(self, reason: str) -> None:
self._require_open()
if not self.balance.is_zero():
raise InvalidStateError(f"close requires a zero balance, have {self.balance}")
self._raise(AccountClosed(reason))
def _require_open(self) -> None:
if self.version == 0:
raise InvalidStateError(f"account {self.account_id!r} does not exist")
if self.closed:
raise InvalidStateError(f"account {self.account_id!r} is closed")
@staticmethod
def _require_positive(amount: Money) -> None:
if amount.cents <= 0:
raise ValidationError("amount must be positive")
# -- events: the only way state changes ----------------------------------------------
def _raise(self, event: DomainEvent) -> None:
self._apply(event)
self._pending.append(event)
def _apply(self, event: DomainEvent) -> None:
match event:
case AccountOpened(owner=owner):
self.owner = owner
case MoneyDeposited(amount=amount):
self.balance = self.balance + amount
case MoneyWithdrawn(amount=amount):
self.balance = self.balance - amount
case AccountClosed():
self.closed = True
self.version += 1
@classmethod
def replay(
cls,
account_id: str,
events: Iterable[DomainEvent],
snapshot: AccountSnapshot | None = None,
) -> BankAccount:
"""Rebuild the aggregate: start from the snapshot (if any), fold the events after it."""
account = cls(account_id)
if snapshot is not None:
if snapshot.account_id != account_id:
raise ValidationError("snapshot belongs to another account")
account.owner, account.balance = snapshot.owner, snapshot.balance
account.closed, account.version = snapshot.closed, snapshot.version
for event in events:
account._apply(event)
return account
@property
def pending_events(self) -> tuple[DomainEvent, ...]:
return tuple(self._pending)
def mark_committed(self) -> None:
self._pending.clear()
def snapshot(self) -> AccountSnapshot:
return AccountSnapshot(self.account_id, self.version, self.owner, self.balance, self.closed)
EventStore.append checks the expected version and appends under one lock, making the concurrency optimistic rather than hopeful; AccountRepository turns that into load-and-save with snapshots:
class EventStore:
"""Append-only streams with optimistic concurrency and a global log.
``_lock`` guards ``_streams``, ``_log`` and ``_snapshots``. The check "is the stream still
at the version the writer saw?" and the append happen under the same lock, which is the
whole point: two writers that loaded version 4 cannot both append version 5.
"""
def __init__(self, clock: Clock | None = None) -> None:
self._clock = clock or FakeClock()
self._streams: dict[str, list[StoredEvent]] = {}
self._log: list[StoredEvent] = []
self._snapshots: dict[str, AccountSnapshot] = {}
self._lock = threading.Lock()
def append(
self, stream_id: str, events: Sequence[DomainEvent], expected_version: int
) -> list[StoredEvent]:
"""Append ``events`` if the stream is at ``expected_version``; ``ConflictError`` if not."""
if not events:
raise ValidationError("nothing to append")
if expected_version < 0:
raise ValidationError("expected_version must be >= 0")
with self._lock:
stream = self._streams.setdefault(stream_id, [])
if len(stream) != expected_version:
raise ConflictError(
f"stream {stream_id!r} is at version {len(stream)}, expected {expected_version}"
)
now = self._clock.now()
stored: list[StoredEvent] = []
for offset, event in enumerate(events, start=1):
record = StoredEvent(
stream_id, expected_version + offset, len(self._log) + 1, now, event
)
stream.append(record)
self._log.append(record)
stored.append(record)
return stored
def load(self, stream_id: str, after_version: int = 0) -> list[StoredEvent]:
with self._lock:
return self._streams.get(stream_id, [])[after_version:]
def version(self, stream_id: str) -> int:
with self._lock:
return len(self._streams.get(stream_id, []))
def read_all(self, after_position: int = 0) -> list[StoredEvent]:
"""The global log after ``after_position``: what a projection subscribes to."""
with self._lock:
return self._log[after_position:]
def save_snapshot(self, snapshot: AccountSnapshot) -> None:
with self._lock:
current = self._snapshots.get(snapshot.account_id)
if current is None or snapshot.version > current.version:
self._snapshots[snapshot.account_id] = snapshot
def snapshot(self, stream_id: str) -> AccountSnapshot | None:
with self._lock:
return self._snapshots.get(stream_id)
class AccountRepository:
"""Load = snapshot + events after it; save = append with the expected version."""
def __init__(self, store: EventStore, snapshot_every: int = 100) -> None:
if snapshot_every <= 0:
raise ValidationError("snapshot_every must be positive")
self._store = store
self._snapshot_every = snapshot_every
def load(self, account_id: str) -> BankAccount:
snapshot = self._store.snapshot(account_id)
after = snapshot.version if snapshot else 0
events = self._store.load(account_id, after_version=after)
if snapshot is None and not events:
raise NotFoundError(f"account {account_id!r} does not exist")
return BankAccount.replay(account_id, (e.event for e in events), snapshot)
def save(self, account: BankAccount) -> list[StoredEvent]:
pending = account.pending_events
if not pending:
return []
expected = account.version - len(pending)
stored = self._store.append(account.account_id, pending, expected_version=expected)
account.mark_committed()
last = self._store.snapshot(account.account_id)
if account.version - (last.version if last else 0) >= self._snapshot_every:
self._store.save_snapshot(account.snapshot())
return stored
Projections consume the global log with a checkpoint, so redelivered events are skipped and rebuild replays from position 0 for a read model that did not yet exist:
class Projection(ABC):
"""A read model fed by the global log. One projector thread owns it (like one member of a
consumer group), so it needs no lock; ``checkpoint`` makes redelivery harmless."""
def __init__(self) -> None:
self.checkpoint = 0
def apply(self, stored: StoredEvent) -> bool:
"""Apply one event; ``False`` if it was already applied (at-least-once delivery)."""
if stored.position <= self.checkpoint:
return False
self._handle(stored)
self.checkpoint = stored.position
return True
def catch_up(self, store: EventStore) -> int:
return sum(self.apply(stored) for stored in store.read_all(after_position=self.checkpoint))
def rebuild(self, store: EventStore) -> int:
"""Throw the read model away and replay the full history: how a new model is born."""
self._reset()
self.checkpoint = 0
return self.catch_up(store)
@abstractmethod
def _handle(self, stored: StoredEvent) -> None: ...
@abstractmethod
def _reset(self) -> None: ...
@dataclass(slots=True)
class AccountSummary:
owner: str
balance: Money = field(default_factory=lambda: Money(0))
deposits: int = 0
withdrawals: int = 0
closed: bool = False
class AccountSummaryProjection(Projection):
"""One row per account, shaped for the query side: no replay needed to read a balance."""
def __init__(self) -> None:
super().__init__()
self.rows: dict[str, AccountSummary] = {}
def _reset(self) -> None:
self.rows = {}
def _handle(self, stored: StoredEvent) -> None:
match stored.event:
case AccountOpened(owner=owner):
self.rows[stored.stream_id] = AccountSummary(owner)
case MoneyDeposited(amount=amount):
row = self.rows[stored.stream_id]
row.balance, row.deposits = row.balance + amount, row.deposits + 1
case MoneyWithdrawn(amount=amount):
row = self.rows[stored.stream_id]
row.balance, row.withdrawals = row.balance - amount, row.withdrawals + 1
case AccountClosed():
self.rows[stored.stream_id].closed = True
def top_balances(self, limit: int = 3) -> list[tuple[str, Money]]:
ranked = sorted(self.rows.items(), key=lambda kv: (-kv[1].balance.cents, kv[0]))
return [(account_id, row.balance) for account_id, row in ranked[:limit]]
class LargeMovementsProjection(Projection):
"""An audit model nobody asked for at design time, built later from the same history."""
def __init__(self, threshold: Money) -> None:
super().__init__()
self._threshold = threshold
self.movements: list[tuple[str, str, Money]] = [] # (account, kind, amount)
def _reset(self) -> None:
self.movements = []
def _handle(self, stored: StoredEvent) -> None:
match stored.event:
case MoneyDeposited(amount=amount) if amount >= self._threshold:
self.movements.append((stored.stream_id, "deposit", amount))
case MoneyWithdrawn(amount=amount) if amount >= self._threshold:
self.movements.append((stored.stream_id, "withdrawal", amount))
uv run python -m hld.event_sourcing prints:
acc-1: 4 events appended, balance 82.50 USD, version 4
stream acc-1: v1=Opened(ann) v2=Deposited(100.00) v3=Withdrawn(30.00) v4=Deposited(12.50)
withdraw 500.00 rejected (insufficient funds: balance 82.50 USD); nothing appended, version 4
optimistic concurrency: stream 'acc-1' is at version 5, expected 4
replay 5 events from scratch: balance 83.50 USD, version 5, same as the live aggregate: True
acc-2 after 121 events: snapshot at v100, load replays 21 events instead of 121
summary projection: 126 events up to position 126: acc-1=83.50 USD, acc-2=120.00 USD
redeliver the last 3 events: 0 applied (the checkpoint skips them)
acc-3: opened, moved 75.00 twice and closed: 4 new events; top balances acc-2=120.00 USD, acc-1=83.50 USD
new audit projection from 130 historical events: acc-1:deposit:100.00 USD acc-3:deposit:75.00 USD acc-3:withdrawal:75.00 USD
Note the fourth line: the second writer loaded version 4, so its append is refused once the stream is at 5. The audit projection arrives last and still sees the first deposit.
In the interview¶
Name the style with its reason as you draw the first box: "I'll start as a modular monolith with orders, payments and inventory as modules with their own schemas, and split payments out first for its compliance boundary and load profile."
Phrases that signal depth: "split on business capability, never on technical layer"; "database per service, so cross-service writes are sagas through an outbox"; "the read model is a projection, rebuildable from zero".
How do services find each other after a deploy changes every IP?
Through a registry fed by the orchestrator, not the instances. Server-side discovery costs one ~500 µs hop and keeps clients dumb; client-side saves the hop and costs a library per language.
Two services need to update their databases atomically. What do you do?
Nothing atomic exists across them. I write the local change and an outbox row in one transaction, relay the event, and make the consumer idempotent; if the second step can fail for business reasons it is a saga with a compensation.
Why not use Kafka as the event store?
Loading one aggregate means reading its events by key, and a topic can only be scanned by partition; compaction keeps the last value per key, destroying history. Kafka carries events to the projections; it is not the store.
A read model shows a stale balance right after a deposit. How do you handle it?
Return the new state from the command response, or route the writer's next read to the write model for a short window; alert on the projector's checkpoint falling behind.
Your team of six engineers wants twelve microservices. What do you say?
That we would ship a distributed monolith: twelve deploys, twelve on-call rotations and six-hop requests for one team's worth of change. Six modules in one deployable, split when a concrete pressure appears.
Interview tip
Volunteer the cost of every box you draw across a network: a 500 µs hop, a failure mode, an owner. Candidates who add services freely look junior; "this stays in the monolith until X" sounds like a veteran.
Common mistakes¶
- Splitting by technical layer: a "user service" every request touches becomes a synchronous dependency of everything. Fix: split by business capability, each owning its data.
- Shared database behind "microservices": schema changes couple deploys and the network buys nothing. Fix: database per service, with events or APIs for others.
- Synchronous call chains: five hops at 99.9% are 99.5% available with a 2.5 ms floor. Fix: events for state changes, timeouts and circuit breakers for the rest.
- Editing events: correcting a stored fact in place breaks every projection that already consumed it. Fix: append a compensating event and replay.
- CQRS and event sourcing everywhere: two models and a replay pipeline for an admin CRUD screen. Fix: reserve them for aggregates with many read shapes or an audit need.
Common mistake
Proposing microservices as the answer to "how would you scale this?" A monolith behind a load balancer scales to ~1k QPS per app server times as many servers as you like; microservices scale teams and blast radius, not throughput. Name the pressure, or do not split.
Self-check¶
What does a modular monolith give you that a monolith does not, and what does it withhold?
Enforced ownership boundaries, a schema per module and a clean path to extraction; it withholds independent deployment and per-module scaling.
Client-side vs server-side discovery: one cost of each?
Client-side: a discovery library per language and stale client caches. Server-side: one extra hop, ~500 µs, and a load balancer to keep highly available.
What does the expected version in an event-store append protect against?
Two writers that loaded the same state both appending: the second names a version the stream has passed and is refused, so no invariant is persisted from stale state.
How does a projection survive at-least-once delivery?
It stores the global position of the last event it applied and skips anything at or below it, so a redelivered batch changes nothing.
Name three symptoms of a distributed monolith.
A feature needs several repositories deployed in order; services share tables or a schema; most requests cross a long chain of synchronous calls, so one slow service degrades all.
Related¶
- Messaging, queues and Kafka internals — the transport between services and to projections
- Load balancing, reverse proxies and API gateways — gateway duties and health checks
- Transactions, 2PC, sagas and idempotency — sagas and the outbox once data is split
- Observability, SLOs and error budgets — tracing and correlation ids across hops
- Deployments, feature flags and data migrations — moving data behind the strangler facade
- Conway, "How Do Committees Invent?" (Datamation, 1968)
- Fowler, "Strangler Fig Application" (martinfowler.com, 2004)
- Young, "CQRS Documents" (2010)