A team splits a monolith into an orders service and an inventory service but leaves both pointed at the same Postgres database, and it runs fine until the inventory team renames a column, at which point the orders service starts throwing at runtime and the two teams find they still have to coordinate every schema change and every deploy. They are running two services with all the operational cost of microservices and none of the independence, because the one boundary that mattered was never drawn.
Most microservices trouble looks like this, where the decision to split has already been made and the pain comes from a handful of practices that got skipped along the way. The practices below each carry a reason and the specific failure they head off, so you can see which ones your setup is missing. If you are still deciding whether to split at all, the monolith vs microservices guide covers that question first.
A well-structured microservices setup: an API gateway routes to three services, each owning its own private database, with an event topic carrying asynchronous updates between them.
The most consequential decision in a microservices system is where the lines between services go, and the reliable way to place them is around business capabilities: orders, payments, inventory, notifications. A capability owns its data and its rules, and it changes for one reason, so a change to how payments work stays inside the payments service instead of rippling across three.
The common failure is slicing by technical layer instead, with a service for controllers, a service for business logic, and a service for data access. Every real feature then cuts across all three, so a single change means three coordinated deploys, which is the distributed monolith teams end up regretting. A useful check is to look at where your changes land: if most features touch one service, the boundaries are holding, and if most features touch several, they are drawn in the wrong place.
Keep services coarse at first. It is easier to split a service that has grown two clear responsibilities than to merge two services that turned out to be one. The modular monolith comparison is worth reading before you commit to fine-grained splits, since a module boundary is cheap to redraw and a service boundary is not.
A service owns its data, and no other service reads or writes its tables directly. This is the practice that makes the rest possible, because a private database is what lets a service change its schema, deploy, and scale without checking with anyone else. Share a database across services and you have coupled them at the layer that is hardest to uncouple later, so a migration in one service can break another at runtime and neither team can deploy alone.
When one service needs another's data, it has two honest options:
The tempting shortcut, letting one service run a quick read against another's tables "just this once," is the exact move that turns two services back into one, because the read never stays a one-off and the tables become a shared dependency again.
Not every call between services should be a blocking request, and the choice is per interaction, based on whether the caller needs an answer before it can proceed.
| Property | Synchronous (request/response) | Asynchronous (events/messages) |
|---|---|---|
| Caller waits for a result | Yes | No |
| Coupling | Caller depends on callee being up | Sender and receiver decoupled |
| Use when | You need the value now, like reading a profile to render a page | You are announcing that something happened, like signup or a stock change |
| Main risk | One slow service stalls the whole request path | Eventual consistency; the receiver sees the update slightly later |
The failure mode is chaining synchronous calls into a long request path, where the gateway calls orders, which calls inventory, which calls pricing, and the user waits on the slowest link while any one outage takes the whole chain down. When the caller does not truly need to wait, publishing an event instead removes that coupling and lets each side fail independently. The message queues vs pub/sub guide covers how to pick a delivery model once you have decided a call should be asynchronous, and service-to-service communication goes deeper on the sync side.
Once other services depend on your API, its shape is a contract, and the goal is to change it without forcing your callers to redeploy in lockstep. Make changes additive: add a field rather than renaming one, and keep old fields working until every caller has moved off them. A caller written against last month's response should still work against this month's.
When a breaking change is unavoidable, run both versions at once rather than cutting over in a single deploy. Expose the new version alongside the old, move callers across one at a time, and retire the old version after the last caller leaves. This is slower to write, and it is what keeps a change in one service from becoming a scheduled outage for everyone downstream. Contract tests, covered further down, are how you keep this promise honest across deploys.
In a monolith a stack trace tells you what happened. Across services, a single user action fans out into calls the individual logs cannot reassemble on their own, so the practice is distributed tracing: assign each incoming request a trace ID and pass it on every downstream call, so one request can be followed across every service it touches.
A single trace ID assigned at the gateway is propagated through each downstream service call, so one request can be followed end to end.
Pair tracing with structured logs that carry the same trace ID and a couple of metrics per service, latency and error rate, so you can see which hop got slow before you go reading spans. The failure this prevents is the one every team hits without it, an error report that spans four services and a night spent lining up log files by timestamp to guess which one failed first.
The payoff of all the work above is that each service deploys on its own, so a team ships its service without waiting on a shared release train. Two things protect that independence. Backward-compatible contracts, so a deploy does not break callers, and a test strategy that does not force everything through one slow gate.
Push most coverage down to the individual service:
The anti-pattern is a suite built almost entirely of end-to-end tests, which grows slow and flaky as services multiply, until people stop trusting it and merge around it. Keeping the pyramid weighted toward per-service tests is what lets deploys stay independent in practice and not just on the architecture diagram.
Several of these practices are things you normally assemble yourself from a service framework, a message broker, a tracing backend, and a pile of glue. In Encore they are defaults. Calls between services are ordinary function calls through the ~encore/clients import, type-checked at compile time, so a contract change that would break a caller fails the build instead of production.
// orders/orders.ts
import { api } from "encore.dev/api";
import { users } from "~encore/clients";
export const create = api(
{ method: "POST", path: "/orders", expose: true },
async (p: CreateParams): Promise<Order> => {
// A type-checked call into the users service. If the users API
// changes shape, this line stops compiling.
const user = await users.get({ id: p.userId });
return placeOrder(user, p.quantity);
},
);
Each service declares its own Postgres database as a typed object in code, and Encore provisions a separate database per service into your own AWS or GCP account, which keeps the no-shared-database rule by construction rather than by discipline. Pub/Sub topics with delivery guarantees give you the asynchronous path when a call should not block, and distributed tracing is built in, so a request that fans across services already arrives as one end-to-end trace without a separate agent to install. Services are written in one codebase and deployed together, and because the boundaries and contracts are enforced by the compiler, you keep the independence microservices are meant to buy without hand-wiring service discovery, tracing, and per-service infrastructure. The TypeScript and Go walkthroughs build a multi-service backend on this from scratch.
Draw service boundaries around business capabilities rather than technical layers, give each service its own database so no two services share tables, and pick synchronous or asynchronous communication per interaction instead of defaulting to one. On top of that, version your contracts so callers do not break on a deploy, trace requests end to end across services, and keep deploys independent. Most microservices pain traces back to skipping one of these.
Yes. A database per service is what makes services independently deployable and independently scalable. When two services read and write the same tables, a schema change in one can break the other, and you can no longer deploy or scale them separately, which removes the main reason to have split them. Services that need another service's data should ask for it through that service's API or receive it through events, not reach into its tables.
Use a synchronous call when the caller needs the result before it can continue, such as reading a user's profile to render a page. Use asynchronous messaging when the caller only needs to announce that something happened and does not need to wait, such as sending a welcome email after signup. Overusing synchronous calls chains services into a slow, fragile request path where one slow service stalls everything upstream.
Treat every service API as a contract and make changes backward compatible, so add fields rather than renaming or removing them, and keep old fields working until every caller has moved off. When a breaking change is unavoidable, run the old and new versions side by side and migrate callers one at a time. This lets teams deploy on their own schedule without coordinating a lockstep release across services.
Use distributed tracing. Each incoming request gets a trace ID that is passed along on every downstream call, so you can follow one request across every service it touches and see where time went or where it failed. Without tracing you are stitching together separate log files by timestamp, which becomes impractical once a request fans out across several services. Structured logs that include the trace ID make this workable.
Test each service in isolation with unit and integration tests against its own database, then add contract tests that assert a service still honors the API its callers depend on. Keep a smaller number of end-to-end tests for the few flows that cross several services and need to be exercised together. Leaning entirely on end-to-end tests produces a slow, flaky suite, so push most coverage down to the individual service.