Event sourcing on AWS serverless, part 3: integration events, closed accounts, and the trade-offs
Publishing events for other systems without coupling the write path, why aggregates get closed instead of deleted, an EventBridge failure mode that hides in a 200, and where this architecture costs you.
Part 3 of a three-part series on event sourcing and CQRS with AWS serverless, grounded in appsync-es. Part 1 built the write model; part 2 built the read model and explained the lag between them. This part is about everything outside the service - and about when you shouldn’t build any of this.
Sooner or later someone asks for the feature that isn’t a read or a write: “can we email the customer when a large withdrawal happens?” In a CRUD codebase this request goes badly. You find every code path that writes a withdrawal, add a notification call to each, and hope nobody adds a new path next quarter without remembering the email. The write logic and the reaction to it are now welded together, and every new reaction makes the weld bigger.
An event-sourced system has a better answer sitting in plain sight: the facts are already flowing. Every FundsWithdrawn is already on a stream, already passing through the projector. All the notification feature needs is a way to subscribe - which is one EventBridge rule, zero changes to the write path. This post is about that boundary: what events become when they leave the service that minted them, plus the two topics I owe you from parts 1 and 2 - deletion, and the honest trade-off ledger.
Domain events and integration events are different things
Even when they look identical.
A domain event is internal vocabulary. FundsDeposited as defined in domain/events.ts is shaped for one purpose: being folded by apply. Its fields are exactly what the reducer needs, and its shape is coupled to a switch statement the same team owns. If next sprint’s refactor renames a field, the fold and the event change together in one commit and nobody outside notices.
An integration event is a published contract. The moment an event leaves your service - onto a bus where other teams’ consumers read it - its shape stops being yours to change freely. Renaming that field now breaks the notifications service, the analytics pipeline, and a consumer you didn’t know existed.
This repo publishes the domain event as the integration event, byte for byte - same payload, serialized into an EventBridge entry. For a reference implementation that’s the right simplification, but the concepts stay separate, and the code keeps a specific seam where they’d diverge: the projector’s publish step. When internal shapes need to evolve independently of the published contract, that’s where an explicit mapping goes - an integration schema, versioned separately, translated from whatever the fold currently uses. Knowing where the seam is matters more than using it on day one.
Publish from the projector, never the command path
Where the publish happens is a design decision with teeth. The obvious spot is the command resolver - it just appended the event, it has it in hand. This repo deliberately doesn’t do that. The write path’s only job is to make the write durable and correct; the moment it also publishes, its latency and failure modes are coupled to EventBridge’s, and “the deposit succeeded but the publish threw” becomes a state your mutation handler has to reason about.
Instead the projector - already listening to the stream, already downstream of the durable write - does the publishing, right after it upserts views:
const result = await client.send(
new PutEventsCommand({
Entries: events.map((e) => ({
EventBusName: EVENT_BUS_NAME,
Source: EVENT_SOURCE, // 'bankaccount.events'
DetailType: e.type, // 'FundsWithdrawn', 'AccountOpened', ...
Detail: JSON.stringify(e),
})),
}),
);
The ordering guarantee this buys is subtle and useful: anything a consumer hears about is already durably in the event store, because the stream only carries committed writes. The write path stays ignorant of who subscribes - which is the property that made the notification feature a one-rule change.
The failure that hides inside a 200
PutEvents has a trap that belongs in every serverless developer’s pocket: it can return HTTP 200 - no exception, nothing thrown - while individual entries in the batch failed. The only evidence is FailedEntryCount in the response body. Skip the check and partial failures vanish silently; your consumers just never hear about some events, and nothing anywhere logs an error.
if (result.FailedEntryCount && result.FailedEntryCount > 0) {
const failed = (result.Entries ?? []).filter((entry) => entry.ErrorCode);
logger.error("EventBridge rejected some events", {
failedEntryCount: result.FailedEntryCount,
errorCodes: failed.map((entry) => entry.ErrorCode),
});
}
Notice what the projector does with a partial failure: it logs and does not throw. Throwing feels more rigorous but is actually wrong here - a throw makes Lambda retry the whole stream batch, which re-publishes the entries that already succeeded, and now every downstream consumer needs to handle duplicates it didn’t need to. A production system would dead-letter the failed entries or retry only those; the point this repo makes is narrower and worth making: inspect the response, because the API will not raise your problem for you.
Consumers are rules
The receiving end, in lib/constructs/integration.ts, is an EventBridge bus (BankAccountEvents) and one rule matching source: ["bankaccount.events"], targeting the audit consumer - a Lambda that formats each event as a ledger line and logs it:
case 'FundsWithdrawn':
return `[${event.timestamp}] account ${event.id} withdrew ${event.amount} (v${event.version})`;
Deliberately boring, and a stand-in for anything: a notifications service, an analytics firehose, another bounded context entirely. The wiring is the lesson:
That large-withdrawal email? A new rule with a narrower pattern - match DetailType: "FundsWithdrawn", add a numeric filter on the amount inside Detail - targeting a new Lambda. The command resolver doesn’t change. The projector doesn’t change. Existing consumers don’t change. Compare that against the CRUD version of the same feature and this is the strongest practical argument for routing integration through a bus instead of direct service-to-service calls.
You don’t delete - you close
Here’s a question that sounds administrative and turns out to be philosophical: how do you delete an account from a system whose entire premise is that facts are never erased?
You don’t. Deletion destroys history, which is the one thing this architecture exists to prevent. Instead you record one more fact - the account closed - and make the aggregate refuse everything afterward. From domain/commands.ts:
export const closeAccount = (
state: AccountState,
p: { now: string },
): AccountClosed => {
if (state.status === "CLOSED") {
throw new InvariantViolation("Account is already closed");
}
if (state.balance !== 0) {
throw new InvariantViolation(
`Cannot close an account with a non-zero balance (${state.balance}); withdraw the funds first`,
);
}
return {
type: "AccountClosed",
id: state.id,
version: state.version + 1,
timestamp: p.now,
};
};
AccountClosed carries no payload - closing is a terminal fact, nothing more. The fold flips status to CLOSED, and from then on every command’s assertOpen guard rejects deposits, withdrawals, and second closes. The account is done, but its history is intact: the audit trail survives, replays still work, and “what did this account do before it closed” remains answerable forever.
The generalization: lifecycle transitions - close, cancel, archive, deactivate - are just more events. Any time a design has you reaching for DELETE, the event-sourced question is “what fact actually occurred?” Something did; record that instead.
One honest caveat: “never erase” collides with privacy law. If your events carry personal data, right-to-erasure requests need a real strategy - crypto-shredding (encrypt each person’s data with a per-person key, destroy the key on request) is the usual answer. This repo’s events carry an email address and doesn’t implement any of that; production systems handling real PII must.
The trade-off ledger
A reference implementation owes you its regrets. The repo’s ARCHITECTURE.md documents alternatives for every major choice; here are the ones worth internalizing.
One table vs. two. Events, snapshots, and views share a table here, split by sort-key prefix. Two tables - an event store and a view table - would make part 2’s self-loop trap structurally impossible (nobody consumes the view table’s stream), scale read and write capacity independently, and give the query Lambda IAM access to a table that physically contains nothing but views. Single-table wins on fewer resources and one stream. This repo chose single-table partly because the self-loop trap is worth learning to recognize; in production I’d weigh the two-table variant seriously.
One projector vs. split projector and publisher. The projector does two jobs - upsert views, publish to EventBridge - so an EventBridge outage and a view backlog share a failure domain. Splitting them into two stream consumers isolates the failures but spends a hard budget: DynamoDB Streams supports roughly two concurrent consumers per shard before throttling. This repo spends one slot on one logical concern (“react to new events”) and keeps one in reserve.
Materialized view vs. fold-on-read. Skipping the read model entirely - getAccount calls loadAccount, always fresh, no projector, no lag - is legitimate for low-read systems, and it’s the strongly-consistent escape hatch from part 2. It stops working the moment you need listAccounts, and it makes every read’s cost scale with write history. The view exists so reads are O(1); the price is the consistency window.
Snapshot cadence. Every 10 events in dev, every 50 in prod - treat both as defaults to tune. The knob trades snapshot-write cost against replay length on every load, and the right value depends on each aggregate’s write/read ratio. What matters is that it lives in constants.ts as a named constant with the trade-off documented beside it, where a code review can question it.
When not to event source
The honest section. This architecture replaced one table and some UPDATEs with an event log, a fold, snapshots, a retry loop, a stream, a projector, idempotency guards, a bus, and a consistency window you have to explain to every new teammate. That’s a real complexity bill, and it’s worth paying when audit history is a requirement rather than a nice-to-have, when “how did we get here” is a question your domain actually asks, when multiple read shapes or downstream consumers need to derive from one source of truth, or when temporal queries are product features.
A settings page does not need event sourcing. A CRUD app whose history nobody will ever query does not need event sourcing. If current state is genuinely all the domain cares about, a plain table with plain updates is the better engineering choice, and there’s no shame in picking it. Event sourcing is a tool for domains where the history is the data - money, inventory, orders, anything contested or audited - and a tax everywhere else.
What I’d tell someone starting today
Five rules, each earned by a specific mechanism in this series:
- Keep the fold pure. No I/O, no clock, no SDK in
applyor the decide functions. Testability is the first thing event sourcing gives you; don’t hand it back. - Make every projection idempotent. Streams deliver at least once and retries re-deliver. The version guard is cheap; a view that drifts is not.
- Know what triggers what. If a Lambda writes into a table whose stream invokes it, you are one weakened filter away from a self-invoking money furnace that passes all its tests.
- Read the response, not just the status.
PutEventsfails quietly, per entry, inside a 200. - Name every number.
SNAPSHOT_FREQUENCY = 10with a comment about the trade-off invites tuning; a bare10in a handler invites archaeology.
Then clone the repo, deploy it, and break it. Try the overdraft and read the InvariantViolation. Race two withdrawals. Query right after a deposit and catch the stale view. Close an account and watch it refuse you. The README has a walkthrough for each, and the comments in the code carry the why. The pattern makes sense from the inside in a way no series of posts - including this one - can fully deliver from the outside.