ADR-066: Postgres-backed idempotency store via Powertools BasePersistenceLayer¶
| Status | Accepted |
| Date | 2026-05-17 |
| Deciders | CTO, Head of Platform Engineering |
| Affects repos | bank-core, bank-aml, bank-payments, bank-credit, bank-kyc, bank-app |
Status: Accepted — 2026-05-17
Context¶
AWS Lambda Powertools for TypeScript provides a makeIdempotent helper that
deduplicates identical requests using a configurable persistence layer. The
library ships DynamoDBPersistenceLayer as its default implementation.
During bank-aml's build of MOD-018 (alert case management), the SD agent
escalated a planning snag: makeIdempotent(handler, ...) wraps the entire
Lambda handler and cannot selectively apply to one route in a multi-route
Lambda. The resolution options surfaced a deeper question — DynamoDB was
about to enter the bank's operational estate as an undeclared dependency of
a library default, not as an explicit architectural decision.
ADR-064 establishes the consolidated bank Neon database as the single
operational store for all domains. DynamoDB is not provisioned in that model.
Permitting it via a Powertools default would introduce a second stateful
technology with its own provisioning, IAM surface, capacity planning, cost
model, and operational burden — for a deduplication key-value lookup.
Decision¶
All idempotency persistence in the bank uses a PostgresPersistenceLayer
that implements Powertools' BasePersistenceLayer interface and is backed
by the consolidated bank Neon database. DynamoDB is not used for
idempotency at any layer.
The shared implementation lives in @bank-core/idempotency (a pnpm
workspace package in the bank-core repo), available to all SD repos via
the standard cross-repo workspace package convention established in the
shared library standard.
Rationale¶
Performance. Every Lambda that needs idempotency is already connected
to Postgres via the pooled DATABASE_URL injected at deploy time (ADR-064).
The idempotency check reuses that connection — no additional network hop.
DynamoDB requires a separate HTTP call to a distinct service endpoint,
adding 5–15 ms to the critical path on every idempotent request. An indexed
primary-key lookup + upsert against Postgres completes in 1–3 ms on the
same connection.
Technology consolidation. One data technology in the operational estate is simpler to monitor, audit, and operate than two. Postgres already handles transactional state for every domain; idempotency records are a natural extension of that model.
TTL. DynamoDB's native TTL is the only operational advantage it holds
over Postgres for this use case. It is replaced by a periodic
DELETE WHERE expires_at < now() query — trivial at the write frequencies
of any use case that requires idempotency (case creation, payment
initiation, loan application intake).
Library contract. Powertools' BasePersistenceLayer is a first-class
published interface, not an internal API. Implementing it keeps the caller
surface identical (makeIdempotent(fn, { persistenceStore })) and means
no Powertools-specific knowledge leaks into module code.
Implementation¶
Shared package — @bank-core/idempotency¶
Owned by bank-core. Exports:
import { PostgresPersistenceLayer } from '@bank-core/idempotency';
const persistenceStore = new PostgresPersistenceLayer({
pool, // pg Pool from @bank-core/db
tableName, // e.g. 'aml.idempotency_records'
expiresAfterSeconds: 86_400, // default 24h, override per call-site
});
PostgresPersistenceLayer implements _putRecord, _getRecord,
_updateRecord, _deleteRecord using standard pg queries against
a Flyway-managed table.
Table DDL (per consuming module's schema)¶
Each SD that uses idempotency owns its own table in its own schema, added via a Flyway migration:
CREATE TABLE IF NOT EXISTS {schema}.idempotency_records (
idempotency_key TEXT PRIMARY KEY,
status TEXT NOT NULL, -- INPROGRESS | COMPLETED | EXPIRED
response_data JSONB,
expiry_time TIMESTAMPTZ NOT NULL,
in_progress_expiry TIMESTAMPTZ,
payload_hash TEXT
);
CREATE INDEX ON {schema}.idempotency_records (expiry_time)
WHERE status != 'EXPIRED';
TTL cleanup runs as a scheduled Lambda (or appended to any existing maintenance job in the schema):
Applying idempotency to a single route in a multi-route Lambda¶
makeIdempotent wraps any async function, not only the Lambda handler
entry point. Apply it to the route handler function, not the outer handler:
const createCaseIdempotent = makeIdempotent(
async (event: APIGatewayProxyEvent) => {
// POST /cases logic
},
{ persistenceStore, eventKeyJmespath: 'body.clientToken' }
);
// In the router:
case 'POST /cases':
return await createCaseIdempotent(event);
This pattern requires no Lambda-per-route refactor.
Consequences¶
Positive - No DynamoDB provisioning, IAM policies, or capacity management required. - Idempotency records are co-located with domain data — same backup, same monitoring, same connection. - Caller code is identical to the DynamoDB path from a Powertools API perspective. - Performance is equal to or better than DynamoDB for co-located Lambda use cases.
Negative / trade-offs
- @bank-core/idempotency must be built and published before any
consuming SD can adopt it. bank-aml is the first consumer and is
blocked until it lands.
- Each consuming SD must add a Flyway migration for the idempotency table
in its schema. This is a one-time, low-risk migration.
- TTL is not automatic; a cleanup job is required per schema. At expected
write volumes this is a negligible operational burden.
Affected modules¶
| SD | Module(s) | Write surface |
|---|---|---|
| SD03 — AML | MOD-018 | POST /cases (case creation) |
| SD04 — Payments | MOD-020, MOD-021 | Payment initiation |
| SD05 — Credit | Loan application intake | Application submission |
| SD01 — Core | MOD-001 | Posting engine (when API-surfaced) |
| SD02 — KYC | Account-opening modules | Submission endpoints |
All ADRs
Compiled 2026-05-22 from source/entities/adrs/ADR-066.yaml