Skip to content

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):

DELETE FROM {schema}.idempotency_records WHERE expiry_time < now();

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