Every architectural move in this stage — splitting a monolith, drawing a service boundary, replacing a function call with an event, putting a gateway at the edge — is the same move: reduce the number of things that must change together, deploy together, or be up at the same instant. That is all coupling is. The rest is vocabulary.
Microservices run on the compute ladder from C3 (containers and serverless especially). They talk over the VPC you networked in C4. They authenticate to each other and to message buses with the workload identities from C2. Each one owning its own datastore is the database-per-service consequence of C5 — and event sourcing is, at bottom, a storage pattern. None of that is re-taught here. This stage is about where the seams go and what travels across them.
The two axes that actually matter
Signature visualArchitectures are usually argued about on one axis — "monolith or microservices?" — and that framing is what produces the disaster. There are two independent axes, and the interesting information is in which quadrant you land.
Where coupling hides at each stage
Coupling is in the call graph
Any function can call any other; any module can reach into any table. Nothing enforces a boundary, so boundaries erode. The tell: you cannot answer "what breaks if I change this?" without reading the whole codebase.
Upside that gets forgotten: one deploy, one stack trace, real ACID transactions.
Coupling moves into the API and the schema
The network makes calls explicit, which is progress — you can now see the dependencies. But a breaking field change in a shared response body still forces a coordinated release, and a shared database re-couples everything silently.
New cost: availability now multiplies down the chain.
Coupling retreats into the event schema
The emitter no longer knows who listens, and neither side has to be up at the same instant. What remains coupled is the meaning of the event: change the payload contract and every consumer breaks at once, silently, at 3 a.m.
New cost: no single stack trace; ordering and duplicates become your problem.
The distributed monolith. Services split apart but wired so tightly they must still deploy together. Symptoms: a release train where six repos ship in a fixed order; services sharing one database; a request that fans out through five synchronous hops before anything is returned; a change to one service's response shape that requires a ticket on three other teams. You have paid the full operational bill of distribution — network failures, distributed tracing, per-service pipelines, eventual consistency — and bought none of the independence it was supposed to fund.
How the four sessions build
Clickable mapDecision tree — which session should you act on first?
The sessions are ordered for teaching, but a real system usually has one problem that dominates. If you are here to fix something rather than to study in order, start where the pain is.
Follow no ↓ down the left. The first yes exits right. Bottom-left is the default.
What you'll be able to do at the end
- Decide monolith vs microservices for a specific system, with a defensible reason that isn't "Netflix does it".
- Draw service boundaries around business capabilities rather than database tables, and say why a given boundary is too big or too small.
- Design an event-driven flow and judge honestly when async is worth its complexity.
- Recognise a distributed monolith in an architecture diagram in under a minute.
- Pick queue vs topic vs event bus on any of the three clouds, and know the ordering and delivery guarantee you just signed up for.
- Place a gateway, a BFF, and a CDN correctly — and know when each is the wrong answer.
- Critique your own FastAPI gateway and inference router against all of the above.
How this stage is taught
Ground rulesPatterns carry this stage; clouds supply plumbing
S1 and S2 are almost pure design. There is no "AWS bounded context" and no "Azure saga" — forcing a three-cloud table onto those sessions would be noise, so those tabs mark themselves concept-only and skip it.
S3 and S4 do touch real products — message brokers and edge services — so both carry a full AWS | Azure | GCP comparison.
Intuition → mechanism → trade-offs
Pass 1 is plain words and an analogy. Pass 2 is how it actually works, with a diagram. Pass 3 is the numbers, the limits, and where it breaks. Consoles, CLI, and click-paths appear only in each tab's final "apply it" section.
Field notes — what changed recently
Four things differ from the plan as given:
Cloud_Patterns.pdf · A cloud patterns catalog, the cloud application patterns catalog
§4 and §5 confirmed; §6 and §8 need two adjustments
§4 is "Microservices Architecture" is "Microservice Design" is "Event-Driven Architecture" — all as planned. But CQRS is not in §6; it lives in §7 (Cloud-Native Storage), consistent with the material treating it as a storage pattern. Event Sourcing is in §6. Both are taught in S2 anyway, sourced from their real homes.
Cloud_Patterns.pdf
§8 is about clients, not gateways — the gateway pattern is in §4
"Cloud Application Clients" covers browser apps, single-page apps, micro frontends, mobile, CLI, and Public API. The API-gateway/BFF pattern this material calls Dispatcher is in §4 (it lists "Backend for Frontend" and "API Gateway" as its aliases). S4 therefore draws on §8 and the §4 Dispatcher pattern. The material also has no real CDN coverage, so that is grounded from vendor docs.
Cloud_Patterns.pdf · the saga pattern
The material does not teach sagas — it cites them out
"Saga" appears twice in the whole source, both times as a cross-reference to Chris the well-known microservices patterns catalog (2018). The material's own answer to distributed transactions is its Service Orchestrator pattern. S2 teaches sagas properly from the wider literature and flags that it is going beyond the spine text.
GCP notes · GCP guidance
Right section, different title
§8 is titled "Introduction to Asynchronous Communication in GCP" — Pub/Sub and Dataflow are its two main sections, so the content matches the plan. Azure §12 "Integration Services" is confirmed exactly, but note it covers Event Grid and API Management in depth while giving Service Bus only a passing definition, and it puts Front Door and CDN in §13, not §12. S3 and S4 fill both gaps from vendor documentation.
AWS notes · AWS guidance, AWS architecture guidance
Confirmed: no dedicated application-architecture section
As expected. Every AWS messaging and edge service in S3 and S4 — SQS, SNS, EventBridge, API Gateway, CloudFront — is sourced from current AWS documentation and announcements rather than from this material, and each tab says so where it happens.
S1 draws the boundaries. Everything in S2, S3, and S4 assumes you got them roughly right — because no amount of good messaging plumbing rescues a bad service boundary.
Why this session exists
10%A team of forty ships once every three weeks. Every release is a negotiation: the payments change has to wait for the search re-index, which is blocked on a schema migration owned by a third team. Nobody wrote a bad line of code. The problem is that everything is in one deployable unit, so everything moves at the speed of the slowest thing in it.
Microservices are the response — but the response is organisational before it is technical. The point is not smaller programs. The point is that a team can decide, build, test, and release without asking anyone's permission. Every technical property people quote — independent scaling, polyglot stacks, failure isolation — is downstream of that.
Which means the decisive question of this session isn't "how do I split this?" It's "where do the seams go so that most changes land inside exactly one of them?" Get that wrong and you have built the distributed monolith: forty people who now need more coordination than before, plus a network between them.
There is no AWS bounded context and no GCP aggregate. Decomposition is a design activity that happens on a whiteboard before any cloud is chosen, and the same answer deploys to all three. The clouds re-enter in S3 (what carries the messages) and S4 (what sits at the edge). Where this tab touches infrastructure at all, it points back to the C3 compute ladder rather than re-teaching it.
Core concepts
50%Cohesion and coupling, in one kitchen
Picture a restaurant kitchen. Cohesion is how much the things at one station belong together: the grill station has the grill, the tongs, the meat, and the person who knows how meat behaves. High cohesion means everything you need for one job is within arm's reach.
Coupling is how much one station has to interrupt another to get its job done. If the grill cook has to shout across the room every thirty seconds to ask the pastry station what temperature to use, those two stations are coupled — and neither can be replaced, retrained, or moved without disrupting the other.
A monolith is one enormous station where everybody works on the same bench. A good microservices architecture is a set of stations where each one can be re-staffed mid-service without the others noticing. A distributed monolith is what you get when you put the stations in separate buildings but keep the shouting.
- Cohesion
- How strongly the things inside one boundary belong together. High is good. If a service handles "pricing rules" and "PDF generation", it has low cohesion — two unrelated reasons to change live in one unit.
- Coupling
- How much a change on one side of a boundary forces a change on the other. Low is good. Two services are coupled if you cannot deploy a change to one without also releasing the other.
- Monolith
- An application built, deployed, and scaled as a single unit. Calls between modules are in-process function calls. It fails as a unit and it scales as a unit.
- Microservice
- An independently deployable service, owned by one small team, organised around a business capability, with its own datastore, reached through a defined API. Sam Newman's definition reduces to the first three words: independently deployable service.
- Distributed monolith
- Services that are separately deployed but not separately deployable — because of shared databases, lock-step release trains, or synchronous call chains. The failure mode this whole stage exists to prevent.
The three architectures, drawn so the difference is visible
These diagrams get drawn wrong constantly: microservices and distributed monoliths look identical in most slide decks, because both are "boxes with arrows". The difference is not the number of boxes. It is where the data lives and what has to ship together.
The design sequence: from conversation to service boundary
the patterns catalog's source is unusual in that it does not start from technology at all. It starts from a room full of people with sticky notes, and it insists that if you skip that step you will derive your services from your existing database schema and call them microservices.
Get the domain experts in the room with the engineers
An event storming workshop is a facilitated session where everyone who knows the business writes down, on sticky notes along a timeline, the things that happen in the domain. Not tables. Not screens. Happenings, in past tense.
The reason this is step one and not step four: the alternative is a developer acting as translator between the expert's mental model and the code, and every translation loses something. The material's test for whether you skipped this: your architecture has few or no services named after things the business would recognise.
Agree what the words mean — and accept that they mean different things elsewhere
The ubiquitous language is the shared vocabulary used identically by the
domain experts, the engineers, and the source code. If the business says "shipment" and the
code says DeliveryRecord, you have a translation layer nobody documented.
The crucial insight is that the language is only ubiquitous within a boundary.
"Customer" in a retail sales context means a person who buys. "Customer" in a marketing
context means a person you advertise to. Same human, genuinely different objects with
different rules. Forcing them into one shared Customer class is one of the most
reliable ways to build a distributed monolith.
Domain events are the raw material of both boundaries and messages
A domain event is an announcement that something meaningful happened in
the business — OrderPlaced, PaymentCaptured,
InventoryReserved. Past tense, because it already occurred and cannot be
declined.
These notes on the wall do double duty. In this session they cluster into boundaries. In S2 the very same events become the messages travelling over the wire. That continuity is why the material puts event storming in the design section rather than the event-driven one.
Aggregates: the unit of consistency
An aggregate is a cluster of related entities whose lifecycles are tied
together and which must stay consistent as a group. The canonical example is
Order and its LineItems: a line item has no independent existence,
and you cannot validate "order total" without seeing all of them at once.
The aggregate is the transaction boundary. Everything inside it can be updated in one ACID transaction. Everything outside it cannot — which is precisely why aggregates set the lower bound on service size, coming up in Pass 3.
Bounded contexts: where a model stops being true
A bounded context is the logical boundary within which a particular set of terms and rules applies consistently. Inside it, "account" means one specific thing. Cross the boundary into the Loans context and "account" means something else, with different invariants and a different owner.
The material's justification is cognitive, not technical: a single model covering an entire enterprise domain grows past what a human can hold in working memory, and when two teams share one model they make changes that are each internally consistent and mutually contradictory. A bounded context is a cohesive group of one or more aggregates plus the behaviour over them.
The context map: draw the relationships, and name the owner
A context map shows the bounded contexts and the relationships between them — which context sends what to which, and critically, which team owns each one. It should be simple enough that a domain expert can read it.
This is where an architecture argument becomes an organisational one. If your context map shows one context owned by three teams, or one team owning nine contexts, the boundaries and the org chart disagree, and one of them is going to have to move.
Anti-corruption layer: a translation membrane at a boundary you don't control
An anti-corruption layer (ACL) is a translation shim that sits between your context and a foreign one — a legacy system, a vendor API, another team's context that changes faster than you'd like — and converts their model into yours at the edge.
Without it, the foreign model leaks inward: the vendor's field names appear in your domain objects, their nulls become your special cases, and their next breaking change propagates to every file you own. With it, exactly one class changes. In S4 this same idea reappears one layer up as the Dispatcher protecting clients from service churn.
What an aggregate looks like on paper
Short enough to be the mechanism itself rather than a console step — this is a consistency boundary written as a data shape:
# AGGREGATE ROOT: Order — one transaction, one owner, one service Order id, customer_ref # a REFERENCE, not an embedded Customer object status, placed_at LineItem[] # inside the boundary: no life of its own sku_ref, qty, price_at_time_of_order invariant: sum(line_items.qty) > 0 and status transitions are legal
Two details carry the whole design. customer_ref is an identifier, not an
embedded object — the Customer lives in another context and you refuse to hold a copy of its
model. And price_at_time_of_order is a snapshot, not a lookup — because the
Catalogue context is free to reprice tomorrow and your order must not silently change.
How big should a service be? Bounds, not vibes
"Two-pizza team" and "small enough to rewrite in two weeks" are folklore. the patterns catalog's source gives an actual pair of bounds, and they are the most useful thing in the section:
No smaller than one aggregate
A microservice should contain at least one aggregate plus the domain and application services that operate on it. Go smaller — a service per entity, or per function — and you have split a transaction boundary across the network. Now updating an order and its line items needs a distributed transaction, which is exactly the thing the cloud is worst at.
If that means one service exposes twenty REST operations, that is fine. The material is explicit: err on the side of too large. Splitting a coarse service later is routine; merging two fine-grained ones that already have separate datastores, separate teams, and separate consumers is not.
No larger than one bounded context
A microservice should be no bigger than the bounded context around a cohesive group of aggregates. Past that, you are hosting two vocabularies in one deployable and the internal coupling starts growing back.
There is a sharp litmus test for the upper bound: if this service fails and more than one business capability goes down, it is too coarse — or you have put it on the critical path of several unrelated flows, which is its own problem.
IDEALS — the design principles behind those bounds
The material's mnemonic for microservice design, the service-level counterpart to SOLID. Two of the six are the entire argument of this stage:
I — Interface segregation
Different client types should reach the service through the contract that suits them, not one lowest-common-denominator API. This is the principle that produces the BFF in S4: a mobile app and a partner integration get different front doors onto the same capabilities.
D — Deployability
Microservices multiply deployment units, so packaging, pipelines, containerisation, monitoring, and progressive rollout stop being nice-to-haves and become load-bearing. This is the C3 compute ladder doing its job — and the practical reason a five-person team should usually not attempt this: the fixed cost of the pipeline is paid per service.
E — Event-driven
Where you can, activate a service with an asynchronous message rather than a synchronous call. Synchronous request/response remains necessary, but it should be the exception you justify, not the default you fall into. This principle is the whole of S2.
A — Availability over consistency
Under CAP, a partitioned distributed system must choose. In most user-facing systems people would rather see slightly stale data than an error page, so favour availability and accept eventual consistency. The exceptions are real and you should be able to name yours — ledger balances and inventory-at-zero are the usual ones.
L — Loose coupling
Watch both afferent coupling (how many things depend on you) and efferent coupling (how many things you depend on). The material names the failure directly: a service with many dependencies makes the system a distributed monolith, or as they put it, a "microlith" — worse than any monolith, because you kept the coupling and distributed it.
S — Single responsibility
The right-size service, cohesive around one thing. Easiest to state, hardest to achieve — which is why the answer is to model around the domain rather than to guess at a line count.
API design and versioning: where coupling survives the split
You can draw perfect boundaries and still re-couple everything through the contract. Three rules that carry most of the weight:
Additive changes are free; removals are not
Adding an optional field breaks nobody if consumers ignore unknown fields — so make tolerant reading a written expectation, not a hope. Removing a field, renaming one, tightening a type, or making an optional field required are all breaking, and all need a new version.
Run two versions, briefly, on purpose
The material's warning is from the field: teams end up supporting six concurrent versions because consumers move slower than producers. One live version is too few for a breaking change; the upper limit is whatever you can genuinely test. Set a sunset date when you launch v2, not when v3 is due.
Never share a database to avoid an API
The moment two services read the same table, that table's schema is a public API with no version, no owner, and no test. This is the single fastest route from C back to B in the diagram above — and it is usually done for a good short-term reason.
Version in the URL path (/v2/orders) when you want it obvious, cacheable, and
trivially routable at a gateway — the pragmatic default. Version in a header
(Accept: application/vnd.acme.v2+json) when you want the resource identity to stay
stable and versioning to be a content-negotiation concern. Whichever you pick, the important
part is that the version is detectable by the client, so a consumer can tell whether
it is compatible before it sends a request rather than after.
Decision tree — monolith or microservices for this system?
Follow no ↓ down the left. The first yes exits right. Bottom-left is the default.
Reality check
25%Worked example — the same domain, split two ways
An online bookstore. Six nouns: users, orders, items, payments, inventory, shipments. Watch what happens when you decompose by table versus by business capability, and count the network hops needed to place one three-line order.
| Order service → User service · validate customer | 1 hop |
| Order service → Item service · price lookup, 3 line items | 3 hops |
| Order service → Inventory service · reserve, 3 line items | 3 hops |
| Order service → Payment service · charge card | 1 hop |
| Order service → Shipment service · create shipment | 1 hop |
| Synchronous hops on the critical path | 9 |
| Availability, each service at 99.9% → 0.9999 | 99.104% |
| Expected downtime per 30-day month | 387 min · 6h27m |
| Ordering owns Order + LineItems (one aggregate) — no hop needed | 0 hops |
| Ordering → Catalogue · one batched price snapshot for all 3 items | 1 hop |
| Ordering → Payment · charge card | 1 hop |
Ordering publishes OrderPlaced; Fulfilment reacts | async |
| Synchronous hops on the critical path | 2 |
| Availability, each service at 99.9% → 0.9992 | 99.800% |
| Expected downtime per 30-day month | 86 min · 1h26m |
Synchronous availability multiplies. Ten services at a very respectable 99.9% each, chained,
give 0.99910 = 99.004% — about 7 hours 10 minutes of downtime a
month. You cannot buy your way out of this with better instances; the only fixes are
fewer hops on the critical path, or hops that are allowed to fail without failing the request.
That second option is what S2 sells.
What happened when real teams did this
Prime Video Tech blog, 2023 · widely re-analysed since
Amazon's own Prime Video team moved a service from distributed serverless back to a monolith — 90% cheaper
The Video Quality Analysis team's stream-monitoring tool was built as distributed components orchestrated by AWS Step Functions, passing video frames between stages via S3. It hit a hard scaling ceiling at roughly 5% of target load, and the orchestration state-transition charges plus the S3 round-trips dominated cost. Consolidating the components into a single process — frames now moving in memory — cut infrastructure cost by over 90% and raised the ceiling.
The correct lesson, which the internet mostly missed: this was not "microservices are bad". It was a boundary drawn through a tight data-flow loop. Media conversion and defect detection pass large binary payloads to each other thousands of times a second — that is maximum cohesion, and putting a network and an object store in the middle of it was the error. The rebuilt system still has separate components, still scales horizontally by cloning; the boundary just moved to where the traffic wasn't. Relevant to you: this is exactly the shape of a GPU inference pipeline.
Airbnb engineering, as reported in 2026 architecture reviews
Airbnb's answer to too-fine granularity: "macroservices" / Service Blocks
After a large microservices build-out, Airbnb found that despite — and because of — the number of services, the estate behaved like a distributed monolith: dense synchronous dependencies between services meant changes still rippled. The response was not to return to a monolith but to group clusters of related microservices into coarser units, restoring cohesion at a higher level. This is the material's "err on the side of too large" advice arrived at the expensive way.
Twilio Segment, "Goodbye Microservices" · plus 2025–26 industry commentary
The consolidation trend is real, but the reported numbers deserve caution
Segment's much-cited reversal moved from per-destination microservices back to a monolith after the operational cost of maintaining hundreds of near-identical services and their queues outgrew the benefit. Several 2025–26 surveys and roundups report that a large minority of organisations have consolidated at least some services back into larger units; one widely-repeated figure of ~42% is attributed to a CNCF survey, but it circulates through secondary write-ups and I could not verify it against a primary CNCF publication — treat it as directional, not as a statistic to quote in a design review. The qualitative pattern, that consolidation is now a normal and respectable move, is well attested.
Two ways decomposition behaves differently when GPUs are in the picture.
First, the scaling-profile argument is unusually strong. Most "we need microservices to scale independently" claims are theoretical. Yours is not: a T4 or L4 replica costs orders of magnitude more per hour than the CPU pod serving your API, and inference concurrency is bounded by KV-cache memory rather than CPU. Coupling those two into one deployable means scaling the expensive thing to meet the demand of the cheap thing. This is a reference text case for extracting exactly one service.
Second, the Prime Video trap is live for you. Anywhere your pipeline passes large tensors, embeddings, or document images between stages, a service boundary inserts serialisation plus network plus possibly object storage into a hot loop. Boundaries belong where the payload is small and the call rate is low — between "accept the request" and "run the model", not in the middle of prefill and decode.
Apply it — your context
10%Critiquing your FastAPI gateway and intelligent router against the patterns
You have already built one of the things this section describes, before reading the section. Here is what the patterns say about it — the good and the exposed.
Your gateway is a reference text Dispatcher
A single stable endpoint in front of an evolving set of backends, doing routing and request shaping, holding no domain logic of its own — that is the patterns catalog's Dispatcher pattern almost to the letter, and it is the pattern S4 expands. The router deciding which model handles a request is the "orchestration" responsibility the source assigns to a Dispatcher.
You also got the boundary in the right place: the seam between routing logic and model execution is a low-frequency, small-payload call. That is the opposite of the Prime Video mistake.
Four things the patterns say to check
1 · Is it also a Dispatcher for a second client type? The material insists on one Dispatcher per client type. If your batch consumers and your interactive consumers share one endpoint and one timeout policy, you are about to discover why.
2 · Does it hold domain logic? If routing rules encode business policy — tier-based model selection, cost caps — that logic is now duplicated or trapped. The material is explicit that a Dispatcher should not contain domain logic.
3 · Availability multiplies through it. Gateway → router → model endpoint is already three synchronous hops before a token is generated.
4 · Is the API versioned? If clients call /generate with no
version, your first breaking change is a coordinated release across every consumer — the
distributed monolith arriving through the front door.
The bounded contexts hiding in your fintech document pipeline
Ingest → analyse → score reads like three services because it is three steps. Steps are not boundaries. Apply the two bounds from Pass 3:
- Ingest owns a genuine aggregate: the Document, with its pages, source metadata, and upload lifecycle. Different vocabulary ("page", "OCR confidence", "source system"), different failure modes, different scaling profile — it is I/O and storage bound. A real bounded context.
- Analyse and score are far more suspect as separate services. If scoring always immediately follows analysis on the same document, passes large extracted structures between the two, and neither is independently useful, that boundary buys nothing and costs a serialisation hop. The upper-bound litmus test: if Analyse goes down, does more than one business capability fail? If the answer is "no, only scoring, which is meaningless without it", they are one context.
- The strongest boundary in that pipeline is probably not between the steps at all. It is between the synchronous request path and the long-running work — which is an S2 question, not an S1 one.
Deliberately a paper lab. There is no cloud resource that teaches boundary-drawing, and spending money to learn this would teach you nothing extra.
- List the domain events in your inference stack, past tense, on one
line each.
RequestReceived,ModelSelected,PromptTokenised,InferenceCompleted,UsageRecorded,QuotaExceeded,ModelVersionPromoted. Aim for fifteen or more. - Cluster them into groups where the same vocabulary applies throughout. You will likely find three or four: request admission, model execution, usage/billing, model lifecycle.
- Name the aggregate in each cluster and write its invariant. If a cluster has no aggregate and no invariant, it is probably a step, not a context — merge it.
- Apply the upper-bound test to each: if this fails, how many business capabilities stop? More than one means split. Exactly one means correct.
- Apply the lower-bound test: does any proposed service need a transaction that spans into another? If yes, you split an aggregate — merge them back.
- Count the synchronous hops on your hottest path and compute
0.999n. Write the monthly downtime number on the diagram. That number is the argument for S2.
If you want a cloud artifact out of it: commit the context map as a Mermaid diagram in your repo next to the ADR that justifies each boundary. Free, versioned, and it is the document you will actually be glad to have in six months.
Nothing to tear down — no resources were created. This is the only lab in this stage with that property. From S3 onward, the labs create billable managed services that idle-charge, and the teardown steps there are not optional.
You now have boundaries — and a downtime number showing that synchronous chains across them multiply failure. S2 attacks that number directly: what if a hop didn't have to be up when you called it, and didn't have to answer at all?
Why this session exists
10%S1 ended on a number: nine synchronous hops at 99.9% each gives you six and a half hours of downtime a month. Drawing better boundaries got that to two hops. But two is not zero, and every remaining hop still carries the same hidden requirement — the callee must be up, right now, at the exact moment the caller needs it.
A service API decouples what from how: swap the implementation, the caller never knows. What it does not decouple is when and where. The caller must locate the provider. The caller waits while the provider works. If the provider fails, the caller must handle it or fail too. That is coupling in time and space, and no amount of interface design removes it.
The material's motivating example is employee onboarding. A new hire triggers an email account, a payroll entry, a welcome package, a badge, an equipment order — and HR policy keeps adding to that list. Written synchronously, some onboarding service must know every downstream system, call each one, and be updated every time the list changes. It is coupled to all of them and it cannot run unless they are all available.
The alternative is to stop calling and start announcing: publish
EmployeeHired and let whoever cares react. The onboarding service no longer knows
who is listening. New listeners appear without it changing. That inversion — from invocation to
notification — is the whole session.
Core concepts
50%Phone call, voicemail, noticeboard
The phone call
You dial. They must be at their desk. You hold the line, doing nothing, until they answer your question. If they are out, your task fails. If they are slow, you are slow. You need their number, so you must know exactly who they are.
Coupled in: time, space, and identity.
The voicemail
You leave a message and hang up. They pick it up when they are back. You are no longer blocked and they no longer need to be available — but you still had to know whose voicemail to call, and the message is meant for exactly one recipient.
Decoupled in: time. Still coupled in: identity.
The noticeboard
You pin up "the shipment left at 14:00" and walk away. You have no idea who reads it. Three departments might; tomorrow a fourth starts, and you never find out. You are not asking anyone to do anything — you are stating a fact.
Decoupled in: time, space, and identity.
That third column is the definition of an event-driven architecture, and it explains the one rule people get wrong: an event is not an instruction. "Employee hired" is an event. "Create an email account" is a command. If your "events" are secretly commands — if the publisher knows and expects what the subscriber will do — you have built a synchronous system with extra latency and no stack trace.
- Synchronous
- The caller blocks until the callee responds. Both must be available simultaneously. One logical transaction, one thread held open.
- Asynchronous
- The sender hands off and continues immediately. Sender and receiver need never be up at the same instant. Separate transactions on each side.
- Pseudosynchronous (the trap)
- Putting a request on a queue and then blocking while waiting for the reply. You have taken
on all the complexity of messaging — correlation IDs, two transactions, crash recovery — and
kept the blocking. The material names it specifically as a thing to avoid. If your "async" code
awaits a response before returning, check which one you built. - Queue
- Point-to-point. A message goes to exactly one consumer, and is removed once acknowledged. N consumers on a queue means work is shared among them. This is a load distributor.
- Topic
- Publish-subscribe. A published message is copied to every subscription. N subscribers means N independent copies. This is a broadcast.
- Event
- An immutable statement that something happened, in the past tense, with no expectation of a particular reaction. Carries what changed, not what to do about it.
- Command
- A request that something specific should happen, addressed to a known handler. Legitimate — but it belongs on a queue, not a topic, and it re-introduces identity coupling.
- Event backbone
- The shared infrastructure carrying all topics, so emitters and listeners connect to it rather than to each other. Kafka, EventBridge, Pub/Sub, Service Bus, Event Grid all play this role. The material is candid that it is a logical single point of failure.
- Idempotency
- The property that processing the same message twice produces the same result as processing it once. Non-negotiable in practice, for reasons in Pass 3.
- Dead-letter queue (DLQ)
- A side queue where messages go after N failed delivery or processing attempts, so one bad message cannot block the queue forever or be silently discarded.
The same business flow, twice
Step through an order being placed as a synchronous chain, then as events. Same domain, same work performed — different failure behaviour, different latency, different blast radius.
Synchronous — the customer presses "Place order"
Ordering receives the request and holds the HTTP connection open. It calls Payment and waits. The customer's browser is waiting. A thread on Ordering is waiting. A connection in the pool is held.
Latency so far: 0 ms + however long Payment takes.
Synchronous — Payment returns, Ordering calls Inventory
Payment succeeded in 180 ms. Ordering now calls Inventory to decrement stock, and waits again. Note that the card has already been charged — if anything after this point fails, real money has moved and something must un-move it.
Latency so far: 180 ms, still holding everything open.
Synchronous — Inventory returns, Ordering calls Shipping, then responds
Inventory took 90 ms, Shipping 140 ms. Total 410 ms before the customer sees anything. The response time the customer experiences is the sum of every hop, and the availability they experience is the product of every hop.
Total: 410 ms · availability 0.999³ = 99.70% · 130 min/month.
Synchronous — now Shipping is down
Payment charged. Inventory decremented. Shipping times out after 30 s. What does Ordering return? The customer's card has been debited and there is no order. Ordering must now explicitly refund the payment and restore the stock — and handle the case where those compensations also fail.
One downstream outage produced an inconsistent business state and a 30-second page load. This is the moment sagas become mandatory.
Asynchronous — the customer presses "Place order"
Ordering validates the request against its own aggregate, writes the order as
PENDING to its own database in one local ACID transaction, publishes
OrderPlaced to a topic, and returns 202 Accepted with an order ID.
Latency: ~25 ms. The customer has an order number and a page that says "confirming". Nothing downstream has been contacted.
Asynchronous — three consumers receive their own copy
Payment, Inventory, and Notifications each hold an independent subscription to the topic.
Each gets its own copy of OrderPlaced, processes at its own pace, and
acknowledges independently. They do not know about each other and they do not run in a fixed
order.
Ordering has no idea any of them exist. That is the point — and it is also the cost, discussed in Pass 3.
Asynchronous — a fourth consumer appears, and nothing changes
Six months later, Fraud Analytics wants order data. It creates a subscription on the existing topic. Zero lines change in Ordering. Zero deployments coordinate. No ticket is filed against the Ordering team.
This is the payoff. The rate at which you can add consumers without touching producers is the practical measure of how decoupled a system really is.
Asynchronous — now Shipping is down
Shipping's subscription stops acknowledging. Messages accumulate in its backlog. The customer's checkout was never affected — it completed in 25 ms an hour ago. Payment and Inventory processed normally. When Shipping comes back, it drains the backlog and catches up.
The outage became a delay instead of a failure. That single sentence is why this stage exists. The cost: for that hour, the customer's order genuinely had no shipment, and your UI had to be honest about that.
Queue versus topic — the diagram that settles most arguments
What an event actually looks like on the wire
The schema is the mechanism here — this is the contract that stays coupled after everything else has been decoupled, so it earns its place in Pass 2:
{
"specversion": "1.0", // CloudEvents — a portable envelope
"type": "com.acme.order.placed.v1", // past tense + VERSION in the type
"source": "/ordering", // who emitted, not who should react
"id": "01JH9Q...ULID", // the dedup key consumers store
"time": "2026-07-28T14:02:11Z",
"data": { "orderId": "A-4471", "total": 4299, "currency": "USD" }
}
Three deliberate choices. The version lives in the type, so v1 and v2 can coexist
on one topic and consumers filter. The id is what makes idempotency possible at all.
And data carries a thin payload — enough to act on, not the whole aggregate,
because every field you publish becomes a field you can never remove.
The idempotency check, in five lines
def handle(event): if seen.add_if_absent(event["id"], ttl=7*86400) is False: return ACK # duplicate — already done, ack and move on do_the_work(event["data"]) # must be safe if we crash right here return ACK
The subtlety is the crash between line 4 and line 5: the ID is recorded, the work is done, the ack is lost. On redelivery you correctly skip. But if you crash between recording the ID and doing the work, you skip work that never happened. The robust version makes the dedup record and the business write part of the same local transaction — which is possible precisely because each service owns its own database.
Pass 3 — Trade-offs and limitsDelivery guarantees: pick two lies you can live with
May lose messages, never duplicates
Fire and forget. Acceptable for high-volume telemetry where a missing sample is meaningless. Almost never acceptable for anything a customer can see.
Never loses, may duplicate
What essentially every managed broker gives you. Duplicates arise from the most ordinary cause imaginable: the consumer processed the message successfully and then the acknowledgement was lost, so the broker redelivers. Build for this one.
Mostly a marketing term
Achievable only within a closed system that controls both the broker and the consumer's state store, via transactional coordination. Across a network to an arbitrary consumer it is not achievable. What vendors sell as "exactly-once" is at-least-once plus broker-side dedup over a bounded window.
Two broker families make opposite CAP trades. MQ-style systems (IBM MQ, RabbitMQ, Azure Service Bus, SQS) hold each message in exactly one queue manager and move it with distributed transactions — maximising consistency, so exactly-once is more attainable, but if that queue manager goes down its messages are unavailable until it recovers. Event-style systems (Kafka, Event Hubs, and the streaming tier of the cloud buses) replicate partitions across brokers — maximising availability, so a broker loss costs nothing, but eventually-consistent replication makes exactly-once very hard.
You are not choosing a product. You are choosing which failure you would rather have.
Ordering is more expensive than it looks
Global ordering across a topic and parallel consumption are fundamentally in tension: if any message may need to be processed before any other, you can only have one consumer. Every broker resolves this the same way — partial ordering within a key. Messages sharing an ordering key (a partition key, message group ID, ordering key) are strictly ordered relative to each other; messages with different keys are not, and can be processed in parallel.
Choosing that key is a real design decision. Key by orderId and every order's
events are ordered while different orders run in parallel — usually correct. Key by
customerId and you get ordering per customer but a single high-volume customer
becomes a hot partition that no amount of scaling relieves. S3 shows what each cloud charges you
for ordering, and the throughput ceilings are real.
Sagas — what replaces the transaction you just lost
A saga is a sequence of local transactions where each step publishes an event that triggers the next, and every step has a compensating action that semantically undoes it. There is no rollback, because there is no shared transaction to roll back. There is only "do the opposite, afterwards, and hope that is meaningful."
No coordinator — each step reacts to the last
Payment listens for OrderPlaced, emits PaymentCaptured;
Inventory listens for that, and so on. Maximum decoupling, no central bottleneck. The cost is
that the business process exists nowhere — to understand the flow you read
five codebases. Debugging is archaeology. Best for short flows, three steps or fewer.
A coordinator drives the steps and the compensations
One saga orchestrator holds the state machine and tells each participant what to do next. The process is legible and testable in one place, and compensation logic lives somewhere you can find it. The cost is a component coupled to every participant — the material's Service Orchestrator, with its warning that it cannot run unless all of them are reachable. Best for longer or regulated flows.
A database rollback leaves no trace. A compensation is a new business fact. Refunding a charge does not un-charge the card — it produces a debit and a credit, both visible on a statement, possibly in different billing periods, possibly with a fee. Un-reserving inventory does not restore the moment in time when that unit was available; someone else may have taken it. Design compensations as first-class business operations that the domain experts have agreed to, not as technical undo. And accept that some steps are genuinely uncompensatable — you cannot un-send an email — which means those steps go last.
Event sourcing and CQRS, at intuition level
Event sourcing — store the changes, derive the state
Instead of storing "account balance = 400", store every deposit and withdrawal, and compute the balance by replaying them. The event log becomes the source of truth; current state is a cached projection of it.
What you buy: a perfect audit trail for free; the ability to answer questions you had not thought of yet by re-deriving history; time-travel debugging; and the ability to fix a bug and replay to correct downstream state. The material motivates it with undo — reversing a fraudulent card transaction means understanding how the state was reached, which a single mutable balance simply cannot tell you.
What you pay: the log only grows, so you need snapshots to avoid replaying a million events on every read; events are immutable, so a schema change means versioned event types and upcasting logic forever; and querying "all accounts over $1000" is genuinely hard when there is no table of balances. That last problem is what CQRS exists to solve. It ties directly to C5 — this is a storage architecture, not a messaging one.
Honest advice: apply it to one aggregate where audit or reversal is a real business requirement. Applying it system-wide because it is elegant is one of the more expensive mistakes in this stage.
CQRS — separate the write model from the read model
Command Query Responsibility Segregation stores the same data twice: a write database optimised for validity under concurrent updates, and one or more read databases shaped for how queries actually arrive. Clients get two APIs — one to modify, one to retrieve — and never touch either database directly. The write side publishes update events over an event backbone; the read side consumes them and rebuilds its projections.
The motivation is that there is no single correct way to organise data that is used many different ways. A buyer wants inventory by geography; restocking wants it by quantity; order history needs products that are no longer sold. Any one schema helps some of those and hurts the rest.
The price is eventual consistency, made visible to users. Write, then immediately read, and you may not see your own change — the projection has not caught up. That is a UX problem before it is a technical one, and it is the reason CQRS belongs on the handful of aggregates that need it rather than everywhere. Note that in the patterns catalog's source CQRS is in §7 with storage, not in the event-driven section — the classification is a hint about where the difficulty really lives.
Dead-letter queues, and the trap inside them
A message fails. The broker retries. It fails again. Without a DLQ you get one of two disasters: on an ordered queue, the poison message blocks everything behind it forever; on an unordered one, it is retried until the retention window silently drops it. A DLQ moves the message aside after N attempts so the flow continues and the failure is preserved.
A DLQ nobody alarms on is a data-loss mechanism with better branding. Alarm
on DLQ depth > 0, not on some threshold — a single message in a dead-letter
queue means a class of input your code cannot handle, and you want to know today. Also decide
up front what "redrive" means: replaying a DLQ into a system whose state has moved on can be
worse than the original failure, which is another reason every consumer must be idempotent.
Decision tree — synchronous call or asynchronous event for this interaction?
Follow no ↓ down the left. The first yes exits right. Bottom-left is the default.
Reality check
25%Worked example 1 — availability, four services, both ways
| Each of 4 services, independently | 99.9% |
| Synchronous chain: 0.999 × 0.999 × 0.999 × 0.999 | 99.6006% |
| Unavailability 0.3994% × 43,200 min/month | 172 min · 2h52m |
| Add one more hop (5 services): 0.9995 | 215 min · 3h36m |
| Every extra synchronous hop costs, roughly | +43 min/month |
| Async: producer availability | 99.9% |
| × broker availability (typical managed SLA 99.95%) | 99.95% |
| User-visible path = producer × broker only | 99.8500% |
| Unavailability 0.15% × 43,200 | 65 min · 1h05m |
| Consumers down → not an outage, a backlog | 0 min |
Worked example 2 — count the failure modes a 4-step saga must handle
Order → reserve inventory → charge card → allocate shipping → notify. In a monolith this is
one BEGIN … COMMIT. Distributed, here is the actual surface area:
| Forward-path failures — one per step, each with a different abort point | 4 |
| Ambiguous timeouts — did it succeed? Must query or retry idempotently | 4 |
| Compensating actions that must exist (steps 1–3) | 3 |
| Compensation failures — the undo itself fails; needs retry + alert + manual path | 3 |
| Duplicate deliveries — every step must be idempotent | 4 |
| Distinct conditions in the distributed version | 18 |
| Distinct conditions with one ACID transaction | 1 |
Grounding — where this shows up in practice
Red Hat Developer, July 2026 · batch inference on OpenShift AI with llm-d
A production LLM platform splitting interactive and batch along exactly this line
The architecture routes interactive traffic and batch traffic through the same gateway to
the same model pods, but over two different paths: interactive requests go straight
to an endpoint picker that does prefix-aware routing to maximise KV-cache hits, while
/v1/batches and /v1/files route to a separate batch gateway whose
processor asynchronously dispatches individual inference requests to the
same backend. Each path gets its own rate-limit and auth policy and scales independently.
This is the S2 decision tree's second branch, implemented: the work exceeds what a caller will wait for, so it becomes a submitted job rather than a held connection — while the expensive GPU capacity underneath stays shared.
A cloud patterns catalog, the cloud application patterns catalog · AsyncAPI specification
Event contracts get specified, or they rot
The material's Event API pattern makes an uncomfortable point: a compiler enforces a service API, but nothing enforces an event API. The set of topics and the message format on each is a real contract with no compiler behind it. Its recommended answer is AsyncAPI — the asynchronous counterpart to OpenAPI — which describes channels, operations, and message schemas, generates documentation, and can validate messaging code in CI. If you take one operational practice from this session: put your event schemas under version control with CI validation, the same way you would never ship a REST API without a spec.
Prime Video VQA case, 2023 — revisited through the S2 lens
Orchestration overhead is a real, billable cost, not just an aesthetic one
The Prime Video rebuild from S1 is also an event-driven cautionary tale. A meaningful share of the cost being eliminated was state transitions in the orchestrator itself, plus the object-store round trips used to pass data between steps. Asynchronous coordination has a per-step price, and when the step count is high and the payload is large, that price can exceed the work being coordinated. Async is a tool for decoupling availability, not a free abstraction — and it is worst-suited exactly where Prime Video applied it: a high-frequency, large-payload, tightly-cohesive inner loop.
Apply it — your context
10%Your fintech pipeline is already asking to be event-driven
Ingest → analyse → score, run synchronously, has all four properties from the decision tree pointing the same way. Reframed as events:
DocumentIngested— the ingest context's aggregate reached a terminal state. Payload is a document ID and a storage reference, not the document. This is the claim-check pattern, and it is mandatory at these payload sizes for reasons S3 quantifies.- Analysis subscribes, does the expensive extraction, emits
DocumentAnalysed. - Scoring subscribes to that. So — later and for free — does an audit-log consumer, a metrics consumer, and whatever compliance asks for next quarter, none of which requires touching ingest.
- The user-facing API returns
202plus a document ID within milliseconds, and a status endpoint reports the state. Your slowest stage no longer determines your API's timeout.
The honest counter-argument, which you should weigh: if this pipeline runs at low volume, has exactly one consumer per stage, and nobody is waiting on it, the synchronous version is simpler and the decision tree's default box applies. The trigger to convert is a real one — a stage that takes longer than a request should, or a second consumer appearing.
Batch and long-running inference: the poster child
Queue depth is the correct autoscaling signal, and it is available only if you have a queue. GPU utilisation is a terrible scaling metric — a replica can look busy while its KV cache is nearly empty, or idle between token bursts. Backlog depth directly represents unmet demand. A synchronous architecture has no backlog to measure, so it scales on the wrong thing and either over-provisions expensive GPUs or drops requests.
Cold starts make spikes unabsorbable synchronously. Pulling a multi-gigabyte image and loading weights onto a T4 or L4 is minutes, not seconds. Any spike faster than your scale-up time must be buffered somewhere. Without a queue, "somewhere" is the client's timeout.
Long generations break HTTP assumptions. A long-context batch generation can exceed load-balancer idle timeouts and most client defaults. Either you stream (which keeps the connection but couples availability for the whole duration) or you submit a job. For batch, submit the job — which is precisely the split the llm-d architecture above implements.
Deliberately local. Every concept in S2 — queue, retry, DLQ, idempotency, backlog-based scaling — is reproducible on your laptop, and learning them where a mistake costs nothing is strictly better. S3 does the managed-broker version, with real costs and real teardown.
- Stand up a broker.
docker run -d --name mq -p 5672:5672 -p 15672:15672 rabbitmq:3-management. Open the console atlocalhost:15672(guest/guest). One container, stopped when you are done. - Split your endpoint in two.
POST /generatewrites a job row with statusqueued, publishes{job_id}, returns202with aLocationheader. AddGET /jobs/{id}returning status and, when finished, the result. - Write the worker. Consume, mark
running, call vLLM, write the result, ack. Setprefetch=1so a slow GPU job does not have five more messages reserved behind it. - Make it idempotent. Use the job ID as the dedup key and take the
five-line pattern from Pass 2. Then prove it: kill the worker with
docker killafter inference completes but before the ack, restart it, and confirm the job is not generated twice. - Add a DLQ. Declare the queue with a dead-letter exchange and
x-delivery-limit: 3. Publish a job with a prompt that reliably fails. Watch it land in the DLQ after three attempts instead of blocking the queue. - Watch the shock absorber work. Fire 200 requests with
heyorab. Synchronously this would have produced timeouts and 503s; now watch the queue depth spike and drain while every request returned202in milliseconds. Screenshot the depth graph — that curve is the entire value proposition of this session. - Scale on the right signal. Note the peak depth. That number, not CPU
or GPU utilisation, is what you would feed to a KEDA
ScaledObjecton GKE to drive replica count.
docker stop mq && docker rm mqdocker volume prune— RabbitMQ leaves a persistence volume behind.- Confirm nothing is bound:
docker ps -a | grep rabbitreturns empty.
No cloud spend here — but build the habit now. From S3 onward you will create managed queues, topics, gateways, and CDNs that bill per request and charge while completely idle. Those teardowns are not optional.
You now know what a queue, a topic, and an event bus are, and when each is right. S3 is the plumbing: which product on AWS, Azure, and GCP actually is each one.
Why this session exists
10%S2 gave you three primitives — queue, topic, event bus — and the rules for choosing between them. Those rules are portable. What is not portable is the fact that each cloud gives you three to five products that all look like "messaging", with overlapping capabilities, different delivery guarantees, and size limits that differ by two orders of magnitude.
The failure mode here is not picking the "wrong" product. It is picking one without noticing which guarantee you just signed up for. A team that assumes ordering because the product is called a queue, or assumes exactly-once because the console has a checkbox labelled that, has built a correctness bug that will surface only under load, only in production, and only intermittently.
Your AWS source has no application-architecture section, so every AWS service here is sourced from current AWS documentation and announcements, not from AWS guidance. Azure §12 covers Event Grid and API Management well but gives Service Bus a single-sentence definition, so Service Bus is filled in from Microsoft Learn. GCP §8 covers Pub/Sub properly.
The finding that matters most: SQS's maximum message payload is no longer 256 KB. It went to 1 MiB in August 2025, and in January 2026 AWS extended the same 1 MB limit across asynchronous Lambda invocations and EventBridge event buses. Effectively every source, exam guide, and tutorial written before that still says 256 KB. The billing implication is covered in the reality check — the limit moved, the metering unit did not.
Core concepts
50%Three shapes, and a fourth that pretends to be one of them
Strip the branding away and there are only a few things a messaging product can be. Learning to classify a service in ten seconds is worth more than memorising any feature matrix.
A pile of work, drained by whoever is free
One message, one handler, deleted on acknowledgement. Adding consumers makes it go faster. The classic backing store for a job system.
SQS Service Bus queue Cloud Tasks (and Pub/Sub with a single subscription).
A broadcast, copied per subscriber
One message, N copies, one per subscription. Adding subscriptions makes it do more things. The backing store for fan-out.
SNS Service Bus topic Pub/Sub topic.
A topic with a router bolted on
Publishers emit to one bus; rules inspect the event body and decide where it goes. The routing logic lives in the infrastructure rather than in subscriber code, so a new consumer is a rule, not a deployment.
EventBridge Event Grid Eventarc.
Not a queue — a replayable tape
Messages are appended to an ordered, partitioned log and retained. Consumers track their own position and can rewind. Nothing is deleted on read. This is the one that gets mistaken for a queue, and the mistake is expensive.
Kinesis Data Streams Event Hubs Managed Service for Kafka on all three.
A queue's consumer says "I'm done, delete it." A log's consumer says "I've read up to offset 4,182." That single difference cascades: logs give you replay, multiple independent readers at different positions, and strict ordering within a partition — and in exchange they give you fixed partition counts, per-consumer offset management, and no per-message retry or dead-lettering. If you want to reprocess history, you want a log. If you want to process each item once and forget it, you want a queue. Choosing a log because it is "more scalable" and then trying to implement per-message retry on top is a well-trodden road to misery.
How a message actually moves, and where it can be lost
Every managed broker runs the same six-beat cycle. Knowing it tells you exactly which config value causes which production incident.
One message, three clouds — the same flow in each vocabulary
Before the comparison table, walk a single concrete case through all three. Scenario: an inference job is submitted, one worker must process it exactly once in effect, and a poison message must not block the queue.
One job, one worker, no poison messages
Your gateway accepts POST /v1/batches, writes a job record, and needs to hand
the work to a pool of GPU workers. Requirements: durable (nothing lost if a worker dies),
one worker per job, retry on transient failure, and after three failures the message goes
somewhere a human will see it rather than blocking the queue or vanishing.
That is the default box from the decision tree below — a plain queue with a DLQ. It is also 80% of real messaging work. Watch how little the three clouds actually differ.
AWS — two resources, because SQS is queue-only
Create an SQS standard queue plus a second SQS queue to act as the DLQ, then attach a
redrive policy to the first pointing at the second with
maxReceiveCount: 3. The gateway calls SendMessage; workers call
ReceiveMessage with long polling, then DeleteMessage on success.
C4 of the lifecycle is the visibility timeout, defaulting to 30 s and settable up to 12 hours — the most generous ceiling of the three, which matters for long generations. If a second consumer type appears later, you put SNS in front and give each consumer its own queue. Quirk to remember: a FIFO queue requires a FIFO DLQ.
Azure — one resource, because dead-lettering is built in
Create a Service Bus queue. That's it — the dead-letter queue already exists
as a sub-entity of the queue; you set MaxDeliveryCount to 3 and messages land in
<queue>/$DeadLetterQueue automatically. The most ergonomic of the three, and
the reason Service Bus is worth knowing even though the material barely covers it.
Workers use PeekLock receive mode; C4 is the lock
duration, default 30 s and capped at 5 minutes — the tightest ceiling here, so long
jobs need RenewLock heartbeats. In exchange you get transactions, scheduled
delivery, sessions, and built-in duplicate detection that no other cloud offers natively.
GCP — one topic, one subscription, and a permissions gotcha
Create a Pub/Sub topic, a subscription on it, and a second topic to serve as the
dead-letter topic with maxDeliveryAttempts: 3. The subscription
is what behaves as your queue. Workers pull and ack.
C4 is the ack deadline, defaulting to only 10 seconds
— by far the most dangerous default of the three for GPU work, though the client libraries
extend the lease automatically up to a configured maximum. The trip-up unique to GCP: you must
explicitly grant the Pub/Sub service agent publisher on the dead-letter topic and
subscriber on the subscription, or dead-lettering silently never
happens and you find out during an incident.
What actually differed — and what didn't
Didn't differ: the shape. Publish, durably store, deliver, hold invisible while working, ack or redeliver, dead-letter after N. Your consumer's structure is identical on all three, and every lesson from S2 — idempotency, DLQ alarms, backlog-based scaling — transfers unchanged.
Did differ, in order of how much it will bite you: the invisibility default (10 s on GCP vs 30 s on the other two, ceilings of 12 h / 5 min / 10 min); whether the DLQ is free or a second resource (Azure free, AWS and GCP explicit); whether the DLQ needs an IAM grant to function (only GCP); and the payload ceiling (1 MiB / 256 KB Standard / 10 MB).
The portability lesson: write your consumer against a thin interface with
receive, ack, nack, and extend_lease, and
the cloud-specific part is roughly thirty lines. The thing that is genuinely not portable is
the timing configuration — and that is the thing most likely to be wrong.
The three-cloud comparison — queues, topics, and event buses
Every service named with a one-line statement of what it is and which layer it sits in. Verified against current vendor documentation in July 2026.
| Primitive | AWS | Azure | Google Cloud |
|---|---|---|---|
| Queue point-to-point work distribution |
Amazon SQSA fully managed message queue at the integration layer. Standard queues: at-least-once, best-effort order, effectively unlimited throughput. FIFO queues: strict order per message group, dedup window. | Azure Service Bus (queues)A fully managed enterprise message broker at the integration layer. MQ-style: transactions, sessions, scheduled delivery, built-in dead-lettering, duplicate detection. | Pub/Sub with one subscription — or Cloud TasksPub/Sub is a global messaging layer; a single subscription behaves as a queue. Cloud Tasks is a genuine task queue for HTTP targets with per-task scheduling and rate control. |
| Topic fan-out, copy per subscriber |
Amazon SNSManaged pub/sub at the integration layer. Fans out to SQS, Lambda, HTTPS, email, SMS. The canonical AWS pattern is SNS→SQS: broadcast at the top, durable buffer per consumer underneath. | Service Bus topics + subscriptionsSame broker as the queue, with rule-based subscriptions. Each subscription is itself a queue, so you get fan-out and durable per-consumer buffering in one product. | Pub/Sub topic + N subscriptionsOne product does both shapes. Each subscription gets an independent copy and its own backlog. Filters on attributes let a subscription take a subset. |
| Event bus content-based routing in the infrastructure |
Amazon EventBridgeServerless event bus at the integration layer. JSON pattern-matching rules route to 20+ target types; schema registry; SaaS partner sources. Pipes adds point-to-point with filter/enrich; Scheduler adds cron. | Azure Event GridEvent routing service at the integration layer. Publishers → topics → subscriptions with advanced filtering. Deep native-source coverage (Blob Storage, IoT Hub, Resource Manager). Standard tier adds namespaces, MQTT, pull delivery. | EventarcRouting layer over Pub/Sub that normalises Google Cloud audit-log and service events into CloudEvents and delivers them to Cloud Run, GKE, or Workflows. Thinner than EventBridge — the transport underneath is still Pub/Sub. |
| Log / stream replayable, partitioned, offset-based |
Kinesis Data Streams Amazon MSKShard-based ordered log; MSK is managed Apache Kafka. Consumers track position; replay within retention. | Azure Event HubsPartitioned event-streaming platform with a Kafka-compatible endpoint. The event-style counterpart to Service Bus's MQ style. | Managed Service for Apache Kafka · Pub/Sub message retentionPub/Sub can replay within its retention window via seek, which covers many cases without a Kafka cluster. |
| Max message size | 1 MiBRaised from 256 KiB in Aug 2025; EventBridge matched it Jan 2026. Beyond that, the Extended Client Library offloads to S3 (claim-check) up to 2 GB. | 256 KB Standard · 100 MB PremiumThe 100 MB path is AMQP-only; over SBMP or HTTP even Premium caps at 1 MB. Large payloads reduce throughput — claim-check is still the recommendation. | 10 MBIncludes data plus attributes. A single flat limit across all tiers, which makes capacity planning simpler than either competitor. |
| Ordering | FIFO queues / FIFO topicsStrict order within a message group ID. Standard queues give best-effort only. FIFO defaults to 300 msg/s (3,000 batched); high throughput mode reaches up to 70,000 msg/s in select regions. | SessionsA session ID gives strict FIFO within that session, and locks the session to one consumer at a time. Event Hubs orders within a partition. | Ordering keysStrict order per key, enabled per subscription. Publishing throughput is capped at 1 MB/s per ordering key — the ceiling to design around. |
| Delivery guarantee | At-least-once on Standard. FIFO offers exactly-once processing via a
5-minute dedup window on MessageDeduplicationId. |
At-least-once by default; PeekLock plus duplicate detection and transactions get closest to genuine exactly-once of the three, at the cost of availability. | At-least-once by default; an exactly-once delivery subscription option exists within a cloud region, with throughput and latency trade-offs. |
| Retention | SQS 4 days default, 14 days max. EventBridge buses do not retain — an unmatched event is gone unless you add an archive. | Service Bus: until consumed or TTL expires (queue size limits apply). Event Grid Basic: 1 day. Standard: up to 7 days. | Subscription retention 7 days default, up to 31 days; topic-level retention also configurable. The most generous default of the three. |
| Dead-lettering | Redrive policy on the queue → separate DLQ after maxReceiveCount. FIFO
queues need a FIFO DLQ. |
Built into every queue and subscription — the DLQ exists automatically, no separate resource to create. The most ergonomic of the three. | Dead-letter topic per subscription, after maxDeliveryAttempts.
Requires granting the Pub/Sub service account publish rights — a common first-time trip-up. |
| Key quirk to know | Fair queues (2025) let a standard queue carry a message group ID purely to stop one noisy tenant starving others — multi-tenant fairness without FIFO's throughput penalty. Very relevant to a shared inference queue. | SBMP protocol support retires 30 Sept 2026. Any older SDK still on SBMP must move to an AMQP-based SDK. Check this before building anything long-lived. | Pub/Sub Lite shut down 18 March 2026. Any material recommending Lite as the cheap high-throughput option is out of date; standard Pub/Sub or Managed Kafka are the successors. |
| Reach for it when… | You are on AWS and want the simplest durable buffer that exists (SQS), or you want routing rules and SaaS event sources without writing a router (EventBridge). | You need enterprise broker semantics — transactions, sessions, scheduled delivery, duplicate detection — or you are integrating with on-premises systems. | You want one global product that covers queue and topic shapes with generous limits and no regional partitioning to think about. The simplest mental model of the three. |
Durable work queue: SQS ≈ Service Bus queue ≈ Pub/Sub subscription (or Cloud Tasks). Fan-out: SNS ≈ Service Bus topic ≈ Pub/Sub topic. Routed event bus: EventBridge ≈ Event Grid ≈ Eventarc. Replayable log: Kinesis/MSK ≈ Event Hubs ≈ Managed Kafka.
The one structural difference worth internalising: AWS splits queue and topic into two products, Azure and GCP each put both shapes in one. That is why the AWS canonical pattern is the two-service SNS→SQS sandwich, while on Azure and GCP the equivalent is a single resource with multiple subscriptions.
The four settings that cause almost every incident
1 · Invisibility window shorter than processing time
SQS visibility timeout (default 30 s, max 12 h), Service Bus lock duration (default 30 s, max 5 min), Pub/Sub ack deadline (default 10 s, max 600 s). If your handler takes longer, the broker concludes you died and hands the message to someone else — while you are still working on it. Two consumers now process the same job concurrently.
This is acute for GPU inference. A batch generation taking 40 seconds against Pub/Sub's 10-second default will be redelivered four times before you finish. The fix is either a longer deadline or, better, lease extension: the client library heartbeats to extend while work is in flight. Google's client does this automatically up to a configured maximum; make sure that maximum exceeds your p99 job duration, not your median.
2 · Ordering enabled without understanding the throughput cost
Ordering always means serialising something. SQS FIFO drops you from unlimited to 300 msg/s unless you enable high-throughput mode. Pub/Sub caps each ordering key at 1 MB/s. Service Bus sessions bind a session to exactly one consumer at a time.
The design move is to make the ordering key as fine-grained as correctness allows.
Order per orderId or per conversationId: thousands of keys running in
parallel, order preserved where it matters. Order per tenantId and your largest
customer becomes a hot key that no scaling can help. Ask "what actually needs to be
ordered relative to what?" — the answer is almost never "everything".
3 · In-flight limits and prefetch starving your workers
SQS caps in-flight (received but not deleted) messages at 120,000 per queue — raised for FIFO
from 20K in late 2024. Past that you get OverLimit errors. The subtlety is that a
large backlog does not count; only messages currently checked out do. A 200,000-message
backlog is fine, but 200,000 simultaneously-in-flight is not.
The mirror-image problem is prefetch. Client libraries eagerly reserve messages to reduce round-trips. On a GPU worker where each job takes seconds, a prefetch of 20 means 19 messages sit locked behind one slow job, invisible to idle workers, ticking toward redelivery. Set prefetch to 1 for long-running work. The default is tuned for millisecond handlers.
4 · Payload size limits met with the wrong workaround
1 MB on SQS/EventBridge, 256 KB on Service Bus Standard, 10 MB on Pub/Sub. The right answer above those is the claim-check pattern: write the payload to object storage, put the reference in the message. AWS's Extended Client Library does this for you up to 2 GB; on Azure and GCP you write the ten lines yourself.
The wrong answers, in order of how often they are attempted: chunking one logical message across several physical ones (now you need reassembly, ordering, and partial-failure handling); compressing to squeeze under the limit (works until one payload doesn't compress); and raising the tier purely for message size (Service Bus Premium for 100 MB messages is legitimate for legacy migrations, but Microsoft's own guidance is to keep payloads small anyway because large messages cut throughput and raise latency).
For your workload specifically: never put a document, an image, or a tensor in a message. Put the GCS/S3/Blob URI. This also makes retries cheap — the payload does not travel again.
None of these brokers should be reached with a static connection string in an environment
variable. Use the workload identities from C2: an IAM role for service account on GKE or a
task role on ECS for SQS/SNS, a managed identity with the Service Bus Data Sender or
Data Receiver role on Azure, and a Google service account with
roles/pubsub.publisher or roles/pubsub.subscriber. Grant publish and
subscribe as separate roles to separate identities — a producer that can also drain
the queue is an accident waiting to happen.
Decision tree — queue, topic, event bus, or log for this message?
Follow no ↓ down the left. The first yes exits right. Bottom-left is the default.
Reality check
25%Worked example — size a queue for an inference traffic spike
A model endpoint runs at a steady 50 req/s. A product launch drives it to 800 req/s for five minutes. You have 20 GPU replicas, each sustaining 12 req/s with continuous batching. Nothing can be dropped. Size the queue.
| Spike arrival rate | 800 req/s |
| Spike duration | 300 s |
| Total requests during spike · 800 × 300 | 240,000 |
| Drain capacity · 20 replicas × 12 req/s | 240 req/s |
| Backlog growth rate · 800 − 240 | 560 req/s |
| Peak backlog · 560 × 300 | 168,000 messages |
| After the spike, arrivals return to | 50 req/s |
| Spare drain capacity · 240 − 50 | 190 req/s |
| Drain time · 168,000 ÷ 190 | 884 s · 14m 44s |
| Retention required (with a large safety margin) | ~1 h |
| vs SQS max 14 days · Pub/Sub default 7 days | not a constraint |
| Backlog storage · 168,000 × 4 KB messages | 672 MB |
| In-flight at any instant · 20 replicas × 4 concurrent | 80 |
| vs SQS in-flight limit of 120,000 | not a constraint |
| API operations · 240,000 msgs × (send + receive + delete) | 720,000 |
| SQS at $0.40 per million requests | $0.29 |
| Now carry a 200 KB prompt inline instead of a reference… | |
| SQS meters per 64 KB chunk · ceil(200 ÷ 64) | 4 units per call |
| Billable requests · 720,000 × 4 | 2,880,000 |
| Same traffic, inline payloads | $1.15 · 4× more |
| For scale: 20 L4 replicas for that same 20 minutes | ≈ $4.70 |
Grounding — three current sources that change the answer
AWS Compute Blog "More room to build: serverless services now support payloads up to 1 MB" · 29 Jan 2026
The 256 KB number in your study materials is dead
AWS raised the maximum payload from 256 KB to 1 MB for asynchronous Lambda invocations,
SQS, and EventBridge event buses; SQS itself moved in August 2025 and the January
announcement unified it across the three. It applies automatically to existing queues and
buses — but note that if you explicitly set the MaximumMessageSize attribute,
you must raise it yourself. AWS's own stated motivation is telling for your work: LLM prompts,
ML outputs, and nested context objects outgrowing the old ceiling.
The caveat AWS states plainly in the same post is the one above — billing still meters in 64 KB units, so bigger messages cost proportionally more per call.
Microsoft Learn · Service Bus premium messaging and protocol retirement notices
Two Azure dates to put in your calendar before designing anything long-lived
SBMP protocol support for Azure Service Bus retires 30 September 2026. Any application still on an older SDK using SBMP must move to an AMQP-based SDK before then. Separately, the 100 MB large-message capability is Premium-tier and AMQP-only — over SBMP or HTTP even Premium caps at 1 MB, and Microsoft's guidance is to keep payloads small regardless because large messages reduce throughput and increase latency. Your Azure source's §12 does not cover Service Bus in any depth, so neither point appears there.
Google Cloud Pub/Sub documentation and deprecation notices
Pub/Sub Lite is gone; ordering keys have a hard per-key ceiling
Pub/Sub Lite reached end of service on 18 March 2026, having been closed to new customers since September 2024. Any guidance recommending Lite as the cheap high-throughput option — and there is a lot of it still online — is obsolete; the successors are standard Pub/Sub or Managed Service for Apache Kafka. Also worth designing around: publishing throughput on each ordering key is limited to 1 MB/s, though aggregate throughput across keys can reach multiple GB/s. Ordering keys are explicitly not partitions and are expected to have far higher cardinality — which is the documentation quietly telling you to key finely.
Apply it — your context
10%Async patterns for long-running and batch inference in your stack
Three shapes, in increasing order of how much they change your architecture. All three assume the claim-check rule: references in messages, payloads in object storage.
Smallest change to what you have
Your FastAPI gateway returns 202 with a job ID and publishes to one queue.
Workers on GKE or Vertex consume, run vLLM, write results to GCS, mark the job complete.
Client polls GET /jobs/{id}.
Pub/Sub subscription plus a Firestore or Cloud SQL job table. One new resource. This is the version to build first.
What the llm-d architecture does
Interactive traffic keeps its synchronous path with prefix-aware routing to maximise KV-cache hits. Batch traffic goes through a queue to the same model replicas, with its own rate limits and its own scaling policy.
Expensive GPU capacity stays pooled; only the admission path forks. This is where your router earns its keep — it already makes routing decisions, so lane selection is a natural extension.
For the fintech document flow
DocumentIngested → topic → analysis subscription →
DocumentAnalysed → topic → scoring subscription. Audit and metrics consumers
attach later for free.
Two topics, four subscriptions, two dead-letter topics. Only worth it once a second consumer genuinely exists — otherwise the S2 default box applies.
Which product, given your preferences
You want open-source-first, equal AWS and GCP fluency, working Azure literacy. On GCP, use Pub/Sub — one product covers queue and topic shapes, the 10 MB ceiling means you rarely fight the limit for prompts, and it is global so there is no regional topology to design. On AWS, use SQS alone for a job queue, and add SNS in front only when a second consumer appears; skip EventBridge until routing rules actually change. On Azure, Service Bus — despite the material barely covering it — because sessions and scheduled delivery are genuinely useful for tenant-ordered inference and delayed retries. If you want the portable answer: RabbitMQ or NATS on Kubernetes speaks AMQP everywhere and keeps your consumer code identical across clouds, at the cost of operating it yourself. That is a real trade, not an obvious win.
Autoscale on queue depth, not GPU utilisation. With KEDA on GKE, a
ScaledObject with the gcp-pubsub scaler reading
num_undelivered_messages targets a backlog per replica rather than a utilisation
percentage. GPU utilisation lies in both directions — a replica can report high utilisation
while its KV cache has room for more concurrent sequences, and can report low utilisation
between token bursts. Backlog depth is unmet demand, measured directly. Set
minReplicaCount above zero unless you are willing to pay a multi-minute cold start
for weight loading on the first request after idle.
GCP because your Vertex and GKE work already lives there. Do not attach a GPU for
this lab — a sleep 8 stands in for inference perfectly and teaches every
messaging lesson at zero GPU cost. Free tier covers the first 10 GiB of Pub/Sub throughput per
month, so this lab is realistically free; the discipline below is for the habit.
Set a budget alert first
# Billing → Budgets & alerts → Create budget. Do this BEFORE creating resources. gcloud billing budgets create \ --billing-account=$BILLING_ACCOUNT \ --display-name="s3-messaging-lab" \ --budget-amount=5USD \ --threshold-rule=percent=0.5 --threshold-rule=percent=0.9
- Create the topic, the DLQ topic, and the subscription.
gcloud pubsub topics create infer-jobs gcloud pubsub topics create infer-jobs-dlq gcloud pubsub subscriptions create infer-workers \ --topic=infer-jobs \ --ack-deadline=60 \ --dead-letter-topic=infer-jobs-dlq \ --max-delivery-attempts=3
The--ack-deadline=60is the lesson: the default of 10 s would redeliver an 8-second job if anything hiccuped. Set it from your p99, not your median. - Grant the dead-letter permissions. This is the step everyone misses,
and the symptom is that dead-lettering silently never happens:
PROJECT_NUMBER=$(gcloud projects describe $(gcloud config get-value project) \ --format="value(projectNumber)") SA="service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com" gcloud pubsub topics add-iam-policy-binding infer-jobs-dlq \ --member="serviceAccount:${SA}" --role="roles/pubsub.publisher" gcloud pubsub subscriptions add-iam-policy-binding infer-workers \ --member="serviceAccount:${SA}" --role="roles/pubsub.subscriber"
- Publish a job as a reference, not a payload. Practise the claim-check
shape even at toy scale:
gcloud pubsub topics publish infer-jobs \ --message='{"job_id":"j-001","prompt_uri":"gs://my-bucket/prompts/j-001.txt"}' \ --attribute=type=infer.requested.v1
- Pull it, and observe the ack deadline. Pull without acking, wait 70 seconds, pull again — the same message reappears. That single experiment is the entire duplicate-delivery lesson, felt rather than read.
- Force a dead-letter. Publish a malformed message, nack it three times,
then pull from a subscription on
infer-jobs-dlqand find it there. Confirm it is no longer blocking the main subscription. - Set the alarm you would actually run. In Cloud Monitoring, alert on
pubsub.googleapis.com/subscription/num_undelivered_messagesfor the DLQ subscription with a threshold of 0. Not 10, not 100. One dead-lettered message means a class of input your code cannot handle. - Optional — see the shock absorber. Publish 5,000 messages in a loop
while a single slow consumer runs. Watch
num_undelivered_messagesrise and drain in the metrics explorer. That curve is the number you would feed to KEDA.
Pub/Sub bills on throughput and on retained message storage, so a forgotten subscription holding an undrained backlog keeps costing after you stop looking at it. Subscriptions also survive topic deletion, so delete them first.
gcloud pubsub subscriptions delete infer-workers- Delete any DLQ subscription you created for step 5.
gcloud pubsub topics delete infer-jobs infer-jobs-dlq- Verify empty:
gcloud pubsub topics listandgcloud pubsub subscriptions list— both should return nothing for this lab. - Delete any GCS objects you uploaded as prompt references.
- Leave the budget alert in place. It costs nothing and it will catch the next lab.
If you ran the AWS variant
instead: aws sqs delete-queue for both the main queue and the DLQ, and delete
any SNS topic and its subscriptions. Queues linger silently — an empty SQS queue costs
nothing, but a queue still receiving traffic from a forgotten producer does not.
The back half of the system is now decoupled — services announce, buses carry, consumers drain at their own pace. S4 turns to the front: the single door every client comes through, and the cache that stops most of them from reaching you at all.
Why this session exists
10%S1 through S3 decomposed the inside of the system. Now look at it from outside. A client — a browser, a mobile app, a partner's integration, your own CLI — needs to use this thing. What it sees is a constellation of services with changing addresses, changing APIs, and no single place to knock.
When the server was a monolith this was trivial: one process, one endpoint, one address that never moved. A microservices application is dynamic by construction — replicas come and go on ephemeral addresses, services get split and merged, APIs evolve. The material puts the problem precisely: how does a client get easy, stable access to a set of services that is deliberately unstable?
And there is a second, harder asymmetry. Deploying a new service version is routine. Updating every mobile app already installed on a phone is not. If your service APIs are your public contract, then every internal refactor becomes a forced migration for people you cannot reach. The edge layer exists to absorb that.
The classic treatment of this session ("Cloud Application Clients") genuinely covers browser apps, single-page apps, micro frontends, mobile, CLI, and the Public API pattern — but the API-gateway/BFF pattern is in §4, where the material calls it Dispatcher and lists "Backend for Frontend" and "API Gateway" as its aliases. This tab uses both section. The material has essentially no CDN coverage, so that comes from vendor documentation.
On the Azure side, the material's CDN section (which is in §13, not §12) is now materially stale: Azure CDN from Edgio has been retired, and Azure CDN Standard from Microsoft (classic) is scheduled for retirement on 30 September 2027, with new profile creation already blocked since August 2025. The go-forward answer on Azure is Front Door. Details and dates in the comparison below.
Core concepts
50%The hotel, the concierge, and the neighbourhood branch
Three edge components, three jobs, and they are routinely confused because all three sit "in front of" your services.
The hotel front desk
One door for the whole building. It checks your identity, tells you which floor you want, logs that you came in, and stops you wandering into the kitchens. It doesn't cook your food — it routes you to whoever does.
Guests never learn the internal room-numbering scheme, so management can renumber freely.
The concierge who knows you
The desk serves everyone identically. A concierge assigned to one type of guest gives a different answer to the same question: the business traveller gets a taxi and a receipt, the family gets a stroller and a map.
Same hotel, same services, a tailored interface per audience. That is the whole idea.
The neighbourhood branch library
The central archive holds everything, but popular sources are copied to branches near where people live. Most requests are satisfied locally and never reach the archive.
Two consequences: it is fast because it is near, and the archive survives a rush because it never sees most of it.
- API gateway
- A managed service at the edge of your architecture that terminates client connections and routes them to backend services, handling cross-cutting concerns — authentication, rate limiting, request/response transformation, logging, API keys — so each service doesn't re-implement them. It sits outside your services and inside your perimeter.
- Backend for frontend (BFF) · the material's Dispatcher
- A thin server-side component built for one client type, exposing exactly the API that client needs and implementing it by calling several microservices. One BFF per client type — web, mobile, partner, CLI. Built and deployed by the same team that owns the client. Contains no domain logic.
- Public API
- §8's separate pattern: a deliberately coarser, more stable API for third parties outside your organisation. Distinct from a BFF because it must change far more slowly — you cannot coordinate a release with strangers.
- CDN · content delivery network
- A globally distributed cache of edge locations (points of presence, "PoPs") that serve copies of your content from near the user. Sits in front of everything, including the gateway.
- Origin
- The materialitative source the CDN pulls from on a cache miss — your bucket, load balancer, or gateway.
- Cache key
- What the CDN uses to decide whether two requests are "the same object". Usually the URL path, plus whichever query parameters, headers, and cookies you tell it to include. The single most consequential CDN setting there is.
- Cache invalidation
- Making the cached copy stop being served before its TTL expires. Two mechanisms: purge (tell the CDN to drop it) and versioned URLs (change the key so nobody asks for the old one).
- Rate limiting
- Capping how many requests a client may make in a window, to protect capacity and enforce fairness. Usually implemented as a token bucket: a refill rate plus a burst capacity.
- Edge compute
- Small programs running at the PoP itself — header rewrites, redirects, auth checks, A/B routing — executing before a request ever travels to a region.
The layers, in order, and what each one is allowed to do
The client side of §8 — what sits on the far side of the edge
§8's actual subject is the clients themselves, and the choice of client shape changes what the edge has to provide. Four patterns worth knowing, and the one architectural principle underneath all of them.
The principle — separate UI from domain, always
§8 opens with ports and adapters (hexagonal architecture): the domain sits in the middle, knowing nothing about how it is reached, and every delivery mechanism — web UI, mobile app, CLI, public API — is an adapter plugged into a port. The practical consequence is that you should be able to add a completely new client type without touching a single line of domain code. If adding a CLI requires changing your order service, the UI and the domain are entangled.
This is the same idea as the anti-corruption layer from S1 and the Dispatcher from §4, applied at a third boundary. The material is consistent about it: every place a foreign concern meets your domain gets a translation layer.
Single-page application — the client becomes a real program
An SPA loads once and then talks to the server over API calls, managing its own state and routing in the browser. From the edge's point of view this splits your traffic cleanly in two: the application bundle is perfectly cacheable static content (hash the filename, set a one-year TTL, never invalidate), while the API calls are almost entirely uncacheable.
That split is why the cache-key discipline in Pass 3 matters so much here: get the bundle caching right and your origin serves almost no static traffic at all. It is also why an SPA is the strongest case for a BFF — a multi-screen wizard may need several services orchestrated per step, and doing that from the browser means many round trips over the user's connection instead of one over your backbone.
Mobile application — the constraint that drives the BFF
Mobile is where interface segregation stops being theoretical. Bandwidth is limited, the screen shows a fraction of the data, and — the decisive point — you cannot force an upgrade. An old app version will be calling your API for years.
The material's guidance is blunt: don't send a large dataset to a device that can only display a little of it. A mobile BFF that trims and paginates on the server side is not an optimisation, it is the difference between a usable app on a poor connection and an unusable one. And because the mobile client and its BFF change together and are built by the same team, they can be written in the same language — which is exactly the argument the material makes for Node BFFs behind JavaScript clients.
Micro frontend — the same decomposition argument, one layer up
If the backend is split so teams can deploy independently, the frontend becomes the new bottleneck: one monolithic UI that every team must contribute to and release together. A micro frontend splits the UI into independently deployable pieces, each owned by the team that owns the corresponding backend capability, composed at runtime or build time into one page.
The trade-off is real and often underestimated: shared design systems, duplicated framework payloads, cross-fragment state, and consistent authentication all become distributed problems. The honest test is the same as S1's — do you have multiple teams genuinely blocked by each other's frontend releases? If not, a modular frontend in one deployable is the better answer, for exactly the reasons the modular monolith is.
Public API — the one you cannot take back
§8 treats a Public API as a distinct pattern rather than "a BFF for partners", and the distinction is about rate of change. A BFF evolves with its client because one team owns both. A Public API's consumers are strangers, so it must be coarser-grained and far more stable — administrative operations excluded, fine-grained data access often replaced by summary access for privacy reasons. The material's example is the US Census APIs: aggregate data exposed, individual records not.
Two costs it names explicitly. Versioning: teams end up supporting several concurrent versions because third parties cannot move as fast as internal teams — the material reports up to six at once. Security: a public internet endpoint invites injection, man-in-the-middle, and DDoS, so a zero-trust posture is a prerequisite, not a hardening task for later.
And the upside it is equally clear about: a Public API creates an ecosystem, and other people's creativity will find uses you did not plan for. That is a blessing and a capacity problem simultaneously.
A cache hit and a cache miss, step by step
The request lands at the nearest PoP, not at your origin
DNS resolves to the CDN's anycast address, so the user connects to a point of presence geographically near them. TLS terminates there. For a user in Sydney and an origin in Virginia, the handshake round-trips have just gone from ~200 ms each to ~5 ms each — before any content is transferred.
This latency win applies to uncacheable content too, which is why putting a CDN in front of a pure API is often worth it even at a 0% hit ratio: the connection setup is local and the origin leg runs over the provider's optimised backbone.
The PoP computes the cache key
By default: the host and path. You then choose what else to include — specific query parameters, specific headers, specific cookies. Everything you add multiplies the number of distinct objects.
The default that ruins hit ratios is including all query strings. A URL
with a marketing tag becomes a different object from the same URL without it. Include
?page=2 because it changes the response; exclude ?utm_source=…
because it does not. Pass 3 puts a number on this.
Miss — the PoP fetches from origin
No local copy, so the PoP requests from the origin. In a well-configured setup it goes via a mid-tier shield cache (CloudFront Origin Shield, or the equivalent) so that one request reaches origin even if 400 PoPs miss simultaneously.
Without collapsing, a cold popular object produces a thundering herd: every PoP misses at once and your origin takes hundreds of identical simultaneous requests. That is how a cache layer causes an outage.
Store — TTL is decided by your headers, not by the CDN
The origin's Cache-Control governs. max-age tells browsers;
s-maxage tells shared caches like the CDN, and overrides max-age
for them. That split is the useful lever: Cache-Control: public, max-age=60,
s-maxage=86400 means browsers recheck every minute while the CDN holds it for a day,
so you can purge centrally and have it take effect within a minute.
If you send no cache headers, you are trusting whatever default the CDN picks. Always be
explicit — including no-store on anything user-specific.
Hit — the origin is never contacted
Subsequent requests for the same key are served from the PoP. Latency drops to the local round trip, and origin load drops by the hit ratio. This is the part everyone knows.
The part worth internalising: hit ratio is a property of your cache key design and your TTLs, not of the CDN you bought. Two teams on the same product can differ by thirty points.
Stale — and why serving it is usually right
stale-while-revalidate lets the PoP serve the expired copy immediately while
fetching a fresh one in the background. stale-if-error lets it serve the expired
copy when the origin is returning 5xx.
That second directive is a genuine availability feature: your site keeps serving cached pages through a full origin outage. It is close to free and routinely unconfigured.
Invalidate — the hard one
Two approaches. Purge tells the CDN to drop a path; it is eventually
consistent across hundreds of PoPs (seconds to minutes), often rate-limited, and sometimes
billed. Versioned URLs sidestep it entirely: publish
/app.4f3c9a.js with a one-year TTL, and a new build produces a new hash and
therefore a new key. Nothing is ever invalidated because nothing is ever overwritten.
Prefer versioned URLs for anything you control the links to. Reserve purge for content whose URL must stay stable — a homepage, a published article, an OpenAPI document. And keep the entry point on a short TTL so it can point at new hashes quickly.
The three-cloud comparison — gateways and edge
Verified against current vendor documentation in July 2026. Retirement dates are called out because two of them fall inside a normal system's lifetime.
| Role | AWS | Azure | Google Cloud |
|---|---|---|---|
| API gateway managed front door for APIs |
Amazon API GatewayManaged gateway at the edge of your architecture, in three flavours: HTTP API (cheaper, faster, fewer features — the default for new work), REST API (full features: request validation, usage plans, WAF, private endpoints), and WebSocket API. | Azure API ManagementFull API-management platform, not just a gateway: developer portal, product/subscription model, policy engine, self-hosted gateways for hybrid. Heavier and more capable than the AWS or GCP equivalents. | API Gateway Cloud Endpoints ApigeeThree products, a deliberate ladder. Endpoints is basic proxy plus API keys and JWT. API Gateway is the managed middle for serverless backends. Apigee is the enterprise platform. |
| Which gateway tier to pick | HTTP API unless you need request validation, usage plans, or API keys — then REST API. HTTP API is roughly a third of the price and materially lower latency. | v2 tiers are the current answer. Basic v2 and Standard v2 went GA in April 2024; Premium v2 GA'd in November 2025. They deploy in minutes rather than hours, scale to 10 units (30 for Premium v2), and bring VNet integration down to Standard pricing. Consumption tier remains for spiky serverless workloads. | Apigee only when APIs are a product — monetisation, a developer portal, partner governance, multi-cloud. Otherwise API Gateway. Endpoints only if you are already running ESPv2 alongside your service. |
| CDN global edge cache |
Amazon CloudFrontGlobal CDN and programmable edge platform. Hundreds of PoPs plus regional edge caches. Origin Shield adds a request-collapsing mid-tier. VPC origins let it front private ALB/NLB backends; cross-account VPC origins arrived Nov 2025. | Azure Front Door Standard / PremiumCombines CDN, global HTTP load balancing, dynamic site acceleration, and WAF in one service. This is now the only go-forward answer — see the retirement row. | Cloud CDN Media CDNCloud CDN is a feature you enable on a global external Application Load Balancer, not a standalone product — a genuinely different mental model. Media CDN is a separate, higher-capacity product for streaming and large media. |
| Edge compute | CloudFront Functions (sub-millisecond JavaScript for header rewrites, redirects, URL manipulation) and Lambda@Edge (heavier Node/Python at regional edge caches). KeyValueStore gives Functions low-latency state for feature flags and routing tables without a network hop. | Front Door Rules Engine for declarative match/action logic at the edge. Heavier logic goes to Azure Functions rather than running in the PoP — less programmable than CloudFront, simpler to reason about. | Rules on the load balancer, plus Service Extensions for running custom code in the request path. Historically the least programmable edge of the three, though the gap has narrowed. |
| Rate limiting | API Gateway usage plans (per-API-key rate + burst + quota) on REST APIs; throttling on HTTP APIs; AWS WAF rate-based rules at the CloudFront layer for IP-level protection. | Policy engine: rate-limit-by-key and quota-by-key with
arbitrary key expressions — per subscription, per header, per JWT claim. The most
expressive of the three. |
Apigee Spike Arrest and Quota policies; API Gateway and Endpoints offer quotas but not Apigee-grade traffic-management policies. Cloud Armor handles IP-level rate limiting at the edge. |
| AI / model-endpoint features | CloudFront added an AI activity dashboard for visibility into AI bot and agent traffic (2026). Model-level token governance is not a gateway feature — you build it, or use Bedrock's own controls. | Clearly ahead here. APIM has an AI gateway feature set: token-based rate limiting, token metering, semantic caching, and Model Context Protocol server support. If you are exposing LLM endpoints through a managed gateway, this is the most complete option today. | Apigee has added LLM-oriented policies and there is model-armor tooling in the Vertex ecosystem, but token-aware limiting is less integrated into the gateway product than Azure's. |
| Pricing shape | Per request plus data transfer out; data from S3/ALB/API Gateway to CloudFront is free. Flat-rate CloudFront plans launched Nov 2025 and gained Lambda@Edge, CAPTCHA and mTLS support in March 2026 — worth checking against pay-as-you-go if traffic is predictable. | APIM is priced per unit-hour — you pay for the instance whether or not it serves traffic, except on Consumption. Front Door is per request plus data plus routing rules. The most idle-charge exposure of the three. | Cloud CDN is per cache egress and cache fill; the load balancer it attaches to has its own hourly forwarding-rule charge. API Gateway is per call. Apigee has both pay-as-you-go and subscription tiers. |
| Retirements to know | Origin Access Identity is superseded by Origin Access Control for S3 origins — use OAC on anything new. | Two live deadlines. Azure CDN from Edgio: retired. Azure CDN Standard from Microsoft (classic): retires 30 Sept 2027, no new profiles since 15 Aug 2025. Azure Front Door (classic): retires 31 March 2027. Managed certificates stopped on classic tiers in Aug 2025. Migrate to Front Door Standard/Premium. | No comparable edge retirement in flight. Note that Cloud CDN cannot exist without its load balancer — a structural dependency rather than a deprecation, but it surprises people migrating from CloudFront. |
| Reach for it when… | You want the most programmable edge and the tightest integration with AWS origins. CloudFront in front of API Gateway is the standard pattern for a global API. | You need full API-product management — portal, subscriptions, monetisation — or you are exposing LLM endpoints and want token governance without building it. | You are already terminating on a global load balancer, in which case Cloud CDN is a checkbox. Escalate to Apigee only when APIs become a product. |
Cache invalidation, and why it is the hard one
The joke about it being one of the two hard problems in computer science is not about the mechanism — purging is an API call. It is that you must decide, in advance, how wrong you are willing for the world to be, and for how long. A TTL is a promise about staleness. Ninety seconds of stale pricing might be fine; ninety seconds of stale account balance is not.
The design move that dissolves most of the problem is to stop invalidating: make the URL
contain a content hash so that a change produces a new key and the old object simply stops being
requested. Anything you cannot version — an entry-point HTML file, a stable API path — gets a
short TTL and a purge on deploy. And two cheap directives buy a lot:
stale-while-revalidate hides refresh latency,
stale-if-error keeps you serving through an origin outage.
Including all query parameters in the cache key. Add a marketing campaign with 20
utm_source values and one cached object becomes 20 distinct objects, each getting
a twentieth of the traffic, each far more likely to be cold or evicted. Hit ratio does not
degrade gracefully here — it falls off a cliff, and origin load rises to meet it. Worse, the
same mechanism is a denial-of-service vector: an attacker appending random query strings
generates a unique key per request, guaranteeing a miss every time and passing your full attack
traffic straight through to origin. Allow-list the parameters that change the response.
Never deny-list.
Rate limiting: which algorithm, and where
Simple, and wrong at the edges
"100 requests per minute", counter resets on the minute. Easy to implement and easy to abuse: 100 requests at 11:59:59 and 100 more at 12:00:00 is 200 in one second, all within policy. Fine for coarse quotas, bad for capacity protection.
Handles bursts on purpose
A bucket of capacity B refills at R tokens per second; each request spends one. Sustained rate is R, but a client idle for a while can burst up to B. This matches how real clients behave, and it is what most gateways implement. Two knobs, both meaningful.
The right one for GPU work
Cap simultaneous in-flight requests rather than arrival rate. For inference this is closer to the real constraint: what exhausts a GPU is concurrent sequences occupying KV cache, not requests per second. A tenant sending one 100k-token request can consume more than one sending fifty short ones.
At the CDN/WAF layer, limit by IP to absorb volumetric attacks before they cost you gateway invocations. At the gateway, limit by API key, subscription, or JWT claim — the identity that maps to a customer. Only the gateway knows who the caller is; only the edge is cheap enough to absorb an attack. Doing just the second means you pay per request to reject requests.
And whichever you choose: return 429 with a Retry-After
header, and document the limit. A rate limit that clients cannot discover produces
retry storms, which is the failure you were trying to prevent.
API versioning through the gateway — the Public API problem
§8 makes a distinction worth holding onto. A BFF's API can evolve fast, because the same team owns the client and can ship both together. A Public API must evolve slowly, because its consumers are strangers who will not move on your schedule. The material reports teams supporting up to six concurrent versions because third parties could not keep up.
The gateway is where you make that survivable. Route /v1/* and /v2/*
to different backend versions and the split is infrastructure config, not application code —
so old and new can run side by side, traffic can shift gradually, and a bad v2 can be rolled back
by changing a route rather than redeploying. It also gives you the data to end the migration:
per-version request metrics tell you which consumers are still on v1 and whether the sunset date
is realistic. Announce the sunset when you launch the new version, not when you are tired
of the old one.
Everything now flows through one component. Two consequences. Operationally: it needs to be multi-region or multi-AZ, and your deploy process for it needs to be safer than the one you use for services — CloudFront's continuous deployment and APIM's revisions exist for this reason. Architecturally, the subtler risk: a gateway that accumulates transformations, conditionals, and per-client special cases becomes a shared component every team must change and nobody owns. That is the distributed monolith reassembling itself at the edge. The §4 rule — a Dispatcher holds no domain logic, and each client type gets its own — exists precisely to prevent it.
Decision tree — direct, gateway, or BFF for this client?
Follow no ↓ down the left. The first yes exits right. Bottom-left is the default.
Reality check
25%Worked example 1 — what seven points of hit ratio actually cost
| Edge traffic | 10,000 req/s |
| At 92% hit ratio · origin sees 10,000 × 0.08 | 800 req/s |
| At 85% hit ratio · origin sees 10,000 × 0.15 | 1,500 req/s |
| Origin load increase from a 7-point drop | 1.88× — nearly double |
| At 70% · 10,000 × 0.30 | 3,000 req/s · 3.75× |
| At 50% · 10,000 × 0.50 | 5,000 req/s · 6.25× |
Worked example 2 — rate limits that add up to more than you have
Your inference backend sustains 240 req/s (the 20 replicas from S3). You have 20 tenants and want to be generous. Watch the arithmetic fail.
| Sustained rate per tenant · R | 10 req/s |
| Aggregate sustained · 20 × 10 | 200 req/s |
| vs capacity 240 · headroom | 40 req/s · OK |
| Burst capacity per tenant · B | 100 |
| Worst case: all tenants burst simultaneously · 20 × 100 | 2,000 requests |
| Instantaneous demand vs 240 req/s capacity | 8.3× over |
| Time to absorb the burst at 240 req/s | 8.3 s of queueing |
Grounding — three current sources
Microsoft Learn · Azure CDN and Front Door retirement documentation
Your Azure source teaches a product line that is being switched off
The material describes Azure CDN as a current service with roughly 70 PoPs and gives no indication of its status. In fact Azure CDN from Edgio has been retired, and Azure CDN Standard from Microsoft (classic) retires on 30 September 2027 — with new profile and domain creation already blocked since 15 August 2025, and managed certificates discontinued on classic tiers from the same date. Azure Front Door (classic) retires 31 March 2027. Microsoft's guidance is unambiguous: migrate to Azure Front Door Standard or Premium, which merged the capabilities of Front Door classic, Microsoft CDN classic, and WAF into a single service back in March 2022.
Practical effect for you: if a certification question or a source exercise says "create an Azure CDN profile", the current answer is a Front Door profile. Study the Front Door tiers, not the CDN ones.
Microsoft Community Hub · APIM announcements, Nov 2025 and Build 2026
API Management's v2 tiers and AI gateway are ahead of the material
The material covers Basic v2 and Standard v2 as recent additions but predates Premium v2, which reached GA in November 2025 with full VNet isolation, availability zones, and scaling to 30 units. Build 2026 added multiple custom domains and wildcard custom hostnames on the v2 tiers, plus workspace support on the built-in gateway.
More relevant to your work: APIM has grown a distinct AI gateway feature set — token-based rate limiting and metering, semantic caching, and Model Context Protocol server support. If you ever need to expose model endpoints to internal teams with per-team token budgets and you do not want to build that yourself, this is currently the most complete managed option across the three clouds.
AWS Networking & Content Delivery blog, Nov 2025 – Mar 2026 · CloudFront developments
CloudFront's pricing model and private-origin story both changed recently
Flat-rate CloudFront pricing plans launched in November 2025 and gained Lambda@Edge, CAPTCHA and mTLS support in March 2026, along with an AI activity dashboard for visibility into AI bot and agent traffic — a category that barely existed when the materials were written. Separately, cross-account VPC origins (Nov 2025) let CloudFront front private ALB/NLB backends in a different account, which makes "public edge, entirely private origin" straightforward in multi-account setups.
For a model endpoint specifically: VPC origins mean your inference load balancer never needs a public IP. CloudFront terminates TLS, WAF filters, rate-based rules absorb volumetric abuse, and the origin is reachable only from CloudFront. You get every edge benefit except caching — which, as the apply-it section explains, you were not going to get anyway.
Apply it — your context
10%Exposing and versioning your model endpoint through a gateway
POST /v1/chat/completions is non-idempotent by design, prompts are close to
unique, and responses are user-specific. Conventional CDN caching gets you essentially nothing.
So be clear about what the edge is actually for on a model endpoint: TLS
termination near the user, DDoS and volumetric absorption before requests cost you compute,
WAF, IP rate limiting, and keeping the origin private. Those are worth having on their own.
What is cacheable sits either side of the generation: the model list, tokeniser configs, OpenAPI documents, static weights or adapter artefacts you serve, and — at the gateway rather than the CDN — semantic caching, which matches on embedding similarity rather than an exact key. That is a gateway feature (APIM has it built in; otherwise it is a Redis-backed layer you write), not a CDN one, because it needs to understand the request body.
The concrete shape for your FastAPI gateway
S1 flagged four exposures in what you have built. Here is what the edge patterns say to do about each, in the order I would do them:
- Version the path first. Move to
/v1/generatetoday, even with one consumer. This is a fifteen-minute change now and a coordinated multi-team migration later. Route/v1/*at the gateway so v2 can run beside it. - Split the two lanes at the route, not in the handler. From S3's Shape B:
/v1/generatestays synchronous with a short timeout;/v1/batchesreturns202and publishes to Pub/Sub. Different timeouts, different rate limits, different scaling — same GPU pool underneath. Doing this at the route level is what lets the two evolve independently. - Move auth and rate limiting out of FastAPI. These are the reference text cross-cutting concerns a gateway exists for. Keeping them in application code means every future service re-implements them, inconsistently.
- Add the global concurrency cap from worked example 2, alongside per-tenant limits. Per-tenant alone does not protect the fleet.
- Decide whether your router is a BFF or a gateway — and don't let it be both. This is the important one. Right now your intelligent router does two different jobs: edge concerns (accepting, authenticating, limiting) and domain-ish concerns (choosing which model should serve this request). The §4 rule says a Dispatcher holds no domain logic. Model selection based on prompt complexity, cost tier, or context length is domain logic — it is your product's routing policy. Put the gateway concerns in a real gateway and keep the router as a service behind it, with its policy testable in isolation. The vLLM Semantic Router project treats routing as its own component for exactly this reason.
- Only then consider a BFF — and only if a second client type appears with genuinely different needs. A notebook client wanting streamed tokens and a batch client wanting job handles is a real second type. One client does not justify one.
Which stack, given your preferences
Open-source-first and portable: Envoy Gateway or Kong on GKE, configured
through the Kubernetes Gateway API, keeps your edge config portable across all three clouds and
runs the same locally. This is also the direction the LLM serving ecosystem has gone —
llm-d and the inference-gateway projects build on Gateway API HTTPRoute, so a
route-level split between interactive and batch is idiomatic rather than bespoke. Managed
alternative on GCP: API Gateway in front of Cloud Run or GKE, escalating to Apigee only if you
ever expose the endpoint to external customers. Put Cloud CDN or CloudFront in front for TLS and
DDoS, not for caching.
Use a stub, not a GPU. A Cloud Run service returning canned JSON after
sleep 2 exercises every edge concept in this session at effectively zero cost.
Attaching an L4 teaches you nothing extra about gateways and costs real money.
Budget alert first — this is the lab where it matters
# Gateways and CDN profiles charge for EXISTING, not just for serving. # APIM Developer tier is roughly $48/month whether you use it or not. gcloud billing budgets create \ --billing-account=$BILLING_ACCOUNT \ --display-name="s4-edge-lab" \ --budget-amount=5USD \ --threshold-rule=percent=0.5 --threshold-rule=percent=0.9
- Deploy the stub backend — smallest possible, scale to zero:
gcloud run deploy model-stub \ --image=gcr.io/cloudrun/hello \ --region=us-central1 \ --cpu=1 --memory=512Mi \ --min-instances=0 --max-instances=2 \ --allow-unauthenticated
--min-instances=0is the cost control: idle costs nothing. - Write an OpenAPI spec with the version in the path. Two routes,
/v1/generateand/v1/batches, so the lane split from S3 exists from the start rather than being retrofitted. - Create the API Gateway config and gateway pointing at the stub. Note how long provisioning takes — that latency is why you version at the gateway rather than recreating gateways per release.
- Add an API key requirement and call it three ways: no key (expect 401/403), wrong key (expect 403), correct key (expect 200). You have now moved authn out of application code, which is exactly what step 3 of the list above asks for.
- Add a quota and prove it fires. Loop 200 requests and confirm you get
429s. Check the response for aRetry-Afterheader — if it is missing, that is the gap that causes client retry storms, and worth knowing your platform's behaviour before you rely on it. - Ship a v2 alongside v1. Add
/v2/generatewith a changed response shape, route it to the same backend, and confirm both paths work simultaneously. This is the whole point of gateway-level versioning: two contracts, one backend, no coordinated client migration. - Optional — see the cache-key effect for real. Put Cloud CDN on a
backend bucket with a static file. Request it 20 times with the same URL and check the hit
ratio in Cloud Monitoring. Then request it 20 times with
?utm_source=$RANDOMappended and watch the hit ratio collapse to zero. That experiment makes worked example 1 visceral in about three minutes.
Gateways, CDN profiles, and load balancers are the worst idle-charge offenders in this entire stage. A forwarding rule bills hourly with zero traffic. An APIM instance on any tier above Consumption bills per unit-hour forever. Delete in dependency order or the deletes fail.
gcloud api-gateway gateways delete <gateway> --location=us-central1gcloud api-gateway api-configs delete <config> --api=<api>gcloud api-gateway apis delete <api>- If you did step 7: disable Cloud CDN on the backend bucket, then delete the forwarding rule, then the target proxy, then the URL map, then the backend bucket, then release the reserved static IP — an unattached reserved IP still bills.
gcloud run services delete model-stub --region=us-central1- Delete the GCS bucket if you created one.
- Verify:
gcloud api-gateway gateways list,gcloud compute forwarding-rules list, andgcloud compute addresses listshould all be empty for this lab. Then check the billing console tomorrow — the only way to be certain is to see a zero.
If you tried the Azure variant: delete the APIM instance itself, not just the APIs — the instance is what bills. Note that APIM deletion is soft by default, so also purge it, or the name stays reserved and the resource can be restored. On AWS: delete the CloudFront distribution (disable first, wait for it to finish deploying, then delete), the API Gateway stage, and the API.
You can now draw boundaries around bounded contexts, judge when async earns its complexity, pick the right messaging primitive on any of the three clouds, and place a gateway, BFF, and CDN correctly. The one thing left is to use it: run the S1 paper lab on your own inference platform, and the number at the end of it will tell you which of S2, S3, or S4 to act on first.