Serverless Systems · Nº 01

Serverless Systems

A field guide to serverless design on AWS

Every system does some mix of five jobs: it accepts requests, runs logic, stores state, moves messages, and keeps records. Serverless design means renting a managed AWS service for each job — so the patching, scaling, and 2 a.m. pages belong to AWS, and your entire contribution is the part that was always the hard part: how the pieces fit together. This guide teaches the seven shapes those pieces make, the failure modes each one hides, and the discipline of reaching for the smallest shape that meets the requirement.

Module 01 The serverless bargain

Strip the branding away and every system you will ever build does some mix of five jobs: it accepts requests, runs logic, stores state, moves messages, and keeps records. Traditionally you did all five on machines you owned — which meant patching, scaling, capacity planning, and 2 a.m. pages. The serverless bargain is simple to state: for each job, rent a managed AWS service instead, and let the undifferentiated work belong to AWS. What remains yours is the part that was always the hard part — how the pieces fit together.

That is why this guide is about system design and not about any single service. The services are commodities; the architecture is the product. This module gives you the mental model the other seven stand on: the five jobs, the cast of services that play them, the cost model that inverts everything you learned about capacity, and the hard limits that shape designs before a line of code exists. The running example for the whole guide is Casework — an internal compliance tool that takes in documents, routes them through review, and must be able to prove, a year later, exactly what happened. We will build it one pattern at a time.

Five jobs, one bargain

The decomposition is the tool. When a requirement arrives — "we need a portal where teams submit evidence and compliance reviews it" — resist the urge to reach for services, and first ask which of the five jobs the requirement is made of. Submitting is accepting requests. Checking the submission is running logic. The evidence itself is stored state. Telling the reviewer is moving messages. Proving it happened is keeping records. Only then do you cast services into the roles.

The bargain has a precise shape. AWS takes: hardware, operating systems, runtime patching, capacity planning, most of scaling, and the physical security of all of it. You keep: identity (who may do what), data (what you store, for how long), wiring (which piece talks to which), and correctness (idempotency, ordering, failure handling). Notice that everything you keep is design. Nothing you keep is toil. That is the trade, and it is why the design conversation gets more important as the operations conversation gets shorter.

The load-bearing idea

Serverless does not make architecture easier; it makes architecture the whole job. When the services are commodities, the only thing left to be good or bad at is the shape of the system — which is exactly the part this guide teaches.

The economics invert

Traditional capacity is a fixed cost you overprovision: you pay for the peak whether it comes or not, and success looks like servers running warm. Serverless inverts this completely. Lambda bills per invocation and per millisecond of execution; DynamoDB per request; S3 per gigabyte-month; Athena per byte scanned. An idle system costs approximately nothing. For internal tools with bursty, unpredictable traffic — most internal tools — this is close to a cheat code: the same design serves ten requests a day or ten thousand a minute without re-architecture, and the bill follows usage down as readily as up.

But an inverted cost model inverts the failure modes too. When cost is linear with use, a runaway loop is not a performance bug — it is a spending bug. A Lambda that retries itself recursively, a poller that wakes every second against an empty queue, a debug log statement inside a hot loop: each is now a line item. Two surprises account for most first-bill shocks: CloudWatch log ingestion (verbose logging at volume costs real money — often more than the compute that produced it) and cross-service chatter (every hop in a chain of six services is billed at every hop).

When it bites — day one, not day ninety

Set a billing alarm before you deploy anything. It is one CloudWatch alarm on estimated charges, it costs nothing, and it converts "we found out at invoice time" into "we found out in an hour." No serverless system should exist without one; it is the smoke detector for the inverted cost model.

The cast of services

Nearly everything in this guide is assembled from about a dozen services. Learn them as roles, not as brands — each one is the managed answer to a slice of the five jobs. This table is the cast list for every diagram that follows.

ServiceWhat it doesThink of it as…
CloudFrontGlobal CDN — caches and serves content close to users, terminates TLS.the front door and doorman
API GatewayManaged HTTP front end — routing, throttling, auth hooks, request validation.the receptionist who checks IDs
LambdaRuns your code in short bursts, triggered by events. No servers to manage.a contractor paid by the minute
S3Object storage — files, exports, evidence, backups. Eleven nines of durability.an infinite filing cabinet
DynamoDBKey-value database with single-digit-millisecond reads at any scale.a ledger with instant lookup
SQSMessage queue — buffers work between a producer and a consumer.an inbox tray between two desks
EventBridgeEvent bus — publishes facts and routes them by rule to many targets.the office announcements channel
SNSPub/sub notifications — fan a message out to email, SMS, or other services.a megaphone with a mailing list
Step FunctionsWorkflow orchestrator — multi-step processes with branching, retries, and human approvals.a project manager who never forgets a step
CognitoUser sign-in and identity — issues the tokens other services verify.the badge office
IAM & KMSWho may do what (IAM); encryption keys with their own access controls (KMS).the locks, and who holds which key
AthenaSQL queries directly over files in S3 — no database to run.a librarian who reads the whole archive on demand
CloudTrail / CloudWatchWho called which API (Trail); logs, metrics, and alarms (Watch).the security camera and the smoke detector
Reading the diagrams

Every figure in this guide uses one visual language: boxes are services, solid arrows are synchronous calls (the caller waits), and dashed arrows are asynchronous events (fire and move on). That single distinction — who is waiting on whom — is the most information-dense line in any architecture diagram, and it is where most design errors hide.

Everything is an API call

Here is the property that quietly changes your security and audit posture: in a serverless system, every action against every service is an authenticated, loggable API call. There is no SSH, no box to shell into, no path to the data that does not pass through IAM. A Lambda reading a table is an API call with an identity attached. A deploy is a series of API calls. Even an administrator's console click is an API call, recorded by CloudTrail like any other.

Two consequences follow. First, identity is the perimeter. There is no network wall to hide behind; every request is decided by policy. This sounds like a loss until you realize what it buys — the second consequence: your security posture becomes configuration, and configuration is reviewable, diffable, version-controlled, and auditable in a way that patch levels and firewall states never were. The question "is this system secure?" becomes the tractable question "what do these policies permit?" — a question you can answer by reading, and prove by testing.

Cross-reference — the record-keeping instinct

If you have ever assembled a privilege log or a chain-of-custody exhibit, you already know why "every action is a recorded API call" matters: provenance that is generated by the system, at the moment of the act beats any after-the-fact reconstruction. Module 08 builds an audit trail on exactly this property — the infrastructure testifies for itself.

Everything has a limit

Managed services trade away one thing you used to own: the ceiling. On your own machines, a slow job runs until it finishes. In serverless, every dimension has a number, and the numbers are design inputs. The ones that shape architectures most often:

LimitNumberWhat it forces
Lambda max duration15 minutesLong work becomes a workflow (m6) or a pipeline (m7), never one heroic function
Lambda sync payload6 MBFiles move via pre-signed URLs (m7), never through the function
DynamoDB item size400 KBBig blobs live in S3; the table stores a pointer
SQS / Step Functions payload256 KBMessages and states carry references, not documents
Lambda default concurrency1,000/account (soft)A traffic spike in one function can starve every other function in the account

The habit that separates smooth projects from painful ones is almost embarrassingly simple: skim the quotas page for each service you adopt before the design review, not during the incident. Half of serverless "outages" are a system meeting a documented number for the first time. The other half are in module 04.

Note — limits as a design language

Read the limits as hints about intended use. A 15-minute cap is AWS telling you Lambda is for bursts; a 256 KB message cap is SQS telling you queues carry references. Designs that fight the limits are usually casting a service in the wrong role.

Module 02 Static delivery at the edge

The first pattern is the one serving you this very page: HTML, CSS, and JavaScript sit in an S3 bucket, CloudFront caches them at edge locations around the world, and a CI/CD pipeline redeploys the whole site on every push to main. There is no server, no runtime, no database — and therefore almost nothing to attack, patch, or pay for.

Don't let the simplicity read as "for brochures only." Internal documentation portals, dashboards that read from an API, report viewers, training material — a surprising share of internal tooling is "a static front end plus a small API," and this module is the front half of that sentence. Casework's portal starts here: the forms, scripts, and styles are static; only the data they fetch is not. The pattern looks too simple to get wrong, which is exactly why its three failure modes — stale caches, exposed origins, and hijacked pipelines — are so common in the wild.

The shape: bucket, edge, pipeline

Three parties, four arrows. A browser asks for a page; DNS points at CloudFront, which serves the file from the nearest edge cache — usually without touching the origin at all. On a cache miss, CloudFront fetches from the private S3 bucket and caches the file per its Cache-Control headers. To ship a change, a developer pushes to main; a GitHub Actions workflow syncs the files to S3 and then tells CloudFront to invalidate, so edges refetch the new files.

Static delivery pattern: browser to CloudFront to S3 on the request path; GitHub Actions syncing to S3 and invalidating CloudFront on the deploy pathBrowserGET /index.htmlCloudFrontedge cache · TLSS3 bucketprivate origin1 request2 on missGitHub Actionson push to main3 sync4 invalidate
Figure 2.1 — The pattern serving this page. Solid arrows are the synchronous request path (the browser waits); dashed arrows are the asynchronous deploy path (fire and move on). Requests never touch a machine you run; a deploy is a sync-and-invalidate, not a release window.

Read the figure through the module-01 lens: the request path has exactly two hops, both operated by AWS, both cached, both encrypted. Everything that can break lives on the deploy path — which runs perhaps ten times a day, is retryable, and inconveniences only you when it fails. Arranging for failures to land on the party who can fix them is most of what good architecture is.

Caching is the contract

The whole pattern stands or falls on one question: when a file changes, who finds out, and when? The answer is a two-tier cache policy that you should be able to recite from memory.

Assets get a year; HTML gets nothing. CSS, JavaScript, images, and fonts are published under versioned names — a content hash in the filename (app.3f2a.js) or a version query string — and served with Cache-Control: public, max-age=31536000, immutable. They can be cached forever precisely because they are never updated in place: a new version is a new URL. HTML is the opposite — it is the entry point that names those versioned assets, so it ships with no-cache (cache, but revalidate every time). The browser asks "still fresh?" on each visit, gets a cheap 304 most of the time, and gets the new HTML — pointing at the new asset names — the moment a deploy lands.

# the two-pass deploy this site actually uses
aws s3 sync ./ s3://site --exclude "*.html" \
  --cache-control "public, max-age=31536000, immutable"
aws s3 sync ./ s3://site --include "*.html" \
  --cache-control "no-cache"
aws cloudfront create-invalidation --paths "/*"
When it bites — the half-deployed page

Get the tiers backwards — long-cached HTML naming short-cached assets — and users hold a stale page that requests asset names which no longer exist: the broken-styles, half-deployed look that plagues static sites. The fix is never "invalidate harder"; it is restoring the invariant that the mutable thing revalidates and the immutable thing is versioned.

The private origin

The bucket must not be publicly readable — and not because the files are secret (they're a public website). The reason is subtler: every property you care about lives on the CloudFront path. TLS termination, access logging, WAF rules if you add them, geographic restrictions, the cache itself. A publicly readable bucket is a second, unmonitored front door that bypasses all of it — an origin URL that shows up in a search index and serves your site with none of your controls attached.

The mechanism is Origin Access Control: the bucket stays private, and its policy grants read access to your CloudFront distribution specifically — CloudFront signs its origin requests, and S3 verifies the signature. One resource policy, and the invariant "all traffic passes through the front door" becomes something S3 enforces rather than something you hope.

Note — block public access, account-wide

S3's account-level Block Public Access setting turns "no bucket in this account is public" from a per-bucket audit into a single switch. Turn it on and leave it on; the rare legitimate public-bucket use case almost certainly isn't yours, and module 07's evidence buckets will thank you.

The pipeline is the attack surface

Inventory what an attacker could want from this system. The content? Public. The bucket? Private, and read-only to the world via OAC. The interesting target is the thing that can write: the deploy pipeline. Whoever controls the CI workflow controls the site — and "the site" here means arbitrary JavaScript served to everyone who visits, from your domain, with your TLS padlock. A static site's XSS story is its supply chain.

The defenses are unglamorous and effective. Protect main — the workflow deploys on push, so pushing is deploying; require review before merge. Scope the CI role to this bucket onlys3:PutObject on site-bucket/* plus one invalidation permission, not s3:* on the account; module 01's blast-radius logic applies to robots exactly as it applies to functions. Prefer short-lived credentials — GitHub's OIDC federation gives the workflow a token that expires in minutes, so there is no long-lived secret to leak. A leaked static-site deploy key with broad S3 permissions is how "defacement" escalates to "data breach."

When static stops being enough

The pattern's boundary is precise: content changes on deploy, not per user. The moment a page needs per-user data, authenticated state, secrets, or writes, that content has left the static tier — and the answer is never to smuggle it back in. The classic smuggle is an API key in client-side JavaScript: every "secret" shipped to a browser is published, full stop. The dashboard that "just needs one little token" to call a third-party API has quietly become a proxy for anyone who opens view-source.

Growing up is graceful, though, and that's the point of the composition: the static tier keeps serving the shell — HTML, styles, scripts, anything cacheable — and the dynamic needs become a small API standing next to it, authenticated per user, holding the secrets server-side. That API is module 03, and Casework crosses the boundary immediately: the portal is static, but "show me my submissions" cannot be. The skill this module leaves you with is recognizing the boundary early — before the workaround, not after it ships.

The load-bearing idea

Serve everything cacheable from the edge; the moment content varies per user, give it an API with real authentication. The static tier's job is to make the dynamic tier as small as possible — not to impersonate it.

Module 03 The synchronous API

This is the pattern to know cold, because it is the serverless answer to the classic three-tier web app: a front door, business logic, and a database. A client calls API Gateway, which authenticates and validates the request before any of your code runs; the matched route invokes a Lambda function holding the business logic; the function reads or writes DynamoDB and returns — all synchronous, all within a couple hundred milliseconds, all fully managed.

Nearly every internal tool starts here, and Casework is no exception: the portal from module 02 needs POST /submissions and GET /submissions/{id}, authenticated per user, backed by a table that can answer "my submissions, newest first" instantly. The pattern is simple to draw and forgiving to run. Its four disciplines — where auth happens, how functions are scoped, how the table is designed, and what is allowed on the synchronous path at all — are where the quality lives, and each one echoes forward through every pattern after this.

The workhorse three-tier

Follow one request end to end. The portal sends GET /submissions/sub_84h2 with a token obtained by signing in through Cognito. API Gateway validates the token, applies throttling, and rejects malformed requests. The matched route invokes the Lambda function with the request as an event; the function queries DynamoDB and returns the item back up the same path. Logs and metrics land in CloudWatch throughout.

Synchronous API pattern: client with Cognito token calling API Gateway, which invokes Lambda, which queries DynamoDB; logs flowing to CloudWatch asynchronouslyClientBearer tokenAPI Gatewayauth · throttle · validateLambdabusiness logicDynamoDBQueryCognitoissues tokensCloudWatchlogs · metrics · alarmssolid = caller waits · dashed = async
Figure 3.1 — The workhorse three-tier. The caller waits end-to-end on the solid path, so every hop must be fast, and every failure returns to the user. That property — the user is on the line — is the constraint the rest of this module manages.

One operational note that earns its place in a design module: Lambda's logs and metrics arrive in CloudWatch automatically, but API Gateway's access logs and tracing are opt-in stage settings. Turn them on when you build the stage. You want the full request picture before the first incident, not after it — retrofitting observability during an outage is how one incident becomes two.

The front door does real work

The most common junior mistake in this pattern is treating API Gateway as a pass-through and doing everything in the function: parse the token in code, validate the body in code, rate-limit in code. It all works — and it means every malicious, malformed, or unauthenticated request in the world gets to invoke a function you pay for and maintain.

The gateway can do three jobs before your code runs. Authentication: a Cognito or Lambda authorizer validates the token at the edge; requests without a valid identity never reach your function — they are rejected by managed infrastructure at no compute cost to you. Throttling: per-client rate and burst limits absorb abusive or buggy callers while everyone else proceeds. Request validation: a JSON schema on the route rejects malformed bodies — missing fields, wrong types — with a clean 400, which is free defense-in-depth against injection and a kindness to your own error handling, which now only sees well-formed input.

The load-bearing idea

Authenticate at the edge. The cheapest request to serve is one that never reaches your code, and the safest function is one that can assume every event it receives has already been authenticated, rate-limited, and schema-checked.

Functions: small, stateless, narrowly armed

Three disciplines make Lambda functions boring in the best sense. Small and single-purpose: a function per route or per tightly-related route group, so each has one reason to change and one failure domain. Stateless: a function instance may be reused for many invocations (that's what makes warm calls fast) but may vanish at any time — anything worth keeping goes to the table, never to memory or /tmp. Narrowly armed: one IAM role per function, granting exactly its own actions on its own resources — dynamodb:GetItem and Query on the submissions table, not dynamodb:* on everything. When a function is compromised — dependency, injection, logic flaw — its role is the blast radius, and you decided its size on the day you wrote the policy.

Then there is the cold start, Lambda's most-discussed and most-misunderstood property. A function that hasn't run recently pays initialization on its first invocation — roughly 100 ms to 1 s depending on runtime and dependencies. For an internal tool at internal-tool traffic, this is almost always fine, and the engineering effort spent "solving" it is usually misallocated. Respect it in the tail latencies, mention it honestly in the design review, reach for provisioned concurrency only when a p99 target genuinely demands it.

When it bites — the shared "utility" role

The anti-pattern is one generous IAM role shared by every function "to keep things simple." It converts your least trusted function into your most privileged one, because a compromise anywhere grants the union of permissions everywhere. One role per function is not bureaucracy; it is the entire compartmentalization story of a serverless system.

Data modeled to the access patterns

DynamoDB is the piece that punishes imported habits. In SQL you model entities and relationships, then write whatever queries you need later; the database's job is to answer ad-hoc questions. DynamoDB makes the opposite trade: it will answer a known question in single-digit milliseconds at any scale, and it will make unknown questions painful — there are no joins, and scanning a table is an anti-pattern billed by the gigabyte. The discipline: write down the access patterns first — the exact questions with their exact parameters — and design the table to answer them.

Casework's questions: "a submission by ID," "a user's submissions, newest first," "all submissions pending review, oldest first." That yields a table keyed on PK = USER#{userId}, SK = SUB#{timestamp} — the second question becomes one Query, already sorted — plus a global secondary index keyed on the bare submission id for the first (a lone sub_84h2 isn't in the base key, so it needs its own index), and another keyed on status for the third. The entity diagram never appears; the questions were the design.

# access patterns → key design
Q1  submission by id            → Query    GSI2: PK=id  (bare id isn't in the base key)
Q2  my submissions, newest      → Query    PK=USER#u, ScanIndexForward=false
Q3  pending review, oldest      → Query GSI1: PK=STATUS#pending, SK=submitted_at
Note — when the questions are unknowable

If the requirement is genuinely ad-hoc analytics — "we'll want to slice this by anything" — DynamoDB is the wrong tool, and the answer is not more indexes; it's the analytics half of module 07, where Athena queries flat files with real SQL. Hot path known questions, cold path open questions: both, cleanly, is the composition.

The two-second rule

The synchronous path has a speed limit, and it is set by the party who can't be configured: the human. Around two seconds of wait, users retry, refresh, or distrust the tool — and their browser, their proxy, and your gateway all have timeouts standing behind theirs (API Gateway cuts sync invocations at 29 seconds by default — raisable on REST APIs via a quota increase, but doing so is usually the wrong move, since user patience is the real ceiling). So the rule: if a request triggers work that takes more than a second or two — PDF rendering, third-party calls, LLM inference, anything with someone else's latency in it — do not make the caller wait. Accept the request, record it, return 202 Accepted with a job ID, and finish the work asynchronously.

POST /submissions          → validate · persist PENDING · enqueue
202 Accepted
{ "id": "sub_84h2", "status": "PENDING" }

GET /submissions/sub_84h2  → { "status": "PROCESSING", … }   # poll, or notify

Notice what the rule really is: a boundary-drawing tool. It splits every feature into the part the user must witness (validation, persistence, the receipt) and the part they must not wait for (everything else). Drawing that line is the design act; the machinery on the far side of it — the queue, the worker, the retries — is module 04's subject, and Casework's document processing is about to need all of it.

Module 04 Queues and asynchronous work

The single most important idea in distributed systems is decoupling: letting the part that accepts work run at a different speed — and fail at different times — than the part that does the work. A queue is how. The producer drops a message in SQS and immediately responds to the user; a worker Lambda picks messages up at its own pace and processes them. The user's request ends at the queue; everything after happens on the system's schedule, not the user's.

Module 03 ended by drawing a line through every feature — the witnessed part and the waited-for part — and promising machinery for the far side. This is that machinery. Casework's POST /submissions returned 202 in tens of milliseconds; now the parse, the virus scan, and the metadata extraction have to actually happen, survive failures, absorb Friday-afternoon submission bursts, and never lose a document someone is legally required to have filed. The queue does all four — in exchange for one non-negotiable demand it makes of your code, which this module drills until it's reflex.

Decoupling, made concrete

Consider the coupling you get without the queue: the API function calls the processing function directly and waits. Now the producer moves at the worker's speed (a slow parse is a slow API), fails when the worker fails (a crashed parser loses the submission), and scales when the worker scales (a burst of submissions is a burst of heavy processing, immediately). Three couplings, three outages waiting.

Insert the queue and all three couplings dissolve. The producer's job shrinks to: validate, persist a PENDING record, enqueue a small message — { submission_id: "sub_84h2" }, a reference, never the document (module 01's 256 KB lesson) — and return. The worker polls at its own pace, processes in small batches, updates the record as it goes. Producer and worker now scale independently, fail independently, and deploy independently.

Queue pattern: API and producer Lambda enqueue to SQS and return 202; worker Lambda polls the queue, writes to DynamoDB; failed messages route to a dead-letter queue with an alarmAPI + producer202 in ~50 msSQS queue{submission_id}Workerbatch · own paceDynamoDBstatusDLQafter N failures → alarmenqueuepoll
Figure 4.1 — The buffer between speeds. Everything right of the queue happens on the system's schedule. The message carries a reference, not the document; the DLQ carries an alarm, not a hope.

What the queue buys

Three purchases, one insertion. Burst absorption: a spike of 10,000 submissions becomes a backlog that drains smoothly instead of a wall of timeouts — the queue converts a latency catastrophe into a temporary, visible, measurable delay. Failure isolation: if the worker or a downstream dependency is broken, messages wait safely in the queue; an outage of the processing tier becomes a processing delay rather than data loss, and recovery is automatic when the dependency returns. Retries for free: a message that fails processing reappears and is tried again — no retry code, no scheduler; failure handling is a property of the infrastructure rather than a feature of your application.

The backlog itself is an underrated gift: it makes load visible. Queue depth and message age are the two most honest metrics in the system — you can watch pressure build, alarm on it, and scale workers against it. The synchronous system under the same load shows you nothing until it shows you timeouts.

Note — what it costs

The purchase price is consistency and simplicity: the client now sees PENDING before COMPLETE (interfaces must say so honestly), the system has more moving parts, and — the subject of the next section — everything downstream must tolerate duplicates. Pay the price where the work is slow, bursty, or unlosable; skip the queue where a 50 ms synchronous call was already fine. Module 08's "start boring" clause applies to queues too.

At-least-once means at-least-twice, eventually

Here is the demand the queue makes in return. SQS guarantees at-least-once delivery: every message arrives, but the same message will occasionally arrive twice. This is not a quality problem to file a ticket about — it is a law of distributed queues. The mechanics make it concrete: when a worker receives a message, SQS doesn't delete it; it hides it for the visibility timeout. The worker processes, then deletes. If the worker crashes mid-processing — or just runs longer than the timeout — the message reappears and another worker receives it. The crash case is the feature (no message lost); the slow case is the trap (two workers, same message, both "succeeding").

So the worker must be idempotent: processing a message twice must equal processing it once. The standing technique is a conditional write keyed on a stable identity — the submission ID the message already carries:

# first delivery wins; the duplicate becomes a no-op
UpdateItem sub_84h2
  SET status = PROCESSING
  ConditionExpression: status = PENDING   # fails on the duplicate → skip

Every side effect needs the same treatment: writes are conditional, emails are deduplicated by a send-record keyed on submission_id, downstream calls carry an idempotency key. And set the visibility timeout deliberately — a common rule is roughly six times the function timeout, so a slow-but-alive worker isn't silently raced by its own retry.

The load-bearing idea

Any handler that isn't safe to run twice is a latent bug, not a working handler. Queues redeliver, events duplicate, retries retry — idempotency isn't a hardening step for later; it is the admission price of asynchronous architecture.

Dead letters must be loud

Retries handle transient failure — the dependency that was down, the throttle that passed. But some messages fail every time: the corrupted PDF, the payload from a buggy client version, the edge case your parser never met. Without intervention, a poison message retries forever, burning compute and — in a batch — blocking its neighbors. The mechanism is maxReceiveCount: after N failed receives, SQS moves the message to the dead-letter queue, where it waits, intact, for a human.

Now the operational truth the diagram can't show: a DLQ nobody monitors is a place where work goes to die — with a receipt. The user got a 202; the system accepted responsibility; the message is sitting in a queue no one reads. That is strictly worse than a synchronous failure, which at least told the user. So the DLQ ships with three attachments, or it isn't finished: an alarm on depth > 0 (a dead letter is a sev-worthy event in a system that promised acceptance), a runbook (how to inspect, fix, and redrive messages back to the source queue after the bug is fixed), and a retention check (14 days maximum — the clock is running on your fix).

When it bites — the 202 that lied

The queue pattern's whole moral contract is: "we answered fast because we promise to finish." Every unmonitored DLQ message is that promise broken silently. If you adopt the pattern, the alarm is not optional hygiene — it is the other half of the 202.

Ordering, FIFO, and the poison batch

Two refinements complete the pattern. First, ordering: standard SQS queues don't guarantee it. Messages arrive roughly in order, but "roughly" is not a contract, and retries reshuffle. If Casework's decision.recorded must never be processed before its submission.created, you have three options in descending order of preference: design the workers so order doesn't matter (idempotent, state-checking handlers that re-fetch current truth rather than trusting message sequence); use a FIFO queue with a message group per submission — strict order within each group, at the price of lower throughput and head-of-line blocking within the group; or reach for the orchestration of module 06, where sequence is explicit. Choosing FIFO by reflex is the common error: most "ordering requirements" dissolve when handlers check state instead of assuming sequence.

Second, batching: workers receive a batch per invocation — up to ten by default, raisable to 10,000 on a standard queue — and by default one bad message fails the whole batch — nine innocent messages retried alongside one poison pill, all ten inching toward the DLQ together. Enable partial-batch responses (ReportBatchItemFailures), so the worker reports exactly which messages failed and only those retry. It's a checkbox and three lines of code, and it's the difference between a poison message quarantining itself and a poison message taking hostages.

Module 05 Event-driven fan-out

A queue connects one producer to one consumer — a private conversation. But mature systems have facts that many parts care about: a submission was approved, a document failed its scan, a policy changed. The event bus inverts the relationship. The producer publishes the fact once, to EventBridge, without knowing or caring who's listening; rules match events by pattern and route copies to every interested target.

Casework has reached the moment this pattern exists for. When a decision is recorded, today's requirements say: notify the submitter. Next quarter's say: also update the search index. The quarter after: also recompute the team's compliance dashboard, and also feed the audit archive. With direct calls, each addition means changing — and redeploying, and re-risking — the decision function. With a bus, the decision function publishes decision.recorded once and is never touched again. This module is about that inversion: what it buys, what it quietly costs, and the two disciplines — schema contracts and correlation IDs — that keep an event-driven system debuggable by humans.

Publish once, route by rule

The mechanics are compact. Something happens — the decision function from module 03 finishes its write — and it publishes an event to the bus: a small JSON document with a source (casework.review), a detail-type (decision.recorded), and a detail payload. Rules on the bus match by pattern — source, type, fields of the payload — and each matching rule forwards a copy to its targets: a Lambda, a queue, a Step Functions execution, another bus.

Event fan-out: a producer publishes decision.recorded to the EventBridge bus; three rules route copies to a notifier Lambda, an SQS-buffered indexer, and a workflow; an archive retains events for replayDecision fnpublishes onceEventBridge busrules match patternsdecision.recordedNotifier → SNSrule: type=decision.*SQS → indexerbuffered per consumerStep Functionsstarts escalation flowArchiveretained · replayable
Figure 5.1 — One fact, three reactions. The producer publishes once and knows nothing else. Rules decide who hears; each consumer is buffered and fails independently; the archive remembers everything for replay. All arrows dashed: nobody is waiting for anybody.

Contrast the alternative honestly, because it's what most systems do first: the decision function calls the notifier, then the indexer, then starts the workflow — in a row, in code. Every new consumer is a code change to the producer. Any consumer's failure is the producer's failure. Any consumer's latency is the producer's latency. The bus severs all three dependencies at once.

Consumers are additions, not modifications

The architectural payoff deserves its own section because it changes how teams work, not just how systems run. When the new requirement arrives — "we also need to notify the review team" — the change is: add a rule, deploy a subscriber. The producing code doesn't change, isn't redeployed, and cannot be broken by the addition. The blast radius of new functionality is the new functionality.

This is the open-closed principle wearing an ops uniform, and its compounding value is organizational: teams ship consumers independently, on their own schedules, without negotiating deploy windows with the producer's owners. The event catalog becomes the integration surface — a new team reads the catalog, subscribes to the facts it needs, and never files a ticket against the producing service. Systems that grow this way stay comprehensible, because every capability is a legible pair: which facts it consumes, which facts it emits.

The load-bearing idea

Don't make the producer care. The moment publishing a fact requires knowing who needs it, you have coupled the past to the future — every tomorrow-consumer becomes a today-change. The bus is how the system says: the fact is public; caring is the consumer's job.

Event schemas are contracts

Here is the bus's hidden invoice. The moment a second consumer subscribes to decision.recorded, its field names, types, and semantics have become a contract with consumers you don't know about — that's the whole point of not knowing your subscribers, applied against you. Rename decision_at to decided_at and somewhere, a dashboard silently reads undefined; there is no compiler, no failing build, no 400 response to tell you. The event just stops meaning what it meant, quietly, in someone else's code.

The disciplines are exactly an API's, because this is an API: additive changes only (new fields are safe; renames and removals are breaking); version explicitly (a version field in the detail, or a versioned detail-type, with old and new published side by side during migrations); document like a public surface (a schema registry or a versioned catalog in the repo — discoverable, not tribal); and carry references, not blobs{ submission_id, decision, decided_by, correlation_id }, not the entire document, both because payloads sprawl into every subscriber's logs and because a fat payload is a snapshot that goes stale while a reference is always current.

Cross-reference — Contracts at the Boundary

Guide Nº 07 spends eight modules on API contracts: breaking versus additive change, tolerant readers, versioning, deprecation. Every clause transfers to events verbatim, with one aggravation — an event consumer can't even send you a 400. The feedback loop for a broken event contract is a human noticing a wrong dashboard, which is to say: weeks. Design accordingly.

Debugging without a call stack

In module 03, a request was a call stack: one arrow in, one arrow out, one log stream to read. That's gone now. "What happened to submission sub_84h2?" is answered across the API's logs, the queue worker's logs, the bus, three subscribers' logs, and a workflow execution — each with its own timestamps and request IDs, none aware of the others. Without deliberate design, tracing one submission through the system is an afternoon of grepping in five consoles.

The deliberate design is one field and one habit. The field: a correlation ID — minted once at the edge (the API request that started everything), then propagated unchanged through every queue message, every event payload, every workflow input, every downstream call. The habit: structured logs that always include it. JSON logs with correlation_id, submission_id, and a terse event name, in every service, means CloudWatch Logs Insights can reassemble the entire story with one query:

fields @timestamp, @log, event, detail
| filter correlation_id = "corr_7f3k"
| sort @timestamp asc

That query output — the submission accepted here, queued there, scanned, decided, indexed, notified, archived, in order, with timing — is the call stack you gave up, rebuilt from discipline. It costs one field and a logging convention; it is the difference between event-driven systems that are observable and event-driven systems that are folklore.

Buffers, replay, and honest interfaces

Three operational refinements finish the pattern. A queue per consumer: route rules through SQS rather than invoking subscriber Lambdas directly, so each consumer gets module 04's full toolkit — buffering, retries, a DLQ of its own — and one slow consumer never drops events or backs up its neighbors. The bus fans out; the queues absorb. The archive: EventBridge can retain everything that crosses the bus, replayable on demand. This quietly changes what's recoverable: deploy a buggy indexer, fix it, replay the week — the events re-run and the index rebuilds. Stand up a brand-new consumer next quarter and replay history into it, as if it had been subscribed all along. The archive turns "we didn't think of that consumer yet" from a data loss into a delay. (Replay is redelivery — your consumers' idempotency, again, is what makes it safe.)

And eventual consistency, stated honestly: subscribers react in milliseconds-to-seconds, not instantly. The search index trails the decision by a moment; the dashboard trails the index. This is fine — if the interfaces say so. "Decision recorded — search results update shortly" is honest; a search page that implies real-time and silently isn't will generate bug reports forever. Eventual consistency is an architecture fact; whether it's a UX bug is a copywriting decision.

Note — scoping PutEvents

Producers should be authorized narrowly: events:PutEvents constrained to their own source value, so a compromised function can publish only its own kind of facts, not forge casework.review events from elsewhere. Module 03's blast-radius clause, applied to the bus.

Module 06 Orchestrated workflows

Queues and events compose beautifully — until the day you try to answer "where is submission sub_84h2 in the review process?" and realize the process exists only as an emergent property of five functions reacting to each other. It works, but nobody can see it: the logic is smeared across handlers, the sequence lives in tribal knowledge, and proving "the defined process was followed" means archaeology across log groups.

Some processes deserve better. An intake review, an access request, an escalation chain — these have states, branches, deadlines, and often a human in the loop. Step Functions makes such a process a first-class artifact: a state machine you can read as a diagram, where retries and error handling are declared rather than coded, where an execution can pause for days awaiting a human at zero cost, and where every run records exactly which path it took, with what data, and why. Casework's review process — validate, scan, human decision, record, notify — is precisely this kind of process, and this module builds it. The counterweight matters too: orchestration is powerful and it is ceremony, and the module ends with the discipline of not using it.

Orchestration vs choreography

Name the two styles precisely, because the choice between them is the module's real subject. Choreography is what modules 04 and 05 built: services react to each other's messages and events, no one is in charge, and the overall behavior emerges. Orchestration adds a conductor: a central definition that says step one, then step two, branch here, retry there — and tracks each execution through it.

Choreography's strengths are decoupling and evolution — consumers as additions, no central chokepoint. Its weakness is that the process is invisible: no single place shows the sequence, and cross-step concerns (timeouts spanning steps, compensation when step four fails after step two committed) have no natural home. Orchestration inverts the trade: the process is explicit, visible, versioned, and recorded — at the cost of a central definition that every step change touches.

The selector: how much does it matter that the process, as a whole, is legible and provable? Plumbing — thumbnail on upload, index on change, notify on decision — wants choreography; nobody audits the thumbnail pipeline. Processes with stakes — reviews, approvals, escalations, anything someone will later ask "was the procedure followed?" about — want orchestration, because for those, the visibility is the feature. The slogan that compresses this module: orchestrate workflows; choreograph plumbing.

A machine you can read

Casework's review process, as a state machine: a Validate task (Lambda) checks the submission and enriches it; a Scan task runs the document check; a choice state branches on the verdict — clean submissions proceed to human review, flagged ones to escalation; after the decision, a Record task writes the outcome and a final task publishes decision.recorded to module 05's bus. Deadlines, retries, and error routes are part of the definition itself.

Review workflow state machine: Validate task, Scan task, choice on verdict branching to human review with callback token or escalation, then Record and Publish tasksValidateretry: 3× backoffScancatch → Quarantineverdict?Escalateflagged pathHuman reviewwaitForTaskToken · 5d timeoutRecordconditional writePublish eventdecision.recordedcleanflaggeddecision
Figure 6.1 — The process as an artifact. The diagram is not documentation of the system — it is the system: this definition executes, and every run records which path it took through it.

Notice what the tasks don't contain. Validate declares Retry: 3 attempts, exponential backoff on the state — no retry loop in the function. Scan declares Catch → Quarantine — no try/except-and-route logic in code. The functions shrink to pure business logic; the process concerns — sequence, retry, branching, timeout, compensation — live in the definition, where they are visible, reviewable in a pull request, and uniform across steps. That relocation is the pattern's quiet gift: process logic stops being smeared through application code.

Humans in the loop

The step that makes orchestration irreplaceable for Casework is the one no queue can model well: wait for a person. Step Functions does it with a callback token: the Human-review state (declared waitForTaskToken) generates a token, hands it to your notification path — the reviewer gets a link — and the execution simply stops. Not polling, not holding a connection: stopped, durable, costing nothing, for hours or days. When the reviewer decides, the decision endpoint calls SendTaskSuccess with the token and the verdict, and the execution resumes exactly where it paused. A timeout on the state (five days, say) routes to an escalation branch, so "the reviewer never responded" is a designed path rather than a stuck execution.

Now the trap. The token proves which execution; it does not prove who. Anyone holding the token can resume the workflow, so if the approval link is the only control, approval-by-forwarded-email is your access model. The decision endpoint must therefore do two checks that the token cannot: authenticate the human (module 03's authorizer — the reviewer signs in) and authorize them for this request (are they an assigned reviewer of sub_84h2, and not, say, its submitter?). Then, and only then, it spends the token. Token proves execution; session proves identity; policy proves entitlement — three different facts, three different mechanisms, all required.

Cross-reference — separation of functions

If you've administered approval authority before — signatures, delegations, conflicts — the shape is familiar: an approval instrument is only as good as the verification that the right officer, empowered for this matter, executed it. The task token is the instrument, not the verification. Casework's rule that a submitter cannot review their own submission is a conflicts check, enforced in the decision endpoint's policy layer.

Standard, Express, and the 90-day memory

Step Functions comes in two modes, and the choice is architectural, not a pricing detail. Standard workflows run up to a year, bill per state transition, guarantee exactly-once state execution, and record full execution history — built for Casework's review, which waits days for humans. Express workflows cap at five minutes, bill per request and duration, run at enormous volume, and log history only through CloudWatch — built for high-rate, short-lived processing where Standard's per-transition pricing would sting. The tell: humans and days → Standard; volume and seconds → Express.

Then the caveat this guide has been building toward. Standard's execution history — every state, input, output, and timestamp — is queryable for roughly 90 days after completion, and then it is gone. For debugging, 90 days is generous: history is the debugger, letting you inspect exactly what step four received the Tuesday before last. But Casework's compliance story needs "prove the process was followed" answerable in year three, and Express workflows never had rich history to begin with. So: treat execution history as the working record, never the archive. The durable proof is the completion event — the workflow's final step publishes decision.recorded with the decision, actor, and timestamps, and module 08's trail records it durably. The event is the evidence; the history is the debugger.

Don't orchestrate everything

Every pattern in this guide has a failure mode of enthusiasm, and orchestration's is the most seductive: once the review workflow is visible and provable, everything starts looking like it deserves a state machine. Resist. For simple "A then B," a queue is cheaper, simpler, and has fewer moving parts; wrapping two steps in Standard-workflow ceremony adds per-transition cost, a definition to version, and an execution history nobody will read. The bar from section one stands: orchestration is earned by processes whose legibility matters — stakes, branches, humans, deadlines, or auditors.

Two operational clauses close the module. Payload limits, again: state-to-state data passes through the execution itself, capped at 256 KB — the same lesson as the queue: pass submission_id and S3 references, never documents, or the workflow that ran fine in testing dies on the first large evidence file. The conductor's role: the state machine's IAM role can invoke its steps — its Lambdas, its publish action — and nothing else. It is the conductor, not a superuser; module 03's blast-radius clause applies to the process layer exactly as it applied to functions.

The load-bearing idea

Orchestrate workflows; choreograph plumbing. The state machine earns its ceremony when the process itself — visible, branching, humane, provable — is the point. When it isn't, module 04 was already the answer.

Module 07 Ingestion and analytics pipelines

Systems that deal in documents and evidence — reports, exports, uploads, records from other systems — converge on a pipeline shape: files land in an S3 bucket, landing triggers processing, and processing produces two products at once. Structured metadata goes to DynamoDB, where the application queries it instantly; a normalized, curated copy goes to a second bucket, where Athena runs SQL over it at leisure. There is no warehouse to run, size, or patch — S3 itself is the database for the analytical half.

This "raw bucket → processing → curated bucket → query in place" shape is a miniature data lake, and it scales from a hundred files to a hundred million without changing the picture. Casework is its natural customer: evidence files arrive daily, the portal needs "show me this submission's documents" in milliseconds, and compliance needs "how many submissions per team, per month, missing attestations?" in seconds — two workloads that would fight each other in one database, and that this pattern serves from two. The module also carries the guide's most safety-critical habit: the raw bucket preserves originals untouched, because the moment anyone asks you to prove what was received, the original is the answer.

The shape: two products from one ingest

Walk one evidence file through. It arrives in the raw bucket — uploaded through the portal or delivered by another system. The ObjectCreated event triggers processing — through a queue, per module 04, because arrival is bursty and the queue is how bursts become backlogs. The processor validates and parses the file, writes extracted metadata to DynamoDB — type, dates, parties, page count, the fields the portal queries — and writes a normalized, columnar copy to the curated bucket, partitioned by date. The original in raw is never modified. Athena then answers analytical questions with plain SQL over the curated files, paying per query.

Ingestion pipeline: upload via pre-signed URL to raw bucket, ObjectCreated through queue to processor Lambda, which writes metadata to DynamoDB and curated Parquet to a second bucket queried by AthenaClientpre-signed PUTRaw bucketoriginals · immutableProcessorvia SQS · parseDynamoDBhot metadataCurated bucketParquet · dt=…AthenaSQL · pay per scanevent
Figure 7.1 — Two products from one ingest. Hot metadata for the application's millisecond queries; a cold, partitioned, queryable archive for the questions nobody has asked yet. The raw original is never modified by anything, ever.

The two-product split is the design insight. The portal's questions ("this submission's documents") are known, keyed, and hot — module 03's access-pattern territory. Compliance's questions ("slice by team, month, attestation status") are ad-hoc, relational, and cold — exactly what module 03 told you DynamoDB punishes. One ingest, two representations, each workload served by the store built for it.

Getting bytes in: the pre-signed URL

Module 01's limits already ruled out files transiting Lambda; the pattern that replaces it deserves precision. The client asks the API (authenticated, module 03) for an upload grant; the API returns a pre-signed URL — a time-limited, signed permission to perform one specific operation against S3. The client PUTs the file directly; bytes flow client-to-S3, never through your compute. The API stays in charge of who may upload and where the object lands (it chooses the key); S3 does the heavy lifting.

Constrain the grant like the credential it is: short expiry (minutes — it's an upload window, not a bearer token for the afternoon), fixed key (the API names the object — never let clients choose paths in your bucket), fixed content type, and — the one everyone learns the hard way — a size cap, which a plain pre-signed PUT cannot express. A pre-signed PUT signs headers, not payload size; to cap size you use the pre-signed POST policy form, whose content-length-range condition S3 enforces at upload time. If uploads are unbounded, your storage bill is a stranger's decision.

When it bites — the file is hostile

Every uploaded file is attacker-controlled input until proven otherwise. Parsers are the classic soft target — malformed PDFs and zip bombs attack the processor, not the storage. So: parse in the sandbox you already have (a Lambda with a tight role, no network egress it doesn't need, bounded memory and timeout), never render or execute uploads server-side without hardening, and treat parser crashes as security signals in module 04's DLQ, not just bugs.

The original is the evidence

The raw bucket has one rule, and the whole compliance story leans on it: nothing modifies an original, ever. The processor reads raw and writes elsewhere; normalization, redaction, format conversion, compression — every transformation produces a new object in the curated bucket, keyed back to its source. The raw object stays byte-for-byte as received, because "what did the submitter actually send?" is a question you will be asked, and a transformed copy is an argument where an original is an answer.

Make the rule structural, not behavioral. Versioning on (an overwrite becomes a new version rather than a destruction); per-stage roles (the upload path can only PutObject to raw; the processor reads raw and writes curated; analysts read curated only — nobody holds write access to raw after landing); and for genuinely evidentiary workloads, Object Lock on the raw bucket, which module 08 covers in anger. Record a checksum (S3 gives you one; store it in the metadata item) so "unchanged since receipt" is demonstrable arithmetic rather than an assurance.

Cross-reference — best evidence

The instinct that a copy, however faithful, is weaker than the original — and that a gap in custody is a gap in the story — is doing real work here. The raw bucket is the original; the checksum and the per-stage roles are the custody log; the curated bucket is the working copy you're allowed to mark up. Systems that respect that hierarchy survive their first serious dispute; systems that "cleaned up" their originals do not.

Athena and the economics of scanning

Athena's pricing is its design pressure: you pay per byte scanned. Everything that matters about laying out the curated bucket follows from that one sentence. Partition — lay keys out as dt=2026-07/team=alpha/…, and a query filtered to July scans July, not the archive; unpartitioned, every query scans everything forever, and the "cheap" pattern quietly becomes the expensive one. Go columnar — Parquet stores columns together, so SELECT team, COUNT(*) reads two columns instead of whole JSON rows; with compression, typical scans shrink by an order of magnitude or two versus raw JSON. Compact — millions of tiny files make every query pay per-object overhead; batch small events into fewer, larger files (a scheduled compaction job, or Data Firehose doing the batching on the way in).

-- compliance asks; Athena answers; the scan is one partition, two columns
SELECT team, COUNT(*) AS missing
FROM curated.submissions
WHERE dt = '2026-07' AND attestation = false
GROUP BY team;

Register the schema once in the Glue Data Catalog (a metadata store both Athena and future tools read), and the curated bucket becomes, functionally, a warehouse — with no cluster, no capacity, no idle cost, and the same eleven-nines substrate as everything else. For an internal tool, this is the whole analytics stack.

Pipeline hygiene

Three habits keep the pipeline trustworthy at volume. Poison files fail alone: one malformed upload must never stall the line. Files process independently (the queue between event and processor is what makes that true), failures route to the DLQ carrying the object key, and module 04's partial-batch discipline keeps one bad file from taking nine hostages. The DLQ alarm matters more here than anywhere: a submission that silently never processed is a compliance gap wearing a 202.

Idempotency, again: S3 events ride at-least-once machinery, and event replay (module 05) will re-run history on purpose. The processor is safe because both writes tolerate repetition — the metadata item is a full Put keyed on the object (same input, same item), and the curated write is deterministic (same source key → same curated key, overwritten with identical content). Reprocessing is then not merely safe but useful: fix the parser, replay the month, and the curated bucket heals.

Watch the lag: the pipeline's health metric is freshness — the age of the oldest unprocessed object (queue age, module 04's honest metric, plus a periodic raw-vs-processed reconciliation). Dashboards that show throughput but not lag report how fast you're going, not whether you're falling behind — and in an evidence pipeline, behind is the state that becomes a finding.

The load-bearing idea

The pipeline's product isn't the curated bucket — it's the guarantee: everything received is preserved untouched, processed exactly-once-in-effect, and queryable two ways. The buckets are easy; the guarantee is the design.

Module 08 Records you can defend — and choosing well

Every pattern so far does things. This one remembers them — in a way that holds up when someone skeptical asks. For governance, risk, and compliance systems, the audit trail is not a logging afterthought; it is a load-bearing feature with three hard requirements: complete (every consequential action recorded), immutable (nobody — including administrators — can quietly edit history), and queryable (an auditor's question is answerable in minutes, not weeks). Casework has been emitting events toward this module since module 03; here we give them somewhere to live that an auditor will believe.

The module then widens into the guide's capstone. The security notes scattered through seven patterns weren't scattered at all — they were six moves applied consistently, and this module names them as the system they are. And it closes where a field guide should: with the decision table that maps requirements to patterns, the five gotchas that cost real teams real weekends, and the argument this guide has been quietly making throughout — that the best architecture is the smallest one that meets the requirement.

Record events, not state

The design principle first. Don't just store "submission sub_84h2 is approved" — store the append-only sequence of facts that got it there: submitted by whom, at when; scanned with what verdict; reviewed by whom; decided on what basis. Current state is derivable from events; events are not derivable from state. A state row that reads APPROVED answers today's question; the event sequence answers the auditor's questions — who, when, in what order, under which policy version — including the ones nobody has thought to ask yet.

And crucially, the trail has two layers, because "who did what" has two meanings. The application layer records what users did: submissions, decisions, escalations — your events, emitted by your code. The infrastructure layer records what every IAM identity did to the system itself: policy changes, table modifications, bucket configuration — CloudTrail's record, emitted by AWS whether your code cooperates or not. Auditors ask about both, and the second layer is what makes the first believable: the application's trail is only as trustworthy as the answer to "who could have altered it?" — which is exactly the question CloudTrail answers.

The load-bearing idea

State is a summary; events are the record. Store the events append-only, derive state freely, and keep two layers — what users did (your events) and what identities did to the infrastructure (CloudTrail) — because the second layer is the first layer's credibility.

Append-only is enforced, not promised

"We never update audit rows" is a team convention; conventions don't survive incidents, personnel changes, or subpoenas. The trail is append-only because policy makes the alternatives impossible, and it takes two locks. First, IAM: roles that write the events table get dynamodb:PutItem — no UpdateItem, no DeleteItem. Second — the one everyone misses — a condition expression on every write: a plain PutItem on an existing key silently overwrites it, so "append-only" IAM still permits rewriting history one key at a time. ConditionExpression: attribute_not_exists(PK) closes the loophole: unique event IDs, insert-or-fail, never insert-or-replace.

# both locks, or it isn't append-only
IAM:  Allow dynamodb:PutItem on events-table        # no Update, no Delete
API:  PutItem { PK: "EVT#01J8ZK…", … }
      ConditionExpression: attribute_not_exists(PK)  # overwrite → error

Then get the events somewhere the application can't reach at all. DynamoDB Streams emits every write at the database layer — the application cannot skip it — into an archiver Lambda that batches events into the archive bucket. Two operational honesty notes: Streams orders changes per item, not across the table, so each event carries its own timestamp and sequence and those are the chronology, not stream order. And the stream is a 24-hour buffer, not a guarantee — an archiver broken longer than the retention window drops records into a silent gap. Alarm on the stream's iterator age, give the archiver an on-failure destination, and treat that alarm as a sev-2: it is the sound of history not being written.

The WORM archive and the separate watcher

The archiver lands events in the archive bucket with S3 Object Lock in compliance mode: write-once-read-many storage where, until the retention period ends, no identity — not an administrator, not the root account — can alter or delete an object. This is the property that upgrades your trail from "we log things" to "we can prove things": immutability enforced by the platform against everyone, including you. Respect its edge: compliance mode is genuinely irreversible — test retention settings on a scratch bucket first, because a misconfigured ten-year lock is a ten-year mistake. And set retention deliberately per record class: regulations set floors (keep at least N years), privacy law sets ceilings (don't keep personal data longer than needed), and the archive should carry references and event facts, not personal-data payloads (module 05's clause, paying off).

In parallel, CloudTrail ships the infrastructure layer to its own locked bucket — including any attempt to tamper with this very pipeline. Know its default honestly: management events (creating, configuring, deleting things) are on; data events — S3 object reads and writes, DynamoDB item operations — are not, and must be enabled explicitly for the buckets and tables that matter. "Who read the evidence file?" is only answerable if you turned that on before the question was asked.

Finally, separate the watcher from the watched: deliver CloudTrail and the archive to a dedicated logging account that workload operators can read but never write. A trail an administrator of the workload can edit is testimony; a trail in an account they cannot touch is evidence. Alarm on the meta-signals — trail configuration changes, Object Lock policy changes, denied writes against the archive — and when the auditor finally asks, Athena (module 07, reused whole) answers with SQL across the archive: every action on sub_84h2, everything one user touched in March, every permission change this quarter — minutes, not weeks.

The security layer: six moves

Look back at seven modules of security notes and see the pattern: serverless security is a small number of moves applied consistently. Under the shared-responsibility model (module 01), AWS handles the layers below you; what remains is configuration — who can do what, to which data, and how you'd know. Configuration is reviewable, diffable, and auditable in a way patch levels never were. The six moves:

MoveThe ruleWhere you practiced it
Identity is the perimeterOne role per function, scoped to its own resources and actions; the role is the blast radius.m3's roles, m5's source-scoped PutEvents, m6's conductor, m7's stages
Encrypt by defaultKMS on S3, DynamoDB, SQS, logs; a customer-managed key adds a second, independent lock for sensitive data.m4's queues, m7's buckets, m8's archive
Validate at every boundaryAuthN and schemas at the edge; re-validate at queue consumers, event handlers, file parsers. Trust is never transitive across an async hop.m3's gateway, m4's workers, m7's hostile files
Secrets are infrastructureSecrets Manager / Parameter Store, fetched by role at runtime — never code, config, or client JS. Rotation becomes an operation, not a migration.m2's no-secrets-in-JS, m3's server-side keys
Watch the watchersCloudTrail everywhere, delivered where the workload can't write; alarm on the control plane — policy changes, trail changes, denied-write spikes.m2's pipeline, m8's logging account
Hold less, deliberatelyThe cheapest data to secure is data you didn't keep: references in events and logs, retention per record class, ceilings as well as floors.m5's payloads, m8's archive

If you internalize one sentence from the entire guide, take this one: in serverless systems, the IAM policy review is the architecture review. The diagram tells you what the system is supposed to do; the roles tell you everything it can do. Keep the gap between those two as close to zero as you can — every permission no arrow uses is attack surface with no compensating feature.

Choosing well

Real systems compose these patterns rather than choosing one — Casework ended as a static portal (m2) calling an API (m3) that queues heavy work (m4), publishes events (m5) that start workflows (m6), over an ingestion pipeline (m7), with everything draining into the audit trail (m8). The skill is mapping each requirement to its shape:

When the requirement sounds like…Reach for
"Everyone sees the same content, fast, everywhere."02 · S3 + CloudFront
"A user asks a question and waits for the answer."03 · API GW → Lambda → DynamoDB
"The work takes a while, and we can't lose requests."04 · SQS + idempotent worker
"When X happens, several things should follow — and the list will grow."05 · EventBridge
"There's a defined process, with branches and an approver."06 · Step Functions
"Files come in; questions get asked about them later."07 · S3 → Lambda → Athena
"We need to prove who did what, when."08 · Append-only + WORM archive

Five honest gotchas, each the compressed form of a lesson above: the cost model inverts (billing alarms on day one; log ingestion is the classic surprise); everything has a limit (skim the quotas before the design review); idempotency is not optional (queues redeliver, events duplicate, retries retry — a handler unsafe to run twice is a latent bug); observability is a design input (correlation IDs and structured logs are decided in the design, not bolted on); and start boring — patterns 03 and 04 cover most internal tools, and every hop you add is latency, cost, and a new place for messages to get lost.

A last thought, the same one the original edition of this guide closed on, because it has not stopped being true. Diagrams like these are the easy part — any of them can be assembled in an afternoon. The durable work is in the requirements the boxes encode: what must never be lost, who must approve what, what you'd need to prove a year from now. Get those right, and the architecture mostly draws itself.

Concept index

Five jobs
Every system accepts requests, runs logic, stores state, moves messages, and keeps records — the decomposition that maps requirements to managed services.
Shared responsibility
AWS patches everything below the API surface; everything you can configure — identity, data, wiring — remains yours.
Scale-to-zero
The property that an idle system costs approximately nothing — and the same design survives a 1,000× traffic spike unchanged.
Service quotas
The numeric limits (durations, payloads, concurrency) that shape designs; read them before the design review, not during the incident.
Cold start
The extra latency (~100 ms–1 s) when a function that hasn't run recently is invoked; usually fine, occasionally load-bearing.
Origin Access Control
The grant that lets CloudFront read a private bucket — so nobody can bypass the CDN's TLS, WAF, and logging to hit S3 directly.
Cache invalidation
The deploy step that tells edge caches to refetch; paired with long-cached assets and no-cache HTML, it makes deploys instant and safe.
Immutable asset
A file whose name changes when its content does (app.3f2a.js) — safe to cache for a year because a new version is a new URL.
Authorizer
The API Gateway hook that validates identity before your code runs — unauthenticated requests never reach a function you pay for.
Access-pattern-first design
Writing down the exact queries before designing a DynamoDB table; the opposite of modeling entities and hoping.
The two-second rule
If a synchronous request does more than ~2 s of work, accept it, return 202 with a job ID, and finish asynchronously.
Decoupling
Letting the part that accepts work run at a different speed — and fail at different times — than the part that does the work.
At-least-once delivery
The only honest promise a distributed queue makes: messages arrive, occasionally more than once. Idempotency is the counterpart obligation.
Idempotent handler
A worker where processing a message twice equals processing it once — usually via conditional writes keyed on a stable ID.
Visibility timeout
The window during which an in-flight message is hidden from other consumers; misjudge it and messages process twice by design.
Dead-letter queue
Where a message lands after repeated failures. Unmonitored, it is where work goes to die; alarmed, it is your best triage signal.
Backpressure
What a queue makes visible: a backlog that drains smoothly instead of a wall of timeouts.
Event bus
The inversion of a queue: producers publish facts once, without knowing who's listening; rules route copies to every interested target.
Event schema
The shape of a published fact — a contract with consumers you don't know about; version it like an API.
Correlation ID
The token stamped on a request at the edge and propagated through every event, log, and hop — the only call stack a distributed system has.
Queue-per-consumer
Buffering each subscriber behind its own queue so one slow consumer can't drop everyone's events.
Archive & replay
The bus's retained history, replayable into a new consumer — or into a fixed one after a bug.
Orchestration
A central state machine that defines a process explicitly — visible, versioned, and recorded per execution.
Choreography
Services reacting to each other's events with no central definition — right for plumbing, wrong for processes that must be provable.
Callback token
The mechanism that lets a workflow pause for a human — for hours or days, costing nothing — until the token is returned.
Standard vs Express
Step Functions' two modes: long-lived, fully-recorded executions vs high-volume, short-lived ones. Pick deliberately.
Pre-signed URL
A time-limited grant to upload or download one object directly against S3 — bytes never transit your Lambda.
Raw / curated split
Two buckets: untouched originals in one, normalized queryable derivatives in the other. The original is evidence; never modify it.
Partition projection
Laying out S3 keys (year=2026/month=07/) so Athena scans only the slice a query needs — the difference between cents and hundreds of dollars.
Poison file
A malformed input that would stall the pipeline; isolate per-file failures to a DLQ carrying the object key.
Event sourcing (lite)
Recording the append-only sequence of facts rather than current state; state is derivable from events, never the reverse.
Condition expression
The DynamoDB clause (attribute_not_exists) that turns PutItem from 'write or silently overwrite' into 'append or fail loudly.'
Object Lock (WORM)
Write-once-read-many retention that even the root account cannot shorten in compliance mode — genuinely irreversible, so test on a scratch bucket.
Separation of watcher and watched
Shipping logs and archives to an account the workload's operators can read but never write; a trail an admin can edit is testimony, not evidence.
Iterator age
The stream-lag metric that tells you the archiver is falling behind — alarm on it, because the stream buffer expires in 24 hours.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.