Appearance
Chapter 5: Communication Patterns
General Track · Foundational · ~35 min read + exercise
Why this chapter exists
The last chapter treated remote calls as something that happens and can fail. This chapter steps back to a more basic question you actually get to decide: when one part of your system needs another part to do something, how should they talk? The answer is one of the heaviest, most consequential choices in distributed design — heavier than most teams realize when they make it by default — and it shapes everything downstream: how the system fails, how you reason about it, how it scales, and how coupled its parts are.
You've already met the core distinction twice in this course, which tells you how fundamental it is. In Chapter 1 it was the frontend choice between awaiting the server and updating optimistically. In Chapter 2 it was one of the two dimensions that place every architectural style. Now we look at it head-on, because the choice between synchronous and asynchronous communication — and the related choice between orchestration and choreography for multi-step workflows — is where a lot of distributed systems are quietly made too coupled or too complex.
Here's the thing that makes this a real decision rather than a technical detail: the two modes don't just have different performance characteristics, they create different systems. A synchronous system and an asynchronous system that implement the "same" feature behave differently under load, fail differently, and demand different things from the developers who maintain them. Choosing between them is choosing what kind of system you want to live with.
Make it concrete. A customer submits a support ticket in your Laravel app. Several things should happen: the ticket gets saved, an expert gets assigned, the customer gets a confirmation email, and an analytics event gets recorded. You could do all of this synchronously inside the request — save, assign, send email, record event, then return the response. It's simple and the code reads top to bottom. But the customer now waits for the email service and the analytics service before they see "ticket submitted," their request is only as fast as the slowest step, and if the email provider is having a bad moment, the whole ticket submission fails or hangs — even though the ticket itself saved fine. Alternatively, you could save the ticket synchronously (the thing the customer actually cares about) and fire the rest asynchronously — drop a job on a queue for assignment, email, and analytics, and return "ticket submitted" immediately. The customer gets a fast response, the submission no longer depends on the email provider being healthy, and a slow analytics service can't break ticket creation. But now the work happens later, out of view; if the email job fails you need a way to retry it (Chapter 4); and "the ticket is submitted" no longer means "everything is done," which is a different mental model for everyone working on the system.
Neither is right in the abstract. The synchronous version is simpler and gives immediate, complete confirmation, paying with coupling and shared fate. The asynchronous version is decoupled and resilient, paying with complexity and deferred, harder-to-trace work. This is the same trade-off shape you've been training on all course — and this chapter gives you the frame to make it deliberately instead of defaulting into whichever your framework made easiest.
What this chapter covers
- Synchronous communication — request/response, what it's good at, and the coupling and cascading-failure costs that come with waiting.
- Asynchronous communication — messaging and queues, the decoupling and resilience they buy, and the complexity they charge.
- How to choose — the questions that decide it, and why most real systems are deliberately a mix.
- Orchestration vs. choreography — for workflows that span several components, who's in charge of the steps, and the trade-off between a central coordinator and emergent coordination.
By the end you'll do a hands-on exercise redesigning a synchronous flow to use async where it helps — and reasoning about what that buys and costs.
Let's start with the mode most systems reach for first.
Synchronous communication: simple, coupled, waiting
Synchronous communication is request/response: the caller sends a request and waits for the response before continuing. A function call is synchronous. A typical HTTP API call where you await the result is synchronous. It's the default almost everywhere because it matches how we think — you ask, you get an answer, you proceed, and the code reads as a straight line from top to bottom.
That straightforwardness is a genuine strength, and it's worth not undervaluing in a field that loves to over-complicate. When a flow is synchronous, you can read it and understand it. The control flow is linear. Errors propagate up the call stack to where you can handle them. Debugging means following a single thread of execution. For a great many operations — anything where the caller genuinely needs the answer before it can continue, like a read that populates a page, or a validation that gates the next step — synchronous is not just acceptable, it's correct. Reaching for async there would add complexity for nothing.
The cost of synchronous communication is temporal coupling: the caller and callee must both be healthy at the same time, and the caller's speed is bound to the callee's. We saw the failure mode this creates back in Chapter 4 — a synchronous call to a slow dependency ties up the caller while it waits, and without a timeout that can cascade into a whole-system stall. But even with good timeouts, synchronous communication means the caller's fate is linked to the callee's: if the thing you call is down, you can't complete, full stop. Chain several synchronous calls together and the availability math gets unkind fast — a request that synchronously depends on five services, each up 99.9% of the time, is itself up only about 99.5%, because it needs all five healthy simultaneously. Every synchronous dependency you add is another thing that has to be working for you to work.
Synchronous communication, then, is the right tool when the caller truly needs the result to proceed and the dependency chain is short and healthy. It becomes a liability when you're synchronously waiting on things the caller doesn't actually need before responding — like making a customer wait for an analytics write before seeing their ticket was submitted. That's the signal to consider the other mode.
Asynchronous communication: decoupled, resilient, complex
Asynchronous communication breaks the waiting. The caller sends a message — drops a job on a queue, publishes an event to a broker — and moves on without waiting for the work to complete. The work happens later, handled by a separate worker or consumer, with results (if any) arriving through some other channel. Laravel's queues and events are async in exactly this sense: dispatch(new SendTicketEmail($ticket)) returns immediately, and the email goes out later in a worker process.
What this buys is decoupling in time, and it's powerful. The caller no longer depends on the consumer being healthy right now — it depends only on the queue accepting the message, and a durable queue is built to stay available and hold that work until a worker can pick it up (durability you configure — persistence and acknowledgements — not a free guarantee). The email provider can be down for two minutes; the jobs wait in the queue and process when it recovers, and the customer who submitted the ticket never knew. This is resilience by construction: the queue absorbs the failure instead of propagating it. Async also absorbs load the same way — a burst of a thousand ticket submissions doesn't overwhelm the email provider, because the jobs queue up and drain at whatever rate the provider can handle. The queue is a shock absorber for both failure and load. And it improves perceived performance: the caller returns as soon as the message is queued, not when the work is done, so the customer gets their fast "submitted" response.
The price is complexity, and it's real enough that you should never reach for async reflexively. The flow no longer reads top to bottom — the work that used to be the next line of code now happens somewhere else, later, in a worker, which makes the system harder to understand and to debug, because there's no single thread to follow. You inherit the hard questions of messaging that Chapter 4 previewed: messages can be delivered more than once (so consumers must be idempotent), ordering isn't guaranteed (so you can't assume message B arrives after message A), and a message that no consumer can process needs somewhere to go — a dead-letter queue — and someone to look at it, or it's a silent data-loss bug. It's worth being precise about delivery here, because "exactly-once" is one of the field's most persistent myths: true exactly-once delivery is generally unachievable across an unreliable network, so brokers give you at-least-once delivery and you build exactly-once processing on top of it, via the idempotency and deduplication from Chapter 4. The guarantee you actually engineer is "delivered possibly many times, applied once." "The work is queued" is not "the work is done," and every part of the system and every developer in it has to internalize that difference. Async failures are also quieter: a synchronous failure throws in the user's face where you'll notice it; an async job failing in a worker at 3 a.m. is invisible unless you built the observability to see it.
So asynchronous communication is the right tool when the caller doesn't need the result to proceed, when you want to decouple the caller's fate from the consumer's, when you need to absorb bursts or smooth a fast producer feeding a slow consumer, or when a slow side-task shouldn't hold up the main response. It's the wrong tool when the caller genuinely needs the answer now — forcing a fundamentally synchronous need through a queue just adds latency and complexity to get back to "waiting for the answer" anyway.
Choosing — and mixing
The choice between sync and async is rarely all-or-nothing for a whole system. The best real-world designs are deliberately mixed: synchronous where the caller needs the answer and the coupling is acceptable, asynchronous where the work can happen later and decoupling pays off. The ticket-submission example from the opening is the template — save the ticket synchronously because the customer needs that confirmation, and fire the email, assignment, and analytics asynchronously because they can happen just after without anyone waiting. A few questions decide each call:
Does the caller actually need the result to continue? If yes — a read that fills the page, a validation that gates the next step — synchronous. If no — a side effect, a notification, a downstream update the caller doesn't depend on — async is available, and often better. This is the first and most decisive question, and a lot of accidental coupling comes from answering it lazily and awaiting things the caller never needed.
Can the work tolerate happening slightly later? Async means the work completes after the response. If that's fine (an email, an analytics event, a cache warm), async is a good fit. If the result must be reflected immediately and consistently (the consistency spectrum from Chapter 3 is the same question), synchronous keeps it simple.
Is decoupling or burst-absorption worth the complexity here? If the consumer's failures or slowness genuinely threaten the caller, or if load is spiky, the queue's resilience earns its complexity. If the dependency is reliable and the load is steady, you may be buying complexity you don't need — the premature-flexibility trap from Chapter 1, wearing a message broker.
The honest default mirrors the rest of the course: start synchronous because it's simpler, and introduce async deliberately for the specific flows that benefit — the slow side-tasks, the burst-prone work, the things that shouldn't share the main request's fate. Reaching for async everywhere "to be scalable" is the communication-layer version of reaching for microservices to be modern: you pay the complexity whether or not the situation needed it.
Contracts change: versioning and evolution
There's a consequence of independent deployment — the promise the styles chapter held out — that's easy to miss until it bites. The moment two parts of a system deploy on their own schedules, you've lost the ability to change the contract between them in lockstep. In a monolith you can rename a field and fix every caller in the same commit, and the compiler keeps you honest. Across a process boundary — a sync API or an async message — the caller and the callee deploy separately, so for some window they're running different versions of the contract at the same time. Change it carelessly and you break whoever hasn't caught up yet.
The discipline that makes this safe is compatibility. A change is backward compatible if newer code can still read data written by older code; forward compatible if older code can tolerate data written by newer code. The practical rule that buys you both is to prefer additive changes — add a new optional field, don't remove or repurpose an existing one — and, when you genuinely must change a field's meaning, to expand then contract: add the new form, migrate every consumer to it, and only then retire the old form, rather than swapping in one step. Consumers help by being tolerant readers — ignoring fields they don't recognize instead of choking on them — so a producer can add to a message without coordinating a synchronized release with everyone who reads it.
When a breaking change really is unavoidable, you version the contract (a /v2 endpoint, a versioned message schema) and run the old and new versions side by side through a deprecation window — long enough for every consumer to migrate on its own schedule — before retiring the old one. This is exactly the coordination cost the styles chapter warned that distribution introduces, and it applies to async messages just as much as to sync APIs: a queued message can outlive the code that wrote it, so its schema has to evolve as carefully as any API. Building Microservices treats contract evolution as a first-class concern of any system whose parts deploy independently, and these are its core moves.
A useful way to see this whole class of problem is information leakage — Ousterhout's term in A Philosophy of Software Design for a single design decision that ends up reflected in more than one place, so changing it means changing all of them. A versioned contract is leakage you chose: both sides necessarily share knowledge of the message shape, and the discipline above is how you manage it deliberately. The dangerous kind is the leakage you didn't notice — a status enum whose exact string values are hard-coded in three services, an internal ID format two services both parse, a date-encoding assumption baked into producer and consumer alike. None of it appears in the official contract, yet every one of those is a decision now reflected in multiple services, so changing it forces a coordinated multi-service release. This gives you a sharp test for whether a decomposition is sound: pick a likely future change and count how many services it forces you to touch. If routine changes keep rippling across boundaries, the boundary is in the wrong place — the services are sharing knowledge that should have lived inside one of them.
Orchestration vs. choreography
Once a workflow spans several components — and especially once it's asynchronous — a second question appears: who's in charge of the steps? Imagine the full ticket lifecycle: created, assigned to an expert, expert notified, resolved, customer surveyed. That's a multi-step workflow across several services or modules, and there are two fundamentally different ways to coordinate it.
In orchestration, one component is the conductor. An orchestrator service explicitly directs the workflow: it tells the assignment service to assign, waits for that, tells the notification service to notify, tracks where the workflow is, and handles failures of individual steps. The workflow logic lives in one place — you can read the orchestrator and see the whole process.
In choreography, there's no conductor. Each component reacts to events and emits its own, and the workflow emerges from the chain of reactions. The ticket service emits "ticket created"; the assignment service hears it, assigns, and emits "ticket assigned"; the notification service hears that and notifies. No single component knows the whole dance — each just knows its own step and what event triggers it.
The trade-off between them is one of the cleaner illustrations of the coupling-vs-clarity tension in this whole course. Orchestration centralizes the workflow, which makes it easy to understand, monitor, and change — the process is written down in one readable place, and you always know its state. The cost is that the orchestrator is a central point everything depends on: it's coupled to all the participants, it can become a bottleneck, and it's a single thing whose failure stops the workflow. Choreography decentralizes, which maximizes decoupling — each component only knows about events, not about the other components, so you can add a new reaction (a new consumer of "ticket created") without touching anything else. The cost is that the workflow exists nowhere as a readable whole; it's an emergent property of scattered reactions, which makes it genuinely hard to understand the end-to-end process, to know its current state, or to debug it when it goes wrong. You trade the orchestrator's central coupling for the difficulty of reasoning about a process that no single place describes.
The practical guidance: orchestration when the workflow is complex, needs monitoring, or has intricate failure handling — a payment-and-fulfillment process where you really need to know what state every order is in and handle each failure precisely. Choreography when decoupling and independent evolution matter more than central visibility — broadcast-style flows where many components react to an occurrence and you want to add and remove reactions freely. Many systems use both: orchestration for the critical, complex core workflows where visibility is worth the coupling, choreography for the peripheral reactions where decoupling is worth the opacity. (This is also the doorway to distributed transactions and the saga pattern — how you keep a multi-step workflow consistent when any step can fail — which we take up in the synthesis and capstone chapters, because it builds directly on this choice.)
This same choice resurfaces, almost unchanged, when the steps being coordinated are LLM calls rather than service calls — increasingly common, and the subject of the capstone's AI axis. Anthropic's Building Effective Agents names the patterns in those terms: an orchestrator-workers setup (a coordinating model or controller farming subtasks out and combining the results) is orchestration; LLM steps that each react to an event, or an evaluator-optimizer loop where one call critiques another's output, lean choreographed. The vocabulary is newer, but the trade-off is the one you just learned — central visibility and control versus decoupling and easy extension — so you reason about an agentic workflow with exactly the lens from this section. (Flagged: the agentic-pattern names are current-ecosystem; the coordination trade-off is the chapter's and the source books'.)
That's the communication toolkit: choose sync or async per call by whether the caller needs the answer, mix them deliberately, and for multi-step workflows, choose orchestration or choreography by whether you value central visibility or maximal decoupling. Time to apply it.
Practice
The skill here is deciding sync vs. async per interaction by whether the caller needs the result, and recognizing when a workflow wants a conductor versus emergent coordination. The exercises build toward redesigning a real flow. Budget about two hours.
Exercise 1: Audit a flow for needless waiting (20 min)
Pick a request handler in a system you work on that does several things before responding. List each step, and for each, answer one question: does the caller (the user) actually need this done before they get their response? Flag every step that's being awaited synchronously but doesn't need to be. That flagged list is your async opportunity — and often your latency problem.
Exercise 2: Redesign sync to async (45 min)
Take the flow from Exercise 1 and redesign it: which steps stay synchronous (the caller needs them), which move to a queue (they don't), and what the response now means ("submitted" rather than "fully done"). Then name what you bought (faster response, decoupling, burst absorption) and what you now owe (retry handling, dead-letter handling, the "queued ≠ done" mental shift, observability for the async work). Be specific about the costs — the point is that async isn't free.
If you sketch the queue design with an AI assistant, interrogate it on the failure cases specifically: what happens when a job fails repeatedly, where do dead letters go, and is the consumer idempotent (Chapter 4)? Those are the parts that are easy to wave away and expensive to get wrong.
Exercise 3: Orchestration or choreography? (45 min)
Take a multi-step workflow in your system (or invent one — order fulfillment, user onboarding). Sketch it twice: once orchestrated (a coordinator directing the steps), once choreographed (components reacting to events). Write two or three sentences on which you'd choose for this workflow and why, in terms of whether central visibility or decoupling matters more here. Note what you'd lose either way.
Deliverable
A one-page communication plan for a real multi-step flow, covering: which interactions are synchronous and why, which are asynchronous and what that buys and costs, and — if it's a multi-step workflow — whether you'd orchestrate or choreograph it, with the reasoning. A reviewer will read it in about fifteen minutes and respond to one question: is each sync/async choice justified by whether the caller needs the result, or is it just following the framework's default?
Check yourself
Before you consider this chapter done, you should be able to:
- Explain temporal coupling and why a chain of synchronous dependencies hurts availability.
- State the decisive question for sync vs. async: does the caller need the result to continue?
- Describe what a queue buys (decoupling, resilience, burst absorption) and what it costs (complexity, ordering, delivery, dead-letters, quieter failures).
- Explain the difference between orchestration and choreography, and the central-coupling vs. hard-to-trace trade-off between them.
- Justify a mixed design — why most real systems are deliberately part sync, part async.
If sync-vs-async still feels like a default rather than a decision, redo Exercise 1 — the "does the caller actually need this?" question is the whole game.
What a strong answer demonstrates: a strong communication plan justifies each sync/async choice by whether the caller actually needs the result to continue — not by the framework's default — and is honest about what async costs (deferred work, at-least-once delivery, dead-letters, quieter failures), not just what it buys. For a multi-step workflow, the clearest signal of understanding is choosing orchestration or choreography by whether central visibility or maximal decoupling matters more here, and naming what you lose either way.
Going deeper (optional)
- Building Microservices (Newman), the chapters on communication styles and on workflow — the direct source for synchronous vs. asynchronous communication and for the orchestration/choreography distinction, with more depth on the messaging mechanics.
- Software Architecture: The Hard Parts, the chapters on managing distributed workflows and on contracts — for orchestration vs. choreography as a trade-off analysis, and for how the saga pattern keeps multi-step workflows consistent (which we return to in the capstone).
- Designing Data-Intensive Applications (Kleppmann), the chapter on encoding and evolution — for how messages are serialized and how their schemas evolve safely over time, which matters the moment async messages outlive the code that wrote them.
Primary sources (the foundational texts beneath messaging and workflows, if you want the theory on-ramp):
- Gregor Hohpe & Bobby Woolf, Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions (Addison-Wesley, 2003) — the canonical catalog of asynchronous-messaging patterns (channels, dead-letter queues, message routing); the reference behind this chapter's queue and broker material.
- Hector Garcia-Molina & Kenneth Salem, "Sagas" (ACM SIGMOD, 1987) — the original paper on breaking a long transaction into a sequence of steps with compensations, the theoretical root of the saga pattern this chapter points forward to.
- Pat Helland, "Life Beyond Distributed Transactions: an Apostate's Opinion" (CIDR 2007) — a short, influential argument for why distributed systems give up cross-service transactions and coordinate with messages and idempotent activities instead; the deep "why" behind choreography and at-least-once delivery.
None of this is required to continue.
Key takeaways
- How parts communicate is a deliberate choice, not a framework default — and sync vs. async create genuinely different systems.
- Synchronous is simple and gives immediate complete results, paying with temporal coupling: the caller's fate and speed are bound to the callee's, and chained dependencies erode availability.
- Asynchronous decouples in time and absorbs failure and load, paying with complexity: deferred work, at-least-once delivery, ordering, dead-letters, and quieter failures.
- The decisive question per interaction is does the caller need the result to continue? Most good systems are a deliberate mix.
- For multi-step workflows, orchestration centralizes the process (readable, monitorable, but coupled to a coordinator) while choreography decentralizes it (maximally decoupled, but hard to trace) — choose by whether visibility or decoupling matters more.
Next chapter: Architecture Governance — we close the foundational track by asking how architectural decisions actually stick: how you record them, how you keep a system from drifting away from its intended design, and how architecture evolves on purpose rather than by decay. This is where ADRs and fitness functions come together.