CQRS on AWS serverless: SAM for writes, Amplify Gen2 for reads
Most systems store what things are. This one stores what happened, and derives everything else. Here is why that turned out to be worth the trouble, what the design looks like before any cloud is involved, and how it maps onto AWS serverless — SAM owning the write side, Amplify Gen2 owning the read side, and DynamoDB underneath both.
The problem
Start with the ordinary version. A blog post has a row. Someone edits the title, and UPDATE posts SET title = ... runs. The new title is now true and the old one is gone.
That is fine until you need to answer a question the row cannot answer:
- When was this scheduled, and by whom, and was it rescheduled before it went out?
- This slug collided last Tuesday. What was the sequence that produced it?
- The listing page shows a post that should still be hidden. Was the write wrong, or the read?
None of those are exotic. They are the questions you ask the first time something looks wrong in production, and a schema of current state has thrown the evidence away. You can bolt on an audit table, but now you have two sources of truth that drift, and the audit table is the one nobody tests.
There is a second problem, and it is the one that actually shapes the architecture. Writes and reads want opposite things.
A write wants to be correct. It needs the current state of exactly one thing, it needs to check invariants against that state, and it needs to refuse if they do not hold. Two posts must not share a slug. A card's rank must not collide with another card's. Correctness here is narrow, transactional, and it does not care about performance.
A read wants to be fast and shaped for the screen. The blog listing wants published posts, newest first, filtered by tag, paginated — a shape no aggregate has, spanning many entities. It does not need to be transactionally consistent with anything. It needs to render in milliseconds.
Serve both from one model and you compromise both: normalised tables that need six joins to render a page, or denormalised tables that make invariants impossible to enforce. The usual escape is a cache, which is a read model with no name, no schema, and no story about how it gets rebuilt when it is wrong.
What CQRS actually says
Command Query Responsibility Segregation is a smaller idea than the acronym suggests: the model you write through and the model you read from do not have to be the same model.
That is it. It does not require event sourcing, message buses, or eventual consistency. It says the write side and the read side are separate responsibilities, and you are allowed to design each for its own job.
Event sourcing is a separate decision that pairs naturally with it: store the events, derive the state. Instead of a row you mutate, you keep an append-only sequence — PostDrafted, PostEdited, PostScheduled, PostPublished — and compute current state by folding that sequence.
Put together, the write path becomes:

Three properties fall out, and they are the reason to bother:
The audit trail is the data. Not a parallel table that might disagree — the events are the system of record. "What happened" is not reconstructed; it is read.
Invariants live in one place. handle() is the only function that decides whether a command is legal. It sees the folded state of one aggregate and nothing else. That is a very small surface to get right, and a very easy one to test — no database, no mocks, just events in and a decision out.
Read models are disposable. A read model is a projection of the event log. If it is wrong, or you want a new shape, you delete it and replay. That is a genuinely different relationship with your query layer than "the table is the truth and I hope the migration was correct."
What it costs
Being honest about the price, because it is real:
The read model is eventually consistent. The event is durable before the projection runs. There is a window — usually milliseconds — where the write succeeded and the read has not caught up. Your UI must have an answer for that, and "it'll be fine" is not one.
Projections can fail after the event is durable. This is the failure mode people underestimate. The command succeeded. The event is committed. The projection threw. Now the log and the read model disagree, permanently, until something repairs it. If your design has no repair path, it has a silent-corruption path.
You cannot query across aggregates on the write side. No SELECT * FROM posts WHERE tag = .... Aggregates load one at a time by id. Anything set-wide — uniqueness, ordering across entities — needs a deliberate mechanism.
More moving parts. Two models, a projection step, a repair channel. For a CRUD form over a handful of fields, this is a bad trade. For a domain where what happened matters, it pays.
The design, before any cloud
Worth drawing tech-agnostically first, because the cloud version is a mapping of this and nothing more:

Two design choices in that picture are worth defending, because both were contested during planning.
The projection runs synchronously, inside the command. The alternative — publish to a queue, project asynchronously — is more "correct" in a purist sense and considerably worse to use. Synchronous projection means read-your-writes works on the command path: an author who publishes a post and is redirected to the listing sees it there. That is worth a great deal of user-visible quality.
The repair channel exists anyway, and is not decoration. Synchronous projection is best-effort: the event is durable first, so a projection failure leaves the read model stale. So the event stream also drives an asynchronous repair path that re-derives the view. The synchronous path is the fast path; the repair channel is what makes convergence a property of the system rather than a hope.
Mapping it onto AWS
Now the concrete version. The constraint that shaped every choice: pay only for what is used. No always-on compute, no NAT gateway, no idle database. A personal site that nobody reads for a week should cost approximately nothing for that week.
That single constraint eliminated the obvious topologies — an EC2 box running the app, a managed Postgres, a load balancer — and forced everything into per-request billing.
The result splits along the CQRS seam, and this is the part worth internalising: the write model and the read model are owned by two different infrastructure tools.

They share exactly one thing: a single Cognito user pool. SAM creates it; Amplify references it rather than creating a second one, reading its ids from SSM parameters the SAM stack publishes. That matters more than it sounds — two pools would mean a signed-in user holds a token from one identity source that the other does not accept.
The write side: SAM, Rust, DynamoDB
The command API is a single ARM64 Lambda. Not one Lambda per route — one Lambda holding the whole axum router, adapted by lambda_http. Fewer cold starts, one deployment artefact, and the routing logic stays ordinary code rather than becoming infrastructure configuration.
The event store is DynamoDB with a deliberately boring key schema:
| attribute | type | |
|---|---|---|
| partition key | AggregateTypeAndId | S |
| sort key | AggregateIdSequence | N |
One aggregate is one partition; its events are ordered by sequence within it. Loading an aggregate is a single Query. Appending is a TransactWriteItems with a condition that the next sequence number does not already exist — which is how optimistic concurrency is enforced without a lock.
The domain layer is cqrs-es, and the aggregates are plain Rust:
impl Aggregate for BlogPost {
type Command = BlogPostCommand;
type Event = BlogPostEvent;
type Error = BlogPostError;
async fn handle(&self, command: Self::Command, services: &Self::Services)
-> Result<Vec<Self::Event>, Self::Error>
{
match command {
BlogPostCommand::Publish { at } => {
if self.body.trim().is_empty() {
return Err(BlogPostError::CannotPublishEmptyBody);
}
Ok(vec![BlogPostEvent::Published { at }])
}
// ...
}
}
fn apply(&mut self, event: Self::Event) { /* fold */ }
}
That is the whole write model, and note what is not in it: no SQL, no DynamoDB, no AWS. handle sees folded state and returns events or an error. It is testable with no infrastructure at all, which is why the domain suite runs in milliseconds and did not change by a single line when the storage moved from Postgres to DynamoDB — the storage adapter changed, the domain did not.
Two things needed real design rather than translation:
Set-wide uniqueness. Aggregates load one at a time, so "no two posts share a slug" cannot be answered by an aggregate. It becomes a reservation item: a separate DynamoDB row keyed by the slug, written with a condition expression that fails if it exists. The conditional write is the uniqueness check. It is more direct than a unique index — the reservation is a fact in the log, not a side effect of a schema.
The 25-event ceiling. TransactWriteItems is capped at 25 items, so a single command cannot emit more than 25 events. That is not a limit you want to discover in production, so it is verified unreachable rather than assumed.
The projection seam
The interesting part is how the write side hands off to the read side, because the obvious approach is wrong in a way that takes a while to notice.
cqrs-es exposes a ViewRepository trait — three methods — which is the seam where projections land. The stock DynamoDB implementation stores a view as a serialised blob in one attribute. That works fine if you read views by primary key. It is useless if a GraphQL layer needs to filter, sort or index on the fields inside the view, because to DynamoDB they do not exist.
So the projection writes flat, per-field attributes instead:
impl ViewRepository<PublishedPostView, BlogPost> for AmplifyViewRepository {
async fn update_view(&self, view: PublishedPostView, context: ViewContext)
-> Result<(), PersistenceError>
{
// serde_dynamo → one attribute per field, not one blob
let item = serde_dynamo::to_item(&view)?;
self.client.put_item()
.table_name(&self.table)
.set_item(Some(item))
.send().await?;
Ok(())
}
}
Those tables are the Amplify Gen2 models. The Rust struct and the TypeScript a.model() describe the same physical items from two directions — which is powerful and is also the single most dangerous coupling in the system. More on that below.
The read side: Amplify Gen2 and AppSync
The read model is declared in TypeScript:
PublishedPost: a.model({
post_id: a.id().required(),
slug: a.string().required(),
title: a.string(),
effective_instant: a.string().required(),
visibility_partition: a.string(),
// ...
}).authorization((allow) => [allow.group('owner')]),
Amplify generates the AppSync API, the resolvers and the typed client. The UI queries GraphQL directly — there is no REST read layer, and no backend-for-frontend hop.
Public reads need care, because Amplify's model-level authorisation is all-or-nothing per model. There is no way to express "guests see published posts, the owner sees drafts too" on one model. So every model is owner-only, and public access goes through custom queries with hand-written resolvers:
// APPSYNC_JS resolver: the cutoff is computed server-side, never from the client
export function request(ctx) {
const cutoff = padCutoffToCanonicalWidth(util.time.nowISO8601());
return {
operation: 'Query',
index: 'publishedPostsByVisibility_partitionAndEffective_instant',
query: visibilityKeyCondition(cutoff),
scanIndexForward: false,
limit: ctx.args.limit ?? 20,
};
}
The visibility boundary is util.time.nowISO8601() — the server's clock, inside the resolver. A client cannot pass a future timestamp and read scheduled posts early, because the client never supplies the cutoff at all.
The repair channel
The synchronous projection is best-effort by construction, so convergence needs its own path:
mimi-events ──Streams──▶ EventBridge Pipe ──▶ custom bus ──▶ repair Lambda
(NEW_IMAGE) filter: INSERT re-derive view
The Pipe reads the table's stream, filters to inserts, and puts events on a custom bus that triggers a repair Lambda which re-derives the affected view from the log. It costs nothing while idle, and it is the difference between "the read model is usually right" and "the read model converges."
Lessons and gotchas — what to plan for
If you are planning this, these are the things worth deciding before you start, because each one cost real time to discover.
Two IaC planes need an explicit contract. The moment your write and read models are owned by different tools, you have an integration boundary. Decide early what carries it. Here it is SSM parameters: SAM publishes the Cognito ids, Amplify reads them. Anything implicit becomes a deploy-order dependency you rediscover at the worst time.
Schema drift between the planes shows up as nulls, not errors. This is the one to be most afraid of. The Rust view struct and the TypeScript a.model() describe the same items. Add a field on one side only and nothing fails: writes succeed, reads return null. No exception, no alarm — just quietly missing data. Budget for a CI gate that diffs the two, and treat it as a condition of the design rather than a nice-to-have.
Decide your projection failure policy on day one. Synchronous projection will fail sometimes, after the event is durable. Either you have a repair path or you have silent divergence. Choosing "we'll add it later" means choosing divergence in the interim.
Managed runtimes are language subsets. AppSync's JS resolver runtime is not Node. It resolves no local modules, forbids throw outright, and rejects exported non-handler functions. CloudFormation reports all of that as The code contains one or more errors — no file, no line. aws appsync evaluate-code names the actual cause and turns ten-minute deploy-time rollbacks into seconds. Find the equivalent evaluate API for whatever managed runtime you are using, before you need it.
Build-time and runtime environments are different environments. Next.js inlines process.env.NEXT_PUBLIC_FOO at build — but only as a static member expression. A helper doing process.env[name] is never inlined, so the value must exist at runtime, and on serverless SSR it does not: platform environment variables reach the build container, not the request Lambda. Two adjacent lines can behave completely differently. Assume nothing about which of your reads survives the bundler.
Test identities are production identities if they share a pool. One shared Cognito pool is the right call for a single-owner app. It also means your e2e harness creates its users in the production pool. Generate their passwords per run and delete them in teardown — never commit a default, because a committed test password for an account in the admin group is a committed production credential.
Pin the whole toolchain, not just the runtime. "Use the latest Node" is not a policy. A build tool pinned to an old transitive dependency may simply not work on a newer runtime, and the error will not mention the version. Decide the version per tool, and verify by running it.
The first deploy is a test suite you cannot run any other way. Every stage of a pipeline that has never executed contains code that has never executed. Expect a defect per stage — not because the work was careless, but because nothing had exercised it. Optimise for cycle time and diagnosis: evidence you can query, assertions that name the missing thing, and artefacts that prove which run produced them. Guessing costs more than measuring.
Assertions must verify which artefact they are looking at. The subtlest failures in this build were not wrong logic — they were correct checks pointed at the wrong thing. A CLI that exited 0 having deployed nothing. A stale outputs file that satisfied a validity check while describing a backend that no longer existed. A server left running on a port, serving an old build to a suite that believed it was testing the new one. Each looked like a product bug and was not.
Where it lands
The domain layer is around 7,600 lines of tests over four aggregates, and it did not change when the storage moved from Postgres to DynamoDB — because handle() never knew what a database was. That is the payoff of the seam, and it is the thing I would keep if I could keep only one part of this design.
The rest is mapping: SAM owns commands and the log, Amplify owns queries and the schema, DynamoDB holds both, and a stream-driven repair channel makes the gap between them converge rather than accumulate.
It costs close to nothing when nobody is reading. Which, for a personal site, is most of the time.