Skip to content

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, and idempotency packages become thin wrappers that re-export from @totara-bank/lambda-core, or are removed entirely.
  • A change to the ErrorKind enum 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_URL from 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_version are 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 ErrorKind enum: VALIDATION_FAILURE | COMPLIANCE_BLOCK | TRANSIENT_INFRA | PROVIDER_ERROR | NOT_FOUND
  • Exports ClassifiedError extends Error with a kind: ErrorKind field
  • 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:

{
  "workspaces": ["packages/*", "MOD-*"]
}

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:

  1. Remove banned src/lib/ files one by one
  2. Replace with imports from @{repo}/db, @{repo}/errors, etc.
  3. Run tests after each file removal
  4. 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.