Microservices for Beginners: Boundaries, Sync vs Async, Queues
Learn microservices from the ground up: when a modular monolith is enough, how to draw service boundaries, and when a call should be sync, async or queued.
By Matthews WongReviewed on
Key takeaway
Microservices are separately deployed services, each owning one business capability and its data; the line around each one is a service boundary. Draw those boundaries inside a modular monolith first, split only when a real constraint forces it, and choose per call: a synchronous chain multiplies availabilities, while 202 Accepted with a queue lets slow work wait and retry.
You will be able to
- Decide whether a system should stay a modular monolith or split, and find the boundaries from language and data ownership.
- Calculate what a synchronous call chain does to availability, and redesign it with 202 Accepted, a queue and a worker.
- Choose between a queue and pub/sub, and between a reverse proxy, an API gateway and a service mesh.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
A synchronous chain versus a queue and 202
Four services called in a chain are only as available as all of them together; behind 202 Accepted, a queue and a worker, the same work survives a dependency that is down.
Keys: ← → step · Home / End · K play or pause
A checkout runs through a chain: the API calls orders, orders calls payment, payment calls the bank. Assume each is up 99.9 percent of the time, failing independently.
Synchronously, every service waits for the next one's answer before it can reply. The user's request stays open until the bank, four hops away, answers.
Predict the next step Each service is up 99.9%. How available is the whole chain? (Still 99.9 percent · About 99.6 percent · About 96 percent)The chain works only when all four work: 0.999 to the fourth power is about 99.6 percent, roughly four times the downtime of a single service.
0.999 ** 4 // 0.996006The bank's API stops answering. Payment waits, orders waits, the API waits, and the user gets a timeout, although three of the four services are healthy.
Redesign: the API checks the order, writes a job to a queue and replies 202 Accepted with a status URL. The user now waits for two hops, not four.
Predict the next step The bank is still down. What happens to the job? (It is lost · It waits in the queue · The order is cancelled)A worker takes jobs from the queue. While the bank is down every attempt fails, so the job stays queued and is retried with backoff. Nothing is lost.
When the bank recovers, the worker runs the same chain and the job succeeds. The client polls the status URL and sees paid; the 202 only promised acceptance.
When should you split a monolith into microservices?
Later than most teams think. A service boundary is valuable because it keeps one capability's code and data private behind a small interface, and you can build that wall inside a single deployable. In a modular monolith each module owns its tables, exposes a narrow facade, and the build fails when another module reaches past it. Martin Fowler's monolith-first argument rests on how hard boundaries are to get right at the start, and on how much harder they are to move once a network runs between them.
Read more (1)Show less
Split a module out only when you can name the constraint it relieves: it must scale differently from the rest, another team needs its own release cadence, it needs a different runtime, or a rule demands hard isolation. Code that merely feels big is not on that list, because modules already solve size. Splitting a tangled system only turns tangled function calls into tangled network calls.
How do you find where one service should end?
Listen to the language. Domain-driven design calls the area where one model and one vocabulary stay consistent a bounded context. When the warehouse, sales and accounting all say item but mean different things, you have found three contexts, and forcing them into one table forces every change through three teams. Eric Evans, quoted by Fowler, puts it plainly: total unification of a large system's model is neither feasible nor cost-effective.
Read more (1)Show less
Inside each context, keep the business rules free of the database and the framework. Hexagonal architecture, also called ports and adapters, does this by letting the core define ports, interfaces in its own words such as save an order, and plugging adapters into them: a Postgres repository in production, an in-memory one in tests. A module built this way can later move behind a network without its rules noticing.
Should services call each other synchronously or through a queue?
Ask of each call whether the caller needs the answer to continue. If it does, a synchronous HTTP or gRPC call is the simplest choice, but it couples the two services in time: both must be up at the same moment. Along a chain, availabilities multiply. Four mandatory hops at 99.9 percent give 0.999 to the fourth power, about 99.6 percent, and latency adds up the same way, so the slowest hop sets the floor for every request.
Read more (2)Show less
If the caller only needs to know the work was accepted, use the 202 shape. The API validates the request, stores a job on a queue, and answers 202 Accepted with a link to a status resource. RFC 9110 calls 202 intentionally noncommittal: it says the request was taken, not that it will succeed, so the status resource must be able to report failure. A worker drains the queue at its own pace and retries while a dependency is down. Delivery becomes at least once, so the worker has to be idempotent, and the queue is now on the user's path itself.
Then decide who should receive each message. A queue gives every message to exactly one of its workers, so adding workers drains a backlog faster; that fits work that must happen once, such as sending a receipt. Pub/sub gives every message to every subscriber, so several services can react to one event. Redis Pub/Sub delivers at most once, and a subscriber that is disconnected when a message is published never sees it. A durable stream with one consumer group per service gives both: each group sees every message, and inside a group each message goes to one consumer.
What plumbing do separate services need, and how do you migrate?
Separate processes have to find each other. Docker assigns container addresses afresh each time a container starts, so call services by name: Docker Compose registers every service name in an internal DNS. Traffic from outside, called north-south, enters through one front door, an API gateway or a plain reverse proxy that terminates TLS, authenticates and rate limits. Traffic between services, east-west, is what a service mesh handles with proxies that add mutual TLS, retries and telemetry. A small team on one server needs the front door and rarely the mesh.
Read more (1)Show less
To move an existing monolith, do not rewrite it. The strangler fig pattern puts a facade in front of the old system, routes one path at a time to a new service, and keeps the old route ready for rollback. Data is the hard half: until the new service owns its tables, it is a distributed monolith with extra network hops. Stop when what remains is stable and cheap to run; a well-kept modular monolith is a legitimate end state.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
try {
const { error } = await getResend(apiKey).emails.send({
from: fromEmail,
to: toEmail,
replyTo: email,
template: {
id: isHiring ? TEMPLATE_HIRING : TEMPLATE_PROJECT,
variables,
},
});
if (error) {
console.error("Resend send error:", error);
return apiError({
status: 502,
code: "upstream_error",
message: "Failed to send message.",This site is one Next.js app, not a set of services, yet its contact form has exactly the synchronous dependency this lesson describes. The route awaits the email provider inside the visitor's request, so if the provider fails, the visitor gets a 502 and has to try again. With five messages a minute per IP and no broker running, that is a fair trade; a queue and a 202 would let the message wait instead.
/**
* The unversioned paths the site used before v1. They still work (cached
* pages and old clients call them), but are deprecated in favour of v1.
*/
export const DEPRECATED_API_ALIASES = [
{ path: "/api/chat", successor: API_PATHS.chat },
{ path: "/api/chat/followups", successor: API_PATHS.followups },
{ path: "/api/contact", successor: API_PATHS.contact },
] as const;A boundary's contract changes the strangler way: route first, move code later. The old unversioned paths keep answering, next.config.ts stamps them with a Deprecation header and a Link to the v1 successor, and app/api/v1/contact/route.ts simply re-exports the old handler. Clients move at their own pace, and nothing breaks on the day of the change.
Mistakes that cost real time
A distributed monolith
Services that still read and write each other's tables pass every routing test and still block every schema change, because each change needs every team. Give each table one owner, and let everyone else reach that data only through the owner's API or events.
Retries at every layer
If three layers each try three times, the struggling service at the bottom can receive 27 calls for one user action. Retry in one layer only, inside one deadline, and only operations that are safe to repeat: a POST that charges a card is not, unless it carries an idempotency key.
Publishing after the commit
Commit the order, then publish the event, and a crash between the two leaves the state changed with nobody told. Write the event to an outbox table in the same transaction and let a relay publish it. The relay can publish twice, so consumers must ignore duplicates.
A 202 the server cannot keep
Replying 202 before the job is stored durably, or with no status resource, leaves the client unable to learn the outcome. RFC 9110 notes that HTTP has no way to resend a status code later, so the status resource is the only channel, and it must be able to say failed.
| Synchronous call | 202 + queue + worker | |
|---|---|---|
| The user waits for | Every hop in the chain | The API and the queue write |
| A dependency is down | The request fails or times out | The job waits and is retried |
| What the reply means | The work is done | The work was accepted |
| Extra parts to run | None beyond the network | A broker, a worker, a status resource |
| Fits | Queries and answers needed now | Slow commands and side effects |
Do it on your own machine
You need Node and Docker, nothing else. Work out the chain arithmetic, then watch a queue and pub/sub behave differently in a local Redis.
Print the availability of chains of 1, 2, 4 and 8 services at 99.9 percent each. You should see 99.90%, 99.80%, 99.60% and 99.20%.
node -e 'for (const n of [1, 2, 4, 8]) console.log(n, (100 * 0.999 ** n).toFixed(2) + "%")'Start Redis, then open two terminals that act as competing workers, both blocking on the same list.
docker run -d --name lab-redis redis:7-alpine # terminal A, and the same in terminal B docker exec -it lab-redis redis-cli BRPOP jobs 0From a third terminal, push two jobs. Each worker prints one job, never both: a queue gives each message to one consumer.
docker exec lab-redis redis-cli LPUSH jobs receipt:41 docker exec lab-redis redis-cli LPUSH jobs receipt:42Now subscribe terminals A and B to a channel and publish: both print the message, and PUBLISH reports 2 receivers. Stop B with Ctrl-C, publish again, restart B: that message never arrives.
# terminals A and B docker exec -it lab-redis redis-cli SUBSCRIBE orders # third terminal docker exec lab-redis redis-cli PUBLISH orders paid:42 # clean up docker rm -f lab-redis
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.A request passes through five services, each 99.9 percent available. Roughly what is the chain's availability?Show the answer
Answer:About 99.5 percent, because 0.999 to the fifth power is 0.995. That is roughly five times the downtime of one service, assuming the services fail independently.
2.Why must a worker that reads from a queue be idempotent?Show the answer
Answer:Because delivery is at least once. A job can be delivered again after a crash or a timeout, so processing it twice must not charge or email twice.
3.Your codebase feels too big. Is that a reason to split it into microservices?Show the answer
Answer:Not on its own. Size is solved by modules with enforced boundaries. Split when you can name a constraint: a different scaling profile, an independent release cadence, another runtime or a hard isolation rule.
Every post in this module, in order
The lesson above is the map. Each post goes deep on one part of it, with the full worked examples.
- Modular Monolith Architecture: When It Beats MicroservicesWhat a modular monolith is and when it beats microservices: one deployable, a public API per module, a Postgres schema per module and lint-enforced boundaries.
- Bounded Contexts in Domain-Driven Design and How to Find ThemWhat a bounded context is, why Item means three things in ERP sales, inventory and accounting, and how to find boundaries and wire contexts with a context map.
- Hexagonal Architecture (Ports and Adapters) in TypeScriptWhat hexagonal architecture is, how ports and adapters keep business logic free of frameworks and databases, and a NestJS and Postgres example you can copy.
- Sync vs Async Microservices Communication: How to ChooseWhen a service should call another and wait, and when it should publish a message instead: availability arithmetic, latency, failure handling, consistency, and the 202 Accepted hybrid.
- Pub/Sub vs Message Queue: Differences and When to Use EachPub/sub vs message queue: competing consumers versus fan-out, delivery guarantees, ordering, replay, consumer groups, and a checklist for jobs, notifications and events.
- Service Discovery in Microservices: How It WorksHow does service discovery work in microservices? Registry, heartbeat and TTL, client-side vs server-side, DNS in Docker Compose and Kubernetes, Consul.
- Service Mesh vs API Gateway: Differences and When to Use EachService mesh vs API gateway: north-south versus east-west traffic, sidecar versus ambient mesh, real nginx and Istio config, cost, and when you need neither.
- Strangler Fig Pattern: Monolith to Microservices MigrationHow to migrate a monolith to microservices with the strangler fig pattern: a routing facade, one slice at a time, data ownership, rollback and when to stop.
Sources
Primary sources checked on the research date. Follow them for the current version.
- Martin Fowler: MonolithFirst
- Martin Fowler: BoundedContext
- Alistair Cockburn: Hexagonal architecture
- Azure Architecture Center: Interservice communication
- Azure Well-Architected: Reliability targets and composite SLOs
- RFC 9110: HTTP Semantics, 202 Accepted
- Redis docs: Pub/Sub delivery semantics
- Redis docs: Streams and consumer groups
- microservices.io: Transactional outbox
- Docker docs: Networking in Compose
- Azure Architecture Center: API gateways
- Azure Architecture Center: Strangler Fig pattern