CQRS with a static read side
Commands update PostgreSQL; a Lambda projector rewrites the affected JSON views in S3, and CloudFront serves every read without touching a database.
Click any box to open its page. Open it in the playground to rewire it, change the traffic or take parts down.
A CQRS app whose read models are files. Writes go through API Gateway to a command handler that checks the rules and saves the change in PostgreSQL, together with an event in an outbox table. Each event reaches a projector function, which works out which views the change touches, renders them as JSON and writes them to S3. Readers fetch those files through CloudFront, so a spike in traffic hits the CDN, not the database, and pages keep loading even while the database is down for maintenance. The price is a few seconds to a minute of staleness and a file rewrite for every view a change affects.
How it flows
- 1
A command changes the source of truth
A merchant’s
ChangePricerequest passes API Gateway’s authentication and throttling to a Lambda command handler. It checks the rules (the price is positive, the merchant owns the product) and, in one PostgreSQL transaction, updates theproductsrow and inserts aPriceChangedrow into anoutboxtable. Either both are saved or neither is, so no change can lose its event. - 2
The outbox feeds a queue
A small relay (a scheduled job polling the outbox, or change data capture) sends each new outbox row to SQS and marks it sent. A crash between sending and marking means the event goes out twice, so everything downstream must be idempotent.
- 3
The projector rewrites every affected view
Lambda polls SQS and hands the projector batches of events. For each one it re-reads the product’s current state from PostgreSQL, rather than trusting the event, so duplicates and out-of-order events still produce the right file. It renders
products/p1.jsonand the listing for the product’s category and writes each with onePutObject; S3 replaces an object atomically, so readers get the old file or the new one, never half. Files go up withCache-Control: max-age=60, or the projector invalidates the changed paths in CloudFront. - 4
Readers only ever fetch files
Product and category pages request their JSON from CloudFront. Most requests are edge cache hits; a miss fetches the object from S3. The page renders in the browser from that JSON, and nothing on the read path queries PostgreSQL, so read traffic adds no load to the database.
- 5
Rebuild everything when the view format changes
Adding a field to
products/*.jsonmeans re-rendering every file, not waiting for the next change. Run the same render code as a batch job over all products, writing the new files under a new prefix (v2/), then switch the pages to it. Old and new versions never mix in a cache, and switching back is a one-line change.
Why this shape works
- Reads scale with the CDN, not the database: a traffic spike costs bandwidth, and PostgreSQL only sees writes and projector reads
- Pages keep working while the write side is slow, down or being migrated; only freshness suffers
- Every view is a file you can open, diff and cache, and a broken view can be fixed by re-rendering it
- Each piece is managed and billed per use: no read replicas or cache cluster to size
Where it strains
- Staleness adds up: relay delay, queue wait, render time and up to
max-ageat the edge. Measure the age of each file against its source and alert when it passes your budget, because a lost event leaves a view silently stale - Fan-out: if one change appears on the product page, two category listings and 30 search pages, every write becomes 34 renders and uploads. Keep views coarse, and serve search and per-user pages from an API instead
- Invalidation costs money at scale: CloudFront’s first 1,000 invalidation paths a month are free and each further path is charged. Prefer short
max-age, wildcard paths, cache tags (one tag per product) or versioned file names over invalidating thousands of paths - Users don’t see their own write right away. After a merchant saves a price, show the value the command returned, not the file, or the page will look like the save failed
Common swaps
For internal dashboards and reports, skip the event pipeline: Observable Framework, or a legacy Evidence project, runs its queries at build time and a GitHub Actions schedule rebuilds and redeploys the static site every hour or day. Current Evidence queries live instead.
On Cloudflare, write the views to R2 and serve them through Cloudflare’s CDN, and R2 charges nothing for egress.
Built from
Amazon API GatewayAWSAPI gatewaysAWS’s managed API front door: REST, HTTP and WebSocket APIs that authenticate, throttle and route calls to Lambda and other backends.
AWS LambdaAWSServerless functions (FaaS)AWS’s functions service: upload a handler, connect a trigger, and pay per request and per millisecond.
Amazon SQSAWSMessage queuesAWS’s fully managed message queue: no brokers to run, billed per request.
Amazon S3AWSObject storageAWS’s object storage, and the de facto API most object stores copy.
Amazon CloudFrontAWSCDNsAWS’s CDN: more than 750 edge locations that cache your S3 files and front your load balancers and APIs.Architectures with parts in common
Sources
- Azure Architecture Center: CQRS pattern (outbox, idempotent read-model consumers)
- Azure Architecture Center: Materialized View pattern (refresh recency alerts, versioned replacement)
- Amazon S3 User Guide: data consistency model (atomic single-key updates)
- Amazon CloudFront Developer Guide: Pay for file invalidation (1,000 free paths a month, wildcards)
- Amazon CloudFront Developer Guide: Invalidating content by cache tags
- Amazon CloudFront Developer Guide: Invalidate files (versioned file names recommended for frequent updates)
- Observable Framework docs: Deploying (scheduled builds with GitHub Actions)