What microservices are, and what they are not
Microservices are an architectural style in which an application is composed of independently deployable services organised around business capabilities, each owning its own data and communicating over the network. The word doing the work is independently. A service that cannot be deployed on its own is not a microservice; it is a module with a network boundary.
It is worth saying what the style is not. It is not a rule that services must be small. It is not a requirement to use containers, Kubernetes or a service mesh, although those tools exist because this style is awkward without them. It is not automatically more scalable, more reliable or more modern than a monolith. Those are properties you build, not properties you get from a topology.
The single defining trait is that a change to one service can reach production without a coordinated release of the others. Everything else — the boundaries, the contracts, the events, the platform — exists to make that trait true and to survive its consequences.
An example decomposition
Take an online store. The capabilities are easy to name, and each becomes a service with a clear owner.
catalog products, prices, availability
cart the pre-purchase basket
orders the placed order and its lifecycle
inventory stock levels and reservations
payments charges, refunds, payment methods
shipping labels, carriers, tracking
notifications email, push, SMS
identity accounts, sessions, API keys
Notice what is missing from that list. There is no “database service”, no “email-sending service” — notifications is a capability, not a transport — and no “user service” that every other service calls just to render a name. Each entry owns a piece of the business, and a team can own one or two of them end to end.
The interactions matter as much as the list. Browsing the catalog is read-heavy and can be cached aggressively. Placing an order is a write that coordinates inventory, payments and shipping. Notifications is a pure consumer: it reacts to events and is never called. These different shapes justify different treatment — caching, sagas, queues — even though all of them are “services”.
The real driver is independent deployability
Teams often adopt microservices “to scale”, then discover they never needed the scaling. The honest reason to split is organisational. When ten engineers work in one deployable, every release waits for the slowest change, and a broken test in one area blocks everyone. Splitting by capability gives each team its own pipeline, its own on-call and its own pace.
This is Conway’s law in reverse: you shape the system so that its seams match the way your teams communicate. If one team owns the entire product, microservices buy you network failures and a platform to maintain in exchange for nothing. If six teams need to ship independently, the seams start to pay for themselves.
So the first question is not “how do we decompose this domain?” but “which parts of this system genuinely need to change at different rates, owned by different people?” Only the answers become services.
Boundaries follow business capabilities
The right seam is a business capability, not a technical layer. Billing, shipping, catalog, identity and notifications are capabilities. A “database service”, an “email service” or a “validation service” is a technical layer wearing a service costume, and every feature will need to change three of them at once.
Domain-driven design gives the vocabulary. A bounded context is a boundary inside which a particular model and its language are consistent. “Order” in the sales context means a cart about to be paid for; in the fulfilment context it means a package to pick and ship. Both are correct inside their own context, and forcing one shared Order class across both is how a monolith becomes a mess.
A good boundary has three properties:
- It owns a coherent set of rules that change together.
- It has a small, stable interface relative to the amount of logic behind it.
- It can be understood by one team without reading the rest of the system.
Boundaries are expensive to move once other services depend on them, so bias towards fewer, larger services early. Splitting a service is easy; merging two that have diverged is painful.
Synchronous communication: REST and gRPC
The simplest interaction is a request and a response. Use REST with JSON for anything a browser or external client touches, and gRPC when both sides are internal and you want a typed, compact, low-latency contract. gRPC’s generated clients make it hard to drift from the schema, which is why many internal service meshes standardise on it.
Synchronous calls are easy to reason about and impossible to make fully safe. Two services are now temporally coupled: the caller cannot proceed unless the callee is up and responsive. Three rules keep that from becoming an outage.
- Always set a timeout. A call with no deadline can hang until your own resources run out. Pick a budget that fits the caller’s own deadline and subtract the rest of its work.
- Retry only idempotent operations. A retried
GETis harmless; a retriedPOST /chargeis a second charge unless the endpoint accepts an idempotency key and honours it. - Break the circuit. When a dependency is failing, stop calling it for a while instead of queueing requests that will also fail. This is the circuit breaker, and it is what turns a slow dependency into a fast, honest error.
Retries amplify load. If three layers each retry three times, a struggling service receives twenty-seven requests for every original. Cap retries at the edge where you can see the whole picture, and add jitter so retries do not arrive in a synchronised wave.
Asynchronous communication: events
The alternative to asking is announcing. A service publishes a fact — orders.created, payment.captured — and any number of consumers react without the producer knowing they exist. This removes temporal coupling: the payment service can be down and the order still gets created, because the event waits on the broker.
Asynchronous communication buys resilience and flexibility at the cost of immediacy and clarity. The producer’s response no longer includes the downstream effect, so the system is eventually consistent. Tracing a request means following a trail of events rather than one call stack. And the broker becomes critical infrastructure that must be durable and monitored.
There are two ways to coordinate a multi-step process. In choreography, each service listens for events and decides what to do next; there is no central brain, which is elegant until nobody can say what the overall flow is. In orchestration, a process manager or saga coordinator explicitly drives the steps. Choreography is better for simple reactions, orchestration for flows with compensations and timeouts. The Event-Driven Architecture and Kafka guides go deeper on the mechanics.
The distributed-systems tax
The moment a call crosses a network, a set of failure modes that do not exist in-process become your responsibility. This is not a reason to avoid services; it is the bill that comes with them.
- The network is not reliable. Packets drop, DNS returns stale addresses, connections reset mid-request.
- Failure is partial. Service A is healthy while B is down. There is no single “the system is up” flag.
- Latency is not free. A call that took microseconds in-process now takes milliseconds, and chains multiply it.
- The response can be lost. A request can succeed and the acknowledgement never arrive. From the caller’s view it failed; the work happened anyway.
- Time is unsynchronised. Clocks across machines drift, so ordering events by timestamp alone is unsafe.
The design response is a small, well-known toolkit: timeouts on every call, bounded retries with backoff and jitter, circuit breakers, bulkheads that isolate one dependency’s failure, and idempotency keys so a repeated request is safe. None of these are optional. A microservices system without them is a system that works until the first bad day.
Data ownership and the shared-database trap
Each service owns its data. That means one service is the only writer to its schema, and no other service connects to that database. Ownership is what makes independent deployment possible, because a schema change affects only the owning service and its contract.
A shared database feels convenient and quietly destroys the architecture. Two services reading the same tables are coupled through the schema: rename a column and both must ship. Worse, the shared schema becomes a de facto shared model, and the bounded contexts collapse back into one.
Cross-service queries are the usual reason teams reach for the shared database. The answers are read models and APIs:
- Ask the owning service over its API for a small, purpose-built response.
- Subscribe to its events and maintain a local projection shaped for your queries.
- Accept that some queries need a dedicated reporting store, not a join across service databases.
A projection is denormalised and eventually consistent, which is exactly why it is fast and why you must be comfortable with a short lag. This is the read side of CQRS, and it is the standard way to answer cross-service questions without breaking ownership.
Sagas instead of distributed transactions
There is no ACID transaction spanning services. Two-phase commit requires all participants to hold locks and be available, which is precisely the condition you cannot guarantee across a network, and most modern datastores do not support it. The replacement is the saga.
A saga is a sequence of local transactions, one per service, where each step publishes an event or invokes the next. If a later step fails, the saga runs compensating actions to undo the completed work in business terms. Consider placing an order:
- Orders creates the order as
pending. - Inventory reserves the stock.
- Payments charges the customer.
- Fulfilment schedules the shipment and orders marks the order
confirmed.
If payment fails, the compensation releases the reservation and cancels the order. Notice that “undo” is not a rollback; it is a new business fact. You cannot un-send a confirmation email, so the compensation is a second email explaining the cancellation.
Because every step can be retried, every step must be idempotent. The same order must not be charged twice. Derive a dedupe key from the business operation — the order id — not from a random request id, and let a unique constraint or a SET NX make the check atomic. Sagas are the single most demanding part of microservices, and skipping the compensation path is how systems end up with stranded reservations and double charges.
Contracts, versioning and compatibility
A service’s contract is its API, and callers depend on it. Treat it as a published interface with a lifecycle:
- Add, do not break. New optional fields are safe; renaming, removing or changing the meaning of a field is not.
- Version deliberately. URL or header versioning makes breaking changes explicit, and the REST and API versioning guides cover the trade-offs.
- Give consumers time. Announce deprecations, emit usage metrics per client, and only remove an endpoint once nobody calls it.
- Test the contract from both sides. Consumer-driven contract tests catch the case where a producer’s “compatible” change is not compatible with a particular consumer.
The same discipline applies to event schemas. A consumer reading a topic will see messages written by an older version of the producer, so events must be additive and self-describing. Put a version or a schema id in the envelope, and let consumers tolerate unknown fields.
Discovery, gateways and load balancing
Services move. Instances are rescheduled, scaled and replaced, so callers cannot hard-code addresses. Service discovery solves this: either the client asks a registry for healthy instances (client-side), or a stable virtual address in front of the instances does it (server-side). Kubernetes gives you the second for free through Services and DNS.
An API gateway sits at the edge and handles the concerns every service would otherwise repeat: authentication, rate limiting, TLS termination, request routing and response aggregation. It is genuinely useful. It also becomes a single point of failure and, if it accumulates business logic, a distributed monolith in miniature. Keep the gateway thin and push capability-specific rules back into the owning service.
Load balancing is not only round-robin. Long-lived connections, sticky sessions and slow consumers all affect how evenly traffic spreads. Let the platform’s load balancer do its job, and make services stateless so any instance can serve any request.
Observability across services
In a monolith, a stack trace usually explains an incident. Across services, the request left your process several hops ago and the trail is gone. Three instruments restore it.
- Correlation ids. Generate an id at the edge, propagate it in headers and event envelopes, and put it in every log line. It is the thread that ties a user action to every service it touched.
- Distributed tracing. OpenTelemetry spans record the time spent in each service and call. A trace shows at a glance which hop is slow, which is the question logs cannot answer.
- Metrics per service. Track request rate, error rate and duration for every endpoint, plus saturation signals like queue depth and pool usage. Alert on symptoms your users feel, not on every blip.
Logs should be structured JSON with a service name and the correlation id, shipped to one place. A log you cannot search across services is a log you will not use at 3am. The Cloud Deployment guide covers the operational side of collecting these signals.
Deadlines and request budgets
Latency in a call chain is additive, and every service in the chain is guessing unless you propagate a deadline. A user-facing request that must answer in 800ms cannot afford four downstream calls each willing to wait two seconds.
Give every request a budget at the edge and pass the remaining time down with the call. Each service subtracts its own work and forwards a smaller deadline, so the chain fails fast instead of stacking timeouts.
// The gateway sets the budget once.
const deadline = Date.now() + 800;
await orders.place(input, { deadline }); // forwards the remaining time
When a service sees the deadline has passed, it should stop and return an honest error rather than start more work it cannot finish. Pair this with a fallback for non-essential calls: if recommendations cannot answer inside 100ms, return the page without them. A request budget turns “sometimes it hangs” into a predictable p99, and it is one of the cheapest reliability wins available across services.
Deployment and infrastructure
Independent deployment is a property of your pipeline, not just your topology. Each service needs its own build, test, image and rollout, plus a way to run database migrations without downtime and a rollback that does not depend on redeploying everything else.
That usually means containers and an orchestrator. Containers make the service’s runtime portable and its dependencies explicit; Kubernetes or a managed equivalent handles scheduling, discovery, health checks, autoscaling and rolling updates. You also need configuration and secrets delivered per environment, and a story for schema migrations that stays compatible with the previous version of the code, because old and new instances run side by side during a rollout.
This is the platform tax. It is real, it is ongoing, and it is why small teams should think hard before adopting the style.
The distributed monolith
The distributed monolith is the worst outcome: many services that must still be deployed together. Its symptoms are unmistakable.
- Services share a database, so schema changes cross team boundaries.
- A release requires coordinating several services at once.
- A synchronous call chain spans four services for one user action.
- Two services are always changed in the same pull request.
When you see these, the boundaries are wrong. The usual causes are splitting by technical layer, splitting before the domain was understood, or letting a shared database survive the split. The fix is often to merge the offending services back together and find a better seam, which is a hard admission but cheaper than a decade of coordinated releases.
When not to use microservices
Do not split when any of these is true:
- The team is small enough that everyone can deploy the whole application safely.
- The domain is still being discovered, so boundaries would be guesses.
- The product is early and requirements change weekly.
- You do not yet have the CI/CD, observability and on-call capacity to run many services.
In all of these, a modular monolith gives you the same internal discipline with one deploy, in-process transactions and refactors that do not require a migration plan. Because its modules communicate through explicit interfaces, a module can be extracted into a service later, when a concrete reason appears: a team that needs its own cadence, a component with a genuinely different scaling profile, or a compliance boundary. Extracting a clean module is far cheaper than merging a bad split.
Migrating with the strangler fig
When you do split an existing system, do it incrementally. The strangler fig pattern routes traffic through a facade and moves one capability at a time until the old code is unused and can be deleted.
- Find a seam. Choose a capability that is already fairly self-contained and changes often. A clean module boundary is the best candidate.
- Put a facade in front. Route the relevant traffic through a proxy or gateway so you can shift it without touching clients.
- Extract the module into a service. Give it its own deployable and pipeline, and move its tables into a database it owns.
- Sync data carefully. Dual-write or publish events and backfill until the new store is authoritative, then stop writing to the old tables.
- Cut over and remove. Shift traffic, watch the metrics, and delete the old code path once it is cold.
Never start with a big-bang rewrite. The value of the strangler approach is that every step is reversible and the system is in production the whole time.
Defining the contract first
An internal HTTP contract is easy to drift, because producer and consumer are written by different teams and only integration tests notice. Defining the contract as a schema before writing either side keeps them honest and lets you generate clients, servers and documentation from one source.
// inventory.proto
service Inventory {
rpc GetStock(GetStockRequest) returns (StockLevel);
rpc Reserve(ReserveRequest) returns (Reservation);
}
message GetStockRequest { string sku = 1; }
message StockLevel { string sku = 1; int32 available = 2; }
For HTTP, an OpenAPI document plays the same role. Either way the schema is the artefact under review, and a breaking change shows up in a diff instead of in production. Consumer-driven contract tests complete the loop by encoding what each consumer actually uses, so a producer knows before release whether its change is safe for the callers that exist.
Matching the transport to the interaction
The mistake is using one transport for everything. A pure REST system serialises every reaction behind a call chain; a pure event system makes a simple lookup absurdly indirect. Choose per interaction, not per system.
| Interaction | Transport | Why |
|---|---|---|
| Browser to backend | REST/JSON | Ubiquitous, cacheable, easy to debug |
| Service to service, low latency | gRPC | Typed, compact, generated clients |
| Fire-and-forget reaction | Events | No temporal coupling, buffered |
| Long-running workflow | Events plus a saga | Durable, retryable, compensated |
| High-volume read | Cache or read model | Avoids the call entirely |
A useful default is to make writes authoritative over a synchronous call when the caller needs the answer, and to publish an event for every effect the caller does not need to wait for.
Idempotency keys in practice
Sagas, retries and at-least-once events all mean the same operation can arrive twice. The only defence that survives concurrency is a dedupe check that is atomic with the side effect.
export async function capturePayment(cmd: CapturePayment) {
const key = `payment:captured:${cmd.orderId}`;
const inserted = await redis.set(key, "1", "NX", "EX", 86_400);
if (inserted === null) return { skipped: true };
return gateway.charge(
{ orderId: cmd.orderId, amountCents: cmd.amountCents },
{ idempotencyKey: cmd.orderId },
);
}
Three details matter. The key is derived from the business operation, not from a random request id, so a producer that enqueues the same logical work twice still collides. The check and the marker write are one atomic operation, so two concurrent workers cannot both win. And the provider is given its own idempotency key, because your marker can be lost while the provider’s record survives.
Outbox, inbox and exactly-once effects
A service that writes to its database and then publishes an event has a gap: the process can crash between the two, leaving the state changed and the event unsent. The outbox pattern closes it by writing the event into an outbox table in the same local transaction as the state change, then letting a relay publish the rows.
await db.transaction(async (tx) => {
await orders.save(order, tx);
await tx.insert(outbox).values({
id: randomUUID(),
topic: "orders.created",
key: order.id,
payload: order.toEvent(),
});
});
// A separate relay polls outbox and publishes, at least once.
The consumer side is the mirror image. Because delivery is at-least-once, a consumer can receive the same event twice. Keep an inbox table of processed event ids and skip duplicates, again in the same transaction as the effect. Outbox and inbox together give effectively-once processing without pretending the network is reliable.
Bulkheads and graceful degradation
A circuit breaker stops calling a failing dependency. A bulkhead goes further and isolates resources so one dependency cannot consume everything. If the recommendations service has its own connection pool, a stall there cannot exhaust the pool that checkout depends on.
const pools = {
checkout: new Pool({ max: 20 }),
recommendations: new Pool({ max: 5, timeoutMs: 500 }),
};
Degradation is the user-facing half of the same idea. When a non-essential dependency is slow, return a reduced response instead of an error. A product page without personalised recommendations is a good page; a product page that times out because recommendations timed out is an outage. Decide per call whether it is required or best-effort, and encode that decision where the call is made.
The anti-corruption layer
When one service consumes another’s model directly, it inherits that model’s assumptions, and a change to the producer’s Order shape ripples into every consumer. An anti-corruption layer is a thin translation layer that maps the external contract into the consumer’s own domain language.
// Shipping has its own idea of a shipment. It translates the event.
function toShipment(event: OrderCreated): ShipmentRequest {
return {
reference: event.id,
destination: event.shippingAddress,
lines: event.lines.map((l) => ({ sku: l.sku, quantity: l.qty })),
};
}
The layer costs a little code and buys real independence. The consumer can rename its own concepts freely, tolerate unknown fields and absorb a breaking change from a third party it does not control.
Knowing whether the split is working
Microservices are a means, not a goal, so measure the thing you actually wanted. The signals that suggest the split is paying off are organisational: the number of teams that can deploy without coordinating, the lead time from commit to production for a single service, and how often one change touches several services. If those numbers are not improving, the topology is not earning its cost.
The signals that suggest it is going badly are technical and easy to see.
- Deploys still happen in a fixed order across services.
- A single user action produces a long synchronous call chain.
- One service’s schema change requires another team’s release.
- The on-call rotation cannot tell which service caused an incident.
Any of these means you have a distributed monolith, and the fix is usually to merge a boundary, not to add another service.
A checklist before you split
Before you take a module out of a monolith, make sure all of these are true.
- The module already owns its data and nobody else writes its tables.
- Other modules depend on its interface, not on its internals.
- You have a reason beyond “it feels big”: a team cadence, a scaling profile or a compliance boundary.
- You have CI/CD, tracing and on-call capacity for one more deployable.
- You have a plan for the data migration and a way to roll it back.
- You have decided how the module will talk to the rest, synchronously or asynchronously, and written the contract.
If any answer is no, the extraction will be more expensive than it looks. Fixing the boundary inside the monolith first is almost always cheaper than fixing it across a network.
Best practices
- Split for independent deployment and team autonomy, not for scale.
- Align each service with a bounded context and give it a clear owner.
- Give every service its own database and forbid cross-service queries.
- Set timeouts, bounded retries with jitter and circuit breakers on every call.
- Make write operations idempotent before you allow any retry.
- Prefer events for reactions that do not need an immediate answer.
- Use sagas with compensations instead of distributed transactions.
- Version contracts and make every change additive.
- Propagate a correlation id and instrument tracing from the first service.
- Keep the gateway thin and push business logic into services.
- Extract services with the strangler fig, one capability at a time.
Common mistakes
- Splitting by technical layer, then needing three services for every feature.
- Sharing a database and wondering why releases still need coordination.
- Deploying services together and calling the result microservices.
- Adding retries without idempotency and double-charging customers.
- Leaving timeouts unset so one slow dependency stalls the whole call chain.
- Using synchronous chains where an event would remove the coupling.
- Building a saga with no compensation path for the failure cases.
- Skipping distributed tracing and debugging by guessing across logs.
- Splitting too early, freezing the wrong boundaries into expensive infrastructure.
- Treating Kubernetes and a service mesh as the goal rather than the cost of the goal.
Where to go next
If the trade-offs here feel heavy for your team, read the Modular Monolith guide — it is the right default and the best preparation for a future split. To make services communicate without calling each other, the Event-Driven Architecture guide is the next step, with Kafka for the log behind it. And once you are running many services, the Cloud Deployment guide covers the platform and observability work that keeps them healthy.