Five services synchronously calling each other, each with 99.9% availability, yield 99.5%. Splitting a monolith doesn't add reliability in itself — it takes it away, and you have to restore it separately.
Why split a monolith then? Independent deployment, separate scaling of the high-load parts, boundaries of responsibility between teams. The patterns below are about how to get that and what you pay for it. Strangler Fig and API Gateway, Service Mesh and Sidecar, Database per Service, CQRS, Event Sourcing.
Strangler Fig
Strangler Fig is a way to rewrite a monolith without ever stopping it: a facade is placed in front of it, and functionality moves out of the monolith into new services piece by piece until nothing is left. The name was coined by Martin Fowler — a strangler fig grows into the fork of another tree and gradually takes its place.
Three stages. The facade takes all traffic from day one and gradually moves it from the monolith to the microservices: the monolith's share drops until it stops receiving requests at all. After the migration the facade is usually not thrown away — it stays as the system's entry point.
Where to start. The first piece you extract isn't the most important one, it's the most convenient: few incoming dependencies, its own data boundary, noticeable but not critical traffic. Usually that's something at the edge of the system — notifications, report generation, search, export. The domain core, where all the transactions converge, is a bad first candidate: you can't extract it without untangling all the data at once, and that's exactly where migrations tend to stall. The point of the first step isn't to get a valuable service, it's to have the team walk the whole path once — extraction, deployment, monitoring, rollback — on something they can afford to break.
Where to draw the boundary. By domain contexts, and there's a single test for a correct one: the business operation fits entirely inside one service and doesn't require a distributed transaction. If placing an order means synchronously calling three new services, the boundary is wrong — you've reproduced the couplings you had in the monolith and added a network between them.
What to do about the data. Forwarding requests is the easy part. The hard part is that the extracted service needs data that still lives in the monolith's database, and for the duration of the migration — months — someone has to own it. There are three options, and you need to pick one before the first traffic switch.
- The new service reads the monolith's database. Fast to start, but you get a distributed monolith: two applications coupled through one database schema, and the "shrinking" usually stops right there. A temporary measure for one release cycle, no longer.
- Data is copied into the new service's database, the source of truth stays in the monolith. Synchronization is one-way — through events or by reading the transaction log (CDC). The new service can only read; all changes still go through the monolith. This is a working intermediate state you can live in for a long time.
- Ownership is handed to the new service, and the monolith calls it through an API. The final state. Getting there requires a short dual-write window and a reconciliation afterwards — the most delicate operation in the whole migration, and one you do for a single service at a time.
Rollback has a limit worth understanding. While the new service only reads, moving traffic back to the monolith is a route switch in the facade, a matter of seconds. Once the service owns the data, rollback in that sense no longer exists: it has accumulated changes that don't exist in the monolith, and "moving the load back" means a reverse data migration, not a config edit. So the ownership handover is planned separately — with a window, a reconciliation, and a return procedure written in advance.
When the migration is done — and why it often isn't. The pattern has a characteristic failure that gets discussed less than the pattern itself: in the first year 60% of the functionality moves out, the remaining 40% turns out to be the most entangled, budget and interest run out — and the company is left forever with a monolith, microservices, and a facade in between, meaning both systems and the cost of operating both. This isn't a hypothetical risk, it's the most likely outcome if nobody watches for it.
The antidote is organizational: from day one, measure the share of what's still in the monolith — by endpoints, by traffic, by tables, it doesn't matter as long as it's always the same way — and require that number to move every quarter. If it hasn't changed in a quarter, the migration has stopped, and that calls for a decision. "We're stopping here deliberately, the rest of the monolith stays forever, and so does the facade" is a perfectly acceptable decision, as long as it's made explicitly.
The facade isn't a free component. All of the system's traffic goes through it from day one, which makes it a single point of failure with everything that follows: fault tolerance, clustering, monitoring, a dedicated owner. The same requirements are covered below in the API Gateway section — and that's not a coincidence: the Strangler Fig facade is usually the system's future API gateway, just early in its life. It's worth choosing it with that in mind rather than standing up a temporary proxy you'll have to replace.
API Gateway
The API gateway is the only address the clients know. It takes an external request, decides which service should handle it, and returns the response; the internal topology is invisible from outside.
Clients talk to a single gateway instead of the services directly. It terminates external HTTPS, handles authentication, rate limiting, caching and logging, and then calls the internal services over their own protocols — REST or gRPC. The external contract doesn't depend on how the internal network is arranged.
The gateway can fan a single external request out into several internal ones and assemble the response: an order page is put together from the orders, delivery and reviews services in one client call instead of three.
But only reads can be assembled this way. An operation that changes state across several services — placing an order together with charging the payment — can't be split the same way: if the charge went through and the order service didn't answer, the gateway has nothing to roll back with, no shared transaction and no compensations. You're left with "money charged, no order." Distributed changes are Saga's job, not the gateway's.
Beyond routing, a gateway usually takes on cross-cutting concerns: TLS termination, logging, caching, rate limiting, protocol translation from external REST into internal gRPC. One of them deserves a separate note.
Authentication at the gateway doesn't mean nobody checks further down. The gateway authenticates the external caller and turns its token into an internal context, but the services themselves are obliged to verify that context. Otherwise anyone who ends up inside the perimeter — a neighbouring service, a compromised pod, a contractor on the same network — gets the privileges of any user.
How much of this you write yourself depends on what's already there. In Kubernetes, external routing and balancing between replicas are already covered by the platform, and a separate gateway is only needed for aggregation, public API versioning and managing keys for external consumers. One idea worth borrowing from there regardless: the platform team owns the entry point, the service teams own their routes. Otherwise the shared configuration quickly becomes nobody's.
Pick the versioning scheme up front. There are three. A version in the path (/v2/orders) is the easiest to debug and cache, but the version leaks into the resource address. A version in a header keeps the address clean, but it's invisible in logs and the request is harder to reproduce by hand. Content negotiation via Accept is formally the most correct and the rarest in practice. Most public APIs take the first.
The cost of a gateway isn't only the single point of failure. That the whole system becomes unreachable when the gateway goes down is obvious — hence clustering, a dedicated owner, and monitoring that isn't tied to the services behind it. Throughput is the less obvious part. On a synthetic setup where an nginx gateway and the service run in containers on the same machine — so there's no network between them at all — proxying added roughly 0.4 ms per request, and throughput dropped threefold: from ~220k to ~75k requests per second. Latency is rarely the critical part; a threefold difference means you'll have to scale the gateway before the services behind it.
Backend for Frontend is the variation where each client type gets its own gateway. You introduce it once the data sets for web and mobile have genuinely diverged. The price is that cross-cutting logic — authorization, rate limiting, logging — gets duplicated across gateways and starts drifting apart over time, which is why BFFs are created in response to actual divergence rather than pre-emptively for each platform.
Service Mesh
A service mesh and an API gateway aren't competitors, they're different traffic axes, and that's worth separating right away. The gateway handles north-south: entry from outside, external clients, their authentication, the public contract. The mesh handles east-west: calls between your own services inside the perimeter, where there are no external clients. A large system runs both, and a request path looks like this: client → gateway → service A's proxy → service B's proxy → service B. Istio can also cover north-south with its ingress gateway, which is why on plain Kubernetes the two layers sometimes collapse into one product.
Two traffic axes. The API gateway owns north-south — entry from outside. The service mesh owns east-west: a proxy next to each service intercepts the calls, encrypts them between proxies over mTLS, and gets its configuration from the control plane. The services themselves talk to their own proxy over localhost and don't know the mesh exists.
A mesh is built as a distributed system of proxies. The data plane is a proxy next to each service intercepting all inbound and outbound traffic; the services "think" they're talking directly to each other. The control plane hands them configuration, issues and rotates certificates, and collects telemetry. Rules like "10% of traffic to v2" or "turn on mTLS everywhere" are set through it — but not by hand: they're described as Kubernetes resources, kept in a repository and delivered through CI, otherwise you lose the main thing, reproducibility.
Since 2024 Istio has a second model, ambient: one shared ztunnel proxy per node covering L4 — mutual TLS, authorization, metrics — with an L7 proxy brought up only where retries, traffic splitting and HTTP parsing are actually needed.
Because all traffic goes through a proxy, a mesh can do whatever a proxy can: balance, split traffic across versions for canaries, retry failed calls, trip the circuit, record the latency and status of every call. None of it requires touching the services.
But that's usually not why a mesh gets adopted. There's normally a single reason: the requirement to encrypt traffic inside the perimeter comes from security or a regulator, and a mesh satisfies it without changing the applications — the proxies bring up mTLS between themselves and the services never know.
There's one thing a mesh doesn't cover, though people expect it to. It forwards tracing headers between services, but it can't carry them through your code: if a service accepts a request and moves on without copying those headers into its outgoing call, the chain breaks. Free tracing doesn't exist; a minimal change to the applications is required anyway.
The overhead is worth knowing before you adopt it. By Istio's own measurements, at 1,000 requests per second with a 1 KB payload and mTLS enabled, a single sidecar proxy costs 0.20 vCPU and 60 MB of memory — per pod. Across two hundred pods that's 40 vCPU and 12 GB spent on infrastructure alone. Ambient in the same configuration costs 0.06 vCPU and 12 MB per node.
A misconfigured control plane breaks communication across the entire system at once rather than in a single service, and nobody except the people who set the mesh up will be able to sort it out. A practical guideline: fewer than two dozen services and nobody demanding encryption inside the perimeter — an API gateway plus a retry library with timeouts will do.
Sidecar
A sidecar is a helper process living next to the application and taking on everything that isn't business logic: outbound TLS, shipping logs, fetching configuration. In Kubernetes it's a second container in the same pod, and the two talk over localhost.
A sidecar runs in the same pod as the application and shares its lifecycle. The application talks to it over localhost in the clear; outbound, the sidecar speaks HTTPS and is the one responsible for certificate verification, retries and log shipping. The business logic knows nothing about TLS or the log server.
A sidecar most often plays one of two roles, and both have names. Ambassador faces outward: it represents the application on the external network and takes on TLS, retries, protocol translation — the application sends plain HTTP to localhost and the sidecar establishes HTTPS. Adapter faces inward: it converts what the application emits into the format the platform expects. The classic example is a metrics exporter — the application publishes metrics its own way and the sidecar turns them into something Prometheus can read.
What gets reused is the image, not the instance. The same image is attached to different applications, but every pod always has its own copy running.
A sidecar's independence is easy to overestimate. Its behaviour genuinely does change through configuration without rebuilding the application — a new retry policy or a new log server address doesn't touch the code. But you can't update the sidecar's image without touching the application: in Kubernetes any change to the pod spec, including swapping the image of one container, recreates the whole pod. You can't scale it separately either — a sidecar lives one-to-one with an application instance by definition and is addressed over localhost.
A sidecar has to start before the application and stop after it. Otherwise the application loses its first requests at startup and its last ones at shutdown. Ordinary containers in a pod give you no such guarantee: their start and stop order is undefined, and the race where the application comes up before the proxy is a classic problem in early service mesh rollouts. The correct mechanism is to declare the sidecar in initContainers with restartPolicy: Always — then the kubelet guarantees it is running before the main container starts and stopped after it exits.
And limits. A memory limit set too low kills not the sidecar but the pod's entire network path: the application stays alive and simply stops being able to reach anything.
Here's the example where this kind of sidecar most often breaks. A Python application makes an ordinary request:
response = requests.get("http://localhost:8080/api/data")
And next to it sits nginx, taking that in the clear and going out over HTTPS:
location / {
proxy_pass https://external-service:443;
proxy_ssl_server_name on; # off by default — SNI is not sent
proxy_ssl_verify on; # off by default — the certificate is not checked
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_ssl_verify_depth 2; # 1 by default
proxy_ssl_protocols TLSv1.2 TLSv1.3;
}
The first two directives are mandatory, and both are off by default. Without proxy_ssl_server_name on the sidecar doesn't send SNI, and any host behind a CDN or behind a load balancer serving several certificates will abort the handshake: the application gets a 502 and the log shows SSL_do_handshake() failed ... tlsv1 unrecognized name. Without proxy_ssl_verify on the sidecar doesn't check the external service's certificate at all — it will accept a self-signed one issued to somebody else's name and hand the application a perfectly honest 200.
Hence a rule worth keeping in mind whenever you use a sidecar: moving a function out of the application and into a sidecar means you inherit the sidecar's defaults, not the defaults of the library you used before. Python's requests verifies certificates by default; nginx as a reverse proxy does not. Naively moving TLS into a sidecar doesn't raise security, it lowers it — and there's no way to notice without a deliberate check, because from the outside everything looks like it's working.
Service mesh proxies are sidecars, so everything above about start-up order and limits applies to them first of all.
Database per Service
Every service has its own database and never touches anyone else's directly — only through the owner's API. This isn't about a physically separate server: several services can live inside one PostgreSQL instance in separate schemas their neighbours can't reach. What matters isn't hardware isolation, it's that you can change the schema without asking anyone.
Each service owns its database and is the only one that touches it. Services exchange data through their APIs, and reaching directly into someone else's database is forbidden: that's exactly what turns a set of microservices into a distributed monolith where you can't change a schema without breaking a neighbour.
One expectation is worth correcting straight away. Splitting the databases doesn't isolate failures by itself: if service A calls B synchronously and without a timeout, B's failed database will take A down with it — first the worker threads run out, then the connections, and the failure travels up the call chain. Isolation appears when the calling side has timeouts and a circuit breaker; splitting the databases merely makes it possible.
Where to draw the boundary. Local transactions only pay off if the business operation fits entirely inside the service. The criterion: everything that must change together and be checked for consistency at the moment of the change has to end up inside one service. If enforcing "you can't order an item that isn't in stock" requires a synchronous look into somebody else's database, the boundary is wrong — and from there you'll either break isolation or build a saga where there needn't have been one.
You can't JOIN across services. The data you need is either fetched over an API or kept locally as a projection the service maintains by listening to the owner's events. And here's the trap almost everybody falls into: if the owner first writes the change into its database and then publishes an event in a separate call, that's a write into two systems without a shared transaction. If the broker goes down between the two calls, the change exists and the event doesn't — subscribers never learn about it, and no error is raised anywhere. The fix is Transactional Outbox: the event is written into the same database, in the same transaction as the change itself, into a separate table, and a separate process reads that table and publishes to the broker.
Transactions are local now. Consistency between services becomes eventual, achieved through events or sagas. A saga breaks a business operation into a chain of local transactions, each publishing an event that triggers the next step. There's no rollback in the usual sense: instead, every step gets a compensating action. Not "undo the charge" but "issue a refund" — a compensation is an ordinary business operation, visible in the history, not a rollback that erases its own traces.
Connections run out before the database's resources do. This is the limit you actually hit in practice, and it isn't obvious. Every replica holds its own pool and the totals multiply: twenty replicas with a pool of ten each is two hundred connections to one database. PostgreSQL defaults to max_connections = 100, three of which are reserved for the superuser — so you'll hit the ceiling long before CPU or memory run out. The fix isn't raising max_connections (every connection costs memory and loads the planner), it's an external pooler such as PgBouncer in transaction pooling mode.
Reports that cross service boundaries are not built with API composition or nightly scripts but with CDC: a separate process reads each database's transaction log and streams the changes into an analytical store, asking nothing of the services themselves. The best-known implementation is Debezium. This is a separate architectural layer and it's better planned in advance.
One detail worth calling out: the orders service stores the item price as of the order rather than fetching it from the catalogue. That's not accidental duplication, it's a deliberate copy — for the order it's part of its own history and must not change retroactively when the price list is updated.
One database shared by several services isn't a mistake, it's a trade-off with a clear price. You get familiar ACID transactions across service boundaries and one database to operate, and you pay with schema coupling (changing a table requires coordination between teams) and runtime coupling (a long transaction in one service blocks another). In the microservice pattern catalogue, Shared Database is listed as a pattern with a list of trade-offs, not as an anti-pattern. The problem isn't that you can't do it, it's that getting out later costs more than not getting in: the longer a shared schema lives, the more code grows into it. So if a shared database is a deliberate choice, write down up front the condition under which you'll leave it.
CQRS
CQRS splits writes and reads: a command changes state and returns next to nothing, a query reads and changes nothing, and the two use different models.
A command changes state through the write model; a query reads a prepared representation from the read model. In the simple form both sides work against one database; in the advanced form they use separate stores and the read store is updated from events with a lag.
Martin Fowler wrote a separate note on the pattern, and his position is worth quoting accurately, because it's often reported backwards: he is cautious about CQRS and states plainly that it should be used with considerable care, that the domains it suits are a clear minority, and that it belongs to a single bounded context rather than to a system as a whole.
The pattern has two forms, and that distinction matters more than anything else. The simple form is one database but different models in code: a command model with invariants and checks, a query model with denormalized representations shaped for specific screens. The advanced form is separate stores: one takes the writes, the other is updated from events and serves the reads.
What follows — both in this article and in most other material — deals almost exclusively with the advanced form, which leaves the impression that CQRS necessarily means two databases and an event bus. It doesn't. The vast majority of systems are fine with the simple form: it delivers most of the benefit and drags along neither eventual consistency, nor projections, nor separate infrastructure. Separate stores get introduced when you've hit a measured limit — read replicas can no longer keep up, or writes and reads need fundamentally different guarantees. "We did it properly from the start" is a poor reason for a second database.
Once the stores are separate, a lag appears: after a command the change isn't immediately visible on the read side. There aren't many ways to live with that. A client-generated identifier lets you display the created entity right away without waiting for the projection. A command can return a version number, and a subsequent read waits until the projection catches up to it — that's read-your-writes. Finally, reads from the author of the change can be routed to the write side for a few seconds while everyone else reads from the read side. What you shouldn't do is ship an interface where the user hits "Save" and doesn't see their own data on the next screen.
Sooner or later a projection will need rebuilding — a bug in a handler, a lost event, a changed requirement. It's a routine operation and it has to be planned for. With Event Sourcing the answer is simple: wipe the projection and replay the log from the beginning. Without Event Sourcing there's no log, so you rebuild from the write model — which needs a process able to read the current state of the write database and reassemble the read tables, plus a way to tell that the projection has fallen behind or diverged: a counter of processed events and a regular reconciliation. Without such a process, the first bug in a projection gets fixed by hand, at night.
// Command model — handler for the create-order command
public class OrderCommandHandler {
private final OrderRepository repo;
private final OutboxRepository outbox;
private final TransactionTemplate tx;
public OrderCommandHandler(OrderRepository repo, OutboxRepository outbox, TransactionTemplate tx) {
this.repo = repo;
this.outbox = outbox;
this.tx = tx;
}
public void handle(CreateOrderCommand cmd) {
// 1. Validate the business rules
// 2. Build the order, add items, compute the total
Order order = new Order(cmd.getOrderId());
// ... (add items and the rest)
// 3. The order and the event are saved in ONE transaction to ONE database
tx.execute(() -> {
repo.save(order);
outbox.save(new OrderCreatedEvent(cmd.getOrderId()));
});
// 4. A separate process reads the outbox table and publishes to the broker
}
}
// Query model — handler for the get-order query
public class OrderQueryService {
private final OrderViewRepository readDb;
public OrderQueryService(OrderViewRepository readDb) {
this.readDb = readDb;
}
public OrderDto handle(GetOrderQuery query) {
// Point lookup by key against the denormalized read-model table
return readDb.findById(query.getOrderId())
.map(OrderDto::from)
.orElse(null);
}
}
Two details here answer questions that come up immediately.
The order identifier arrives in the command rather than being issued by the database. That's the answer to "how does the client learn the id if the command returns nothing?" — the client generates it, usually as a UUID. A side benefit is that the command becomes idempotent: sending it again with the same identifier won't create a second order.
The order and the event are saved in one transaction to one database. The naive version — repo.save(order) followed by eventBus.publish(...) — is the same dual write from the Database per Service section: if the broker is down between the two calls, the read model never learns about the order. Hence the outbox table and the separate process that drains it.
Splitting write and read across separate services is possible but not required — CQRS lives happily inside a single one. Splitting into services makes sense when the two sides have diverging scaling requirements, not for its own sake.
Event Sourcing
State isn't stored, it's computed: events go into a log, and an aggregate's current state is obtained by applying them in order. state = f(all past events). That's also where the ability to get the state at any point in the past comes from — replay the log up to that point.
State isn't stored: events are appended to the log, and an aggregate's current state is assembled by applying them in order. A snapshot is a cached point you can start replaying from instead of starting at zero.
The obvious benefit is a full history and audit trail, but replay has a caveat people notice late. It only answers new questions with data the events already carry. If MoneyDeposited was saved without a timestamp or without the channel of the operation, no new logic will recover them: replay reproduces history, it doesn't invent it. Hence the rule for designing events — record everything you know at the moment the event occurs, even if nobody needs it today. Disk space is cheaper than being unable to answer a question two years from now.
Events live in an event store, grouped by aggregate: every event for order 1234 is one sequence. To get the state you load them and apply them to an empty object:
Order order = new Order();
for (Event event : eventsForOrder1234) {
order.apply(event);
}
Everything hinges on apply. It's tempting to declare one overload per event type — apply(OrderCreated), apply(ItemAddedToOrder) — but that won't compile: overloads in Java are picked by the static type of the argument, and inside the loop it's always Event. So there's a single apply with the dispatch inside:
public void apply(Event event) {
switch (event) {
case OrderCreated e -> { this.id = e.orderId(); this.status = "created"; }
case ItemAddedToOrder e -> { items.add(e.item()); total = total.add(e.price()); }
case OrderShipped e -> { this.status = "shipped"; this.shippedAt = e.date(); }
default -> throw new IllegalStateException(
"Unknown event type: " + event.getClass().getName());
}
}
The default branch is mandatory, and it isn't paranoia. Six months from now a new event type shows up — say MoneyTransferredOut. Without that branch, reconstruction silently skips it: no exception, nothing in the logs, and the state comes out wrong by exactly the sum of all such events, which surfaces during a reconciliation an unknown amount of time later. Reconstruction from the log should fail on an unknown event rather than count its way to a plausible but incorrect number.
Snapshots are a periodically saved state so you don't replay a long history from scratch. Two caveats, both practical. They're needed by a minority of aggregates: if a typical aggregate has dozens of events, replaying costs a fraction of a millisecond, while a snapshot adds code and one more place where data can diverge — introduce them based on measurement. And a snapshot is a cached result of apply, so changing the logic of apply invalidates every existing snapshot: version them and be able to discard them wholesale.
Appending an event isn't just an append, and without this the whole construction is unsafe. If two processes modify the same aggregate at once, both read the same history, both decide on the basis of it, and both append their events — you end up with two debits against a balance that was only enough for one. The protection is optimistic locking by version: on read you remember the number of the last event, on write you pass it as the expected one, and the insert goes through a unique index on the (aggregate_id, version) pair. If somebody wrote in the meantime, the insert violates uniqueness, the command fails, and it has to be retried after re-reading the history.
You can't correct what's written. If an event is wrong, a compensating one is appended and the wrong one stays in the log. You can see what was recorded, when it was corrected, and by how much.
Personal data in an immutable log is a limitation people remember late, and it sits awkwardly with the very domains where Event Sourcing is recommended first. In an append-only log, a subject's request to delete their personal data (GDPR) can't be satisfied: an event can't be modified or deleted without breaking the model. The standard workaround is crypto-shredding: personal fields are written into the event encrypted, the key is stored separately and tied to the subject, and deleting the subject means deleting the key. The events stay put, the history is intact, and the personal fields can no longer be read. This has to go into the event schema from the very beginning — retrofitting encryption into an existing log is considerably more expensive.
An endlessly growing stream for a single aggregate usually means the aggregate boundary was drawn wrong. A "Product" aggregate with a million events over five years isn't a case for snapshots, it's a case for revisiting the model. Healthy aggregates have a natural end: an order closes, an account closes, a reporting period closes — and completed streams can be moved to an archive without touching the active ones.
Event schemas evolve painfully: you can't just change a field, the old events are already in the log. You either version the events or migrate the log, and migrating a log is far from trivial.
There are two kinds of events and mixing them is a mistake. The ones in the event store are internal, domain events — designed for reconstructing aggregate state and a private detail of the service. What gets published outward are separate integration events: coarser, more stable, with a versioned schema. Publish the internal ones directly and every subscriber becomes coupled to your aggregate's internal model, at which point event versioning stops being an internal concern of the service and turns into release coordination between teams.
Conclusion
What all seven have in common isn't the problems they solve, it's the exact place where they defy expectation. Splitting databases doesn't isolate failures — timeouts and a circuit breaker on the calling side do. Authentication at the gateway doesn't mean the services can skip checking. A sidecar can't be updated or scaled separately from its application. CQRS doesn't require two databases. A mesh doesn't give you free tracing. In Strangler Fig, rollback exists right up until data ownership changes hands. Every one of these corrections costs more than the pattern itself, because you find it in production.
The second thing they have in common is that almost all of them add a component that needs an owner. A facade, a gateway, a mesh control plane, a sidecar in every pod, a process draining the outbox, a process rebuilding projections. That's not a box on an architecture diagram — it's an on-call rotation, upgrades, and its own page in the runbook. Before adopting a pattern it's worth asking who fixes it at three in the morning.
You'll end up combining them anyway: a Strangler Fig migration almost always comes with splitting the databases, and CQRS doesn't work without an outbox. But picking them up "to do it properly" is the worst possible reason. Each has a measurable condition under which it pays for itself, and in most cases that condition hasn't arrived yet.







Top comments (0)