Module shared library standard¶
Every Lambda module in every repo shares infrastructure code through workspace packages — it does not copy it. This standard defines what those packages are, what each is responsible for, and what individual modules must not define themselves.
The problem¶
Every module in every repo independently defines its own copies of database connection pool management, event publishing, error classification, structured logging, and idempotency enforcement. The same 300–500 lines appear across every module in bank-app, bank-kyc, bank-credit, bank-payments, and bank-core. A pool timeout change requires updating 25 files. A log format change requires updating every module independently. This is a maintenance liability and the source of divergent behaviour across modules.
Physical location¶
Packages live in a packages/ directory at the repo root, alongside the module
directories. Each repo is a Node.js monorepo using npm or pnpm workspaces.
bank-credit/
packages/
db/ ← @bank-credit/db
events/ ← @bank-credit/events
errors/ ← @bank-credit/errors
observability/ ← @bank-credit/observability
idempotency/ ← @bank-credit/idempotency
MOD-027-affordability-calculator/
package.json ← { "dependencies": { "@bank-credit/db": "workspace:*" } }
MOD-128-credit-bureau-enquiry/
...
package.json ← { "workspaces": ["packages/*", "MOD-*"] }
The workspace:* protocol is resolved locally by the package manager — no registry
needed. Packages within a repo reference each other as local dependencies.
Two-tier sharing model¶
The five packages are not all the same kind of thing. Two are repo-specific; three are generic across every repo.
Tier 1 — repo-specific packages (live per-repo, always)¶
{repo}/db and {repo}/events stay per-repo even when cross-repo sharing
exists. db is parameterised by DATABASE_URL and pool configuration that may
legitimately differ between repos. events carries the domain bus name which is
repo-specific. These packages are small and the per-repo duplication is acceptable.
Tier 2 — generic packages (per-repo now; shared package later)¶
{repo}/errors, {repo}/observability, and {repo}/idempotency contain
no repo-specific logic. The ErrorKind enum, Powertools Logger configuration, and
Powertools Idempotency setup are identical across bank-app, bank-kyc, bank-credit,
bank-payments, and bank-core.
These are candidates for extraction to a single @totara-bank/lambda-core package
published from bank-platform to the GitLab Package Registry (npm scope). When that
package exists:
- Per-repo
errors,observability, andidempotencypackages become thin wrappers that re-export from@totara-bank/lambda-core, or are removed entirely. - A change to the
ErrorKindenum or Powertools version lands in one place and propagates to all repos via a dependency update. - GitLab Package Registry requires no new infrastructure — it is available on the existing GitLab plan and the publish step is a single CI job in bank-platform.
This extraction is not a prerequisite for the within-repo migration. Per-repo
packages deliver the immediate value (eliminating 169 duplicate files). The
@totara-bank/lambda-core extraction follows when the per-repo packages have
stabilised and the shared interface is clear.
Required workspace packages¶
Each repo exposes a packages/ workspace with the following packages.
Package names are repo-scoped: @bank-app/db, @bank-kyc/events, etc.
{repo}/db¶
Manages the single Postgres connection to the bank database (ADR-064).
- Exports
getPool(): Pool— singleton PgBouncer-pooled connection - Reads
DATABASE_URLfrom environment (injected at deploy time — see below) - Configures pool size, idle timeout, and error handling in one place
- Exports
withTransaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T>
Modules must not create their own Pool instances. All queries go through this package.
{repo}/events¶
Publishes domain events to EventBridge.
- Exports
publishEvent(bus: string, event: DomainEvent): Promise<void> - Validates
event_id,idempotency_key,schema_versionare present before publishing - Handles transient EventBridge failures with exponential backoff (max 3 attempts)
- Singleton EventBridge client — initialised once per container
{repo}/errors¶
Shared error taxonomy.
- Exports
ErrorKindenum:VALIDATION_FAILURE | COMPLIANCE_BLOCK | TRANSIENT_INFRA | PROVIDER_ERROR | NOT_FOUND - Exports
ClassifiedError extends Errorwith akind: ErrorKindfield - Exports
isRetryable(kind: ErrorKind): boolean - Exports
httpStatusFor(kind: ErrorKind): number
{repo}/observability¶
Structured logging and CloudWatch EMF metrics.
Use AWS Lambda Powertools (@aws-lambda-powertools/logger,
@aws-lambda-powertools/metrics) directly — do not wrap them in a custom class.
Powertools handles structured JSON output, cold-start context injection, and X-Ray
correlation automatically.
Configure once at the package level:
import { Logger } from '@aws-lambda-powertools/logger';
export const logger = new Logger({ serviceName: process.env.MODULE_ID });
Modules import logger from this package. They do not instantiate their own Logger.
{repo}/idempotency¶
Idempotency enforcement.
Use AWS Lambda Powertools Idempotency (@aws-lambda-powertools/idempotency) —
do not implement a custom PostgresIdempotencyStore. Powertools supports Postgres
persistence and handles TTL and cleanup automatically.
What modules must not define¶
A module is non-compliant if it contains any of the following files:
| Banned file | Replace with |
|---|---|
src/lib/db.ts — creates a new Pool() |
@{repo}/db |
src/lib/neon-connection.ts |
@{repo}/db |
src/lib/event-publisher.ts — creates an EventBridgeClient |
@{repo}/events |
src/lib/eventbridge-client.ts |
@{repo}/events |
src/lib/errors.ts — defines ErrorKind enum |
@{repo}/errors |
src/lib/observability.ts — defines a custom Logger class |
@{repo}/observability |
src/lib/logger.ts — wraps Powertools Logger |
@{repo}/observability |
src/lib/idempotency.ts — implements PostgresIdempotencyStore |
@{repo}/idempotency |
Code review must reject PRs that add these files to a module.
Infrastructure references at deploy time, not runtime¶
Lambda ARNs, bus ARNs, and infrastructure endpoints are known at deploy time. They are injected as environment variables by the Pulumi stack — they are not resolved via SSM API calls at Lambda cold start.
Banned pattern — SSM polling at cold start:
let targetArn: string | undefined;
async function getTargetArn(): Promise<string> {
if (targetArn) return targetArn;
const result = await ssmClient.send(
new GetParameterCommand({ Name: '/bank/dev/mod068/validate-session-fn-arn' })
);
targetArn = result.Parameter!.Value!;
return targetArn;
}
Required pattern — environment variable injected by Pulumi:
const TARGET_ARN = process.env.TARGET_FN_ARN
?? (() => { throw new Error('TARGET_FN_ARN not set'); })();
The Pulumi stack reads the SSM parameter at pulumi up time and writes it as a Lambda
environment variable. The Lambda never calls SSM at runtime.
Migration path for existing modules¶
New modules start from the shared packages and never create the banned files.
For existing modules, migrate incrementally — on the next meaningful change to each module, refactor its infrastructure files to use the shared packages.
Step 0 — set up the workspace (once per repo)
Add workspace configuration to the repo root package.json:
Create packages/db/, packages/events/, packages/errors/,
packages/observability/, packages/idempotency/ with their package.json,
src/index.ts, and tsconfig.json. Stub implementations are fine initially —
copy from the first module you migrate.
Step 1 — migrate one module end-to-end first
Choose the simplest module in the repo. Migrate it completely: remove all five banned files, replace with workspace imports, run its tests. This proves the workspace setup works and produces a reference PR other migrations can follow.
Step 2 — migrate remaining modules one at a time
For each subsequent module, on its next meaningful change:
- Remove banned
src/lib/files one by one - Replace with imports from
@{repo}/db,@{repo}/errors, etc. - Run tests after each file removal
- Do not batch multiple modules into one PR
Step 3 — extract to @totara-bank/lambda-core (future, once per-repo packages stabilise)
When errors, observability, and idempotency packages are stable and consistent
across repos, extract them to a shared package in bank-platform and publish to the
GitLab Package Registry. Update each repo's packages to re-export from the shared
package. This is a separate workstream — do not block within-repo migration on it.
EventBridge vs direct invocation¶
See the event catalogue for the rule on when to use EventBridge versus a direct Lambda invoke or SQS queue.