// Stay in touch?
Products
Encore CloudEncore Cloud
Encore.tsEncore.ts
Encore.goEncore.go
PricingPricing
Book a DemoBook a Demo
Use Cases
AI-Powered DevelopmentAI-Powered Development
Event-Driven SystemsEvent-Driven Systems
Distributed SystemsDistributed Systems
Case StudiesCase Studies
ShowcaseShowcase
Resources
DocsDocs
InstallInstall
Example AppsExample Apps
Demo videoDemo video
ArticlesArticles
GitHub ReleasesGitHub Releases
Systems Operational
Company
About UsAbout Us
Swag ShopSwag Shop
ContactContact
JobsJobs
PressPress
SecuritySecurity
TermsTerms
Privacy PolicyPrivacy Policy
Data Processing AgreementData Processing Agreement
Enterprise SLAEnterprise SLA
Encore
© 2026 EncoreAll rights reserved
© 2026 Encore All Rights Reserved
GitHubDiscordYouTube

Microservices Best Practices

Service boundaries, a database per service, sync vs async calls, contracts, tracing, and independent deploys, with the failure each practice prevents

07/20/26
10 Min Read
Ivan Cernja
07/20/26

Microservices Best Practices

Service boundaries, a database per service, sync vs async calls, contracts, tracing, and independent deploys, with the failure each practice prevents

Ivan Cernja
10 Min Read

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.

Client requesthttp
edge
API gateway
services (each owns its data)
service
Orders
service
Users
service
Inventory
private databases
postgres
orders_db
postgres
users_db
postgres
inventory_db
async
Events topic
Each service owns its own database; async events cross boundaries without blocking calls.

Draw boundaries around business capabilities

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.

Give every service its own database

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:

  • Ask through the API. The orders service calls the users service to read a profile. Simple, and correct when the caller needs the current value right now.
  • Subscribe to events. The inventory service publishes a stock-changed event, and the orders service keeps the slice it cares about in its own database. This trades freshness for independence, so the orders service keeps working even when inventory is down.

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.

Choose synchronous or asynchronous per interaction

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.

PropertySynchronous (request/response)Asynchronous (events/messages)
Caller waits for a resultYesNo
CouplingCaller depends on callee being upSender and receiver decoupled
Use whenYou need the value now, like reading a profile to render a pageYou are announcing that something happened, like signup or a stock change
Main riskOne slow service stalls the whole request pathEventual 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.

Treat every API as a versioned contract

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.

Make every request traceable across services

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.

Request arrivestrace id assigned
trace-abc
Gateway
trace-abc
Orders service
trace-abc
Inventory service
one view
One end-to-end trace
One trace ID, propagated on every hop, turns scattered logs into a single request timeline.

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.

Keep deploys independent, and test at the right level

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:

  • Unit and integration tests run against the service and its own database, fast and owned by the team that owns the service.
  • Contract tests assert that a service still honors the API its callers depend on, catching a breaking change before it ships rather than in production.
  • End-to-end tests, kept to a small set, exercise the few flows that cross several services.

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.

Where Encore fits

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.

Related reading

  • Monolith vs Microservices: How to Choose in 2026
  • Service-to-Service Communication
  • Microservices vs Modular Monolith
  • Message Queues vs Pub/Sub
  • How to Build Microservices with TypeScript
  • How to Build Microservices with Go

Frequently asked questions

What are the most important microservices best practices?

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.

Should each microservice have its own database?

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.

When should microservices communicate synchronously versus asynchronously?

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.

How do you handle versioning between microservices?

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.

How do you debug a request that spans multiple microservices?

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.

How do you test microservices?

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.

Contents
Draw boundaries around business capabilities
Give every service its own database
Choose synchronous or asynchronous per interaction
Treat every API as a versioned contract
Make every request traceable across services
Keep deploys independent, and test at the right level
Where Encore fits
Related reading
Encore

The backend platform for humans and agents

Build backends in Go or TypeScript and run them on your own AWS or GCP, with the guardrails your team and AI agents need to ship safely.

Get started

Ready to build your next backend?

Encore is the Open Source framework for building robust type-safe distributed systems with declarative infrastructure.