Skip to content

Instantly share code, notes, and snippets.

@AmitKulkarni23
Created February 11, 2026 06:02
Show Gist options
  • Select an option

  • Save AmitKulkarni23/749dcf876ceb5a4e29d7c7c3a2a2db54 to your computer and use it in GitHub Desktop.

Select an option

Save AmitKulkarni23/749dcf876ceb5a4e29d7c7c3a2a2db54 to your computer and use it in GitHub Desktop.

Ledger POC — System Design

A production-style double-entry ledger with simulated onchain settlement, built on AWS serverless infrastructure. Demonstrates the core engineering concepts behind financial systems: atomicity, immutability, idempotency, and event-driven settlement lifecycle.


Table of Contents

  1. What This Demonstrates
  2. Architecture Overview
  3. AWS Infrastructure
  4. Database Design
  5. Core Concepts
  6. API Design
  7. Security Model
  8. Trade-offs and Design Decisions

1. What This Demonstrates

Concept Implementation
Double-entry accounting Every POST /transactions = exactly one DEBIT + one CREDIT; amounts must sum to zero
Immutable audit log journal_entries table is append-only — no UPDATE or DELETE, ever
Atomic multi-write A single PostgreSQL BEGIN/COMMIT covers 2 INSERTs + 2 UPDATEs + 1 INSERT
Idempotency Unique DB constraint on idempotency_key; duplicates return 409 with no side effects
Materialized balance Running balance updated atomically with entries; no full-table scan needed
Keyset pagination Ledger history paginated on (created_at DESC, id DESC) — stable under concurrent inserts
Onchain settlement lifecycle PENDING → CONFIRMED state machine with simulated hash and block number
Serverless + VPC Go Lambda in private subnet connecting to Aurora Serverless v2; no public DB endpoint

2. Architecture Overview

Authenticated Client (JWT)
        │
        ▼
┌────────────────────────────────────────────────────────┐
│                   API Gateway (REST API)               │
│                                                        │
│   Cognito Authorizer                                   │
│   Throttle: 500 req/s steady, 1000 req/s burst         │
│                                                        │
│   POST   /accounts                                     │
│   GET    /accounts/{id}                                │
│   GET    /accounts/{id}/ledger                         │
│   POST   /transactions                                 │
│   GET    /transactions/{id}                            │
│   GET    /transactions/{id}/settlement                 │
└──────────────────────────┬─────────────────────────────┘
                           │  JWT validated by API Gateway
                           │  Claims injected into Lambda event
                           ▼
┌────────────────────────────────────────────────────────┐
│              Go Lambda (PROVIDED_AL2023)                │
│                                                        │
│  • One Lambda per endpoint (6 total)                   │
│  • pgxpool reused across warm invocations              │
│  • DB credentials fetched from Secrets Manager         │
│    at cold start, cached in memory                     │
└──────────────────────────┬─────────────────────────────┘
                           │  Private VPC, port 5432
                           ▼
┌────────────────────────────────────────────────────────┐
│           Aurora Serverless v2 (PostgreSQL)            │
│            Private subnet — no public endpoint         │
│                                                        │
│   accounts            — account registry               │
│   account_balances    — materialized running balance   │
│   journal_entries     — immutable append-only ledger   │
│   onchain_settlements — settlement lifecycle           │
└────────────────────────────────────────────────────────┘

Infrastructure as Code: AWS CDK (TypeScript). Aurora, VPC, security groups, Secrets Manager, and all API Gateway routes are defined in code and deployable with cdk deploy.


3. AWS Infrastructure

Compute — Go Lambda

  • Runtime: PROVIDED_AL2023 (custom Go binary, arm64)
  • One Lambda per endpoint — independent scaling, deployment, and observability per route
  • pgxpool reuse: the connection pool is initialized once at cold start and reused across all warm invocations, amortizing PostgreSQL connection setup cost
  • Memory: 512 MB per function | Timeout: 30s

Database — Aurora Serverless v2

  • Engine: PostgreSQL 15
  • Capacity: 0.5 ACU min → 4 ACU max (scales to near-zero when idle)
  • Placement: private VPC subnet — no public endpoint, no direct internet exposure
  • Credentials: stored in AWS Secrets Manager, fetched at Lambda cold start

Networking

[Lambda SG]  --outbound TCP 5432-->  [Aurora SG]
[Aurora SG]  --inbound from Lambda SG only--

Lambda and Aurora share the same VPC. The Aurora security group only accepts connections from the Lambda security group on port 5432. No other ingress is allowed.

Secrets Manager

Credentials are never stored as Lambda environment variables. The Lambda fetches ledger/db-credentials at cold start using the AWS SDK, caches them in memory, and uses them to build the DSN for the connection pool. The IAM execution role grants secretsmanager:GetSecretValue on that one secret ARN only.

{
  "username": "ledger_app",
  "password": "<generated>",
  "host": "<aurora-cluster-endpoint>",
  "port": 5432,
  "dbname": "ledger"
}

4. Database Design

Schema

CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Account registry
CREATE TABLE accounts (
    id         UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
    owner_id   VARCHAR(255) NOT NULL,
    asset      VARCHAR(10)  NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now(),
    CONSTRAINT accounts_asset_check CHECK (asset IN ('ETH', 'BTC', 'USDC', 'USD'))
);

-- Materialized running balance (updated atomically with journal entries)
CREATE TABLE account_balances (
    account_id  UUID            PRIMARY KEY REFERENCES accounts(id),
    asset       VARCHAR(10)     NOT NULL,
    balance     NUMERIC(36, 18) NOT NULL DEFAULT 0,
    updated_at  TIMESTAMPTZ     NOT NULL DEFAULT now()
);

-- Immutable ledger (append-only, never updated or deleted)
CREATE TABLE journal_entries (
    id               UUID            PRIMARY KEY DEFAULT gen_random_uuid(),
    transaction_id   UUID            NOT NULL,
    account_id       UUID            NOT NULL REFERENCES accounts(id),
    amount           NUMERIC(36, 18) NOT NULL,
    entry_type       VARCHAR(10)     NOT NULL,
    asset            VARCHAR(10)     NOT NULL,
    idempotency_key  VARCHAR(128)    UNIQUE,
    metadata         JSONB,
    created_at       TIMESTAMPTZ     NOT NULL DEFAULT now(),
    CONSTRAINT je_entry_type_check CHECK (entry_type IN ('DEBIT', 'CREDIT')),
    CONSTRAINT je_asset_check      CHECK (asset IN ('ETH', 'BTC', 'USDC', 'USD')),
    CONSTRAINT je_amount_sign      CHECK (
        (entry_type = 'DEBIT'  AND amount < 0) OR
        (entry_type = 'CREDIT' AND amount > 0)
    )
);

CREATE INDEX idx_je_transaction  ON journal_entries(transaction_id);
CREATE INDEX idx_je_account_time ON journal_entries(account_id, created_at DESC);

-- Onchain settlement lifecycle
CREATE TABLE onchain_settlements (
    id             UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    transaction_id UUID        NOT NULL UNIQUE,
    chain          VARCHAR(20) NOT NULL DEFAULT 'ethereum',
    simulated_hash VARCHAR(66) NOT NULL,
    status         VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    block_number   BIGINT,
    confirmed_at   TIMESTAMPTZ,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT settlement_status_check CHECK (status IN ('PENDING', 'CONFIRMED', 'FAILED'))
);

Entity Relationships

accounts (1)
  ├── (1:1) account_balances
  └── (1:N) journal_entries  [grouped by transaction_id]
                │
                └── (1:1 per transaction_id) onchain_settlements

Amount Precision

All monetary amounts use NUMERIC(36, 18) — 18 decimal places, matching Ethereum's precision. Stored and returned as decimal strings, never IEEE 754 floats. Arithmetic in Go uses shopspring/decimal to avoid precision loss.


5. Core Concepts

Double-Entry Accounting

Every financial transaction records two sides: where value came from (DEBIT) and where it went (CREDIT). The amounts must always sum to zero. This is the fundamental invariant of the ledger.

Example — Transfer 1.5 ETH from Alice to Bob:

Entry Account Type Amount
1 Alice's ETH account DEBIT -1.5 ETH
2 Bob's ETH account CREDIT +1.5 ETH
Sum 0

The je_amount_sign DB constraint enforces this at the storage layer: DEBIT amounts must be negative, CREDIT amounts must be positive. The application layer validates that they sum to zero before hitting the DB.


Immutability

journal_entries is an append-only table. There are no UPDATE or DELETE operations — enforced by the application layer. This means:

  • Complete audit trail — every balance change is traceable to a specific transaction
  • Correctness by construction — to reverse a transaction, you post a new offsetting transaction; you never mutate history
  • Regulatory alignment — financial systems require an immutable audit log

Atomicity

POST /transactions executes a single PostgreSQL transaction covering five writes:

BEGIN
  INSERT journal_entries (DEBIT row)
  INSERT journal_entries (CREDIT row)
  UPDATE account_balances WHERE account_id = debit_account  (balance += amount)
  UPDATE account_balances WHERE account_id = credit_account (balance += amount)
  INSERT onchain_settlements (status = PENDING)
COMMIT

If any step fails, the entire transaction rolls back. There is no partial state: you never get journal entries without a corresponding balance update, and you never get a balance update without journal entries.


Idempotency

Financial APIs must be safe to retry. Network failures, timeouts, and client bugs mean a request may be sent more than once. Without idempotency, a retry would post a duplicate transaction.

Implementation:

  1. The client generates a UUID v4 Idempotency-Key and sends it as an HTTP header
  2. The key is stored in the idempotency_key column of journal_entries (UNIQUE constraint)
  3. If the same key is submitted again, PostgreSQL raises a unique constraint violation (23505), which the handler maps to a 409 DUPLICATE_TRANSACTION response
  4. No pre-check SELECT — relying on the DB constraint rather than a check-then-insert avoids race conditions under concurrent retries
First request:  INSERT succeeds → 201 Created
Second request: INSERT fails (unique violation) → 409 DUPLICATE_TRANSACTION

Materialized Balance

Account balances could be computed by scanning all journal entries (SUM(amount) WHERE account_id = ?). For accounts with thousands of entries, this would be slow.

Instead, account_balances stores a running total that is updated in the same atomic transaction as every journal entry insert:

UPDATE account_balances
SET balance = balance + $amount, updated_at = now()
WHERE account_id = $account_id

Because the update is inside the same BEGIN/COMMIT as the journal entries, the balance is always consistent with the entries — no read-your-own-writes inconsistency is possible.


Keyset Pagination

GET /accounts/{id}/ledger returns journal entries newest-first with cursor-based pagination.

Why not offset pagination? OFFSET 100 LIMIT 20 requires the DB to scan and discard the first 100 rows on every page request. As the ledger grows, page 50 becomes as expensive as scanning 1,000 rows. It also produces duplicate or skipped entries when new rows are inserted between page requests.

Keyset pagination instead uses the last-seen row's position as the anchor:

SELECT * FROM journal_entries
WHERE account_id = $1
  AND (created_at, id) < ($cursor_ts, $cursor_id)  -- keyset condition
ORDER BY created_at DESC, id DESC
LIMIT $limit + 1  -- fetch one extra to determine hasMore

The cursor is a base64-encoded JSON struct {"ts":"<RFC3339Nano>","id":"<uuid>"}. It is:

  • Stable: new inserts after page 1 don't affect page 2
  • O(log n): uses the idx_je_account_time composite index
  • Opaque: the client treats it as a black box and passes it back unchanged

6. API Design

All endpoints require a Cognito JWT. Auth is enforced by API Gateway before the Lambda is invoked — the Lambda receives pre-validated requests with claims injected automatically.

Endpoints

Method Path Purpose
POST /accounts Create account (owner + asset)
GET /accounts/{id} Get account + current balance
GET /accounts/{id}/ledger Paginated journal entry history
POST /transactions Post double-entry transaction
GET /transactions/{id} Get transaction + both entries
GET /transactions/{id}/settlement Get settlement status

POST /transactions — Request Shape

POST /transactions
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "description": "Transfer 1.5 ETH from alice to bob",
  "entries": [
    { "accountId": "<alice-account-id>", "amount": "-1.5", "entryType": "DEBIT",  "asset": "ETH" },
    { "accountId": "<bob-account-id>",   "amount": "1.5",  "entryType": "CREDIT", "asset": "ETH" }
  ],
  "metadata": { "memo": "Payment for services" }
}

Validation Chain

  1. Idempotency-Key header present → else 400
  2. Exactly 2 entries → else 400
  3. One DEBIT, one CREDIT → else 400
  4. Amounts sum to zero → else 400 ENTRIES_UNBALANCED
  5. Each account exists → else 404 ACCOUNT_NOT_FOUND
  6. Entry asset matches account asset → else 400 ASSET_MISMATCH
  7. DB constraint enforces idempotency → else 409 DUPLICATE_TRANSACTION

Error Shape

{ "error": "ENTRIES_UNBALANCED", "message": "Transaction entries must sum to zero" }

Amount Representation

Amounts are always decimal strings, never floats:

{ "amount": "-1.500000000000000000" }   // not -1.5 or -1.500000238418579

This avoids IEEE 754 precision loss for values like 0.1 + 0.2.


7. Security Model

Concern Approach
Authentication API Gateway Cognito Authorizer validates JWT before Lambda is invoked
Authorization Cognito sub claim identifies the caller; injected by API Gateway automatically
DB credentials Stored in Secrets Manager, never in environment variables or source code
DB network access Aurora in private VPC subnet; only Lambda SG can reach it on port 5432
Duplicate transactions UNIQUE constraint on idempotency_key — DB-enforced, not application-enforced
Ledger integrity No UPDATE/DELETE on journal_entries; enforced at the application layer
Injection pgx parameterized queries — no string concatenation in SQL

8. Trade-offs and Design Decisions

Why Aurora Serverless v2 and not DynamoDB?

This is a financial ledger. It needs:

  • Joins — balance queries join accounts + account_balances
  • Transactions — 5 writes must be atomic; DynamoDB transactions are limited and awkward for this pattern
  • Check constraints — the je_amount_sign constraint enforces double-entry correctness at the DB level; DynamoDB has no equivalent
  • Decimal arithmeticNUMERIC(36,18) is native in PostgreSQL; DynamoDB has no fixed-precision numeric type

Aurora Serverless v2 scales to near-zero ACUs when idle, so the cost profile is acceptable for a POC.

Why a Materialized Balance and not computing it from entries?

Option A (scan): SELECT SUM(amount) FROM journal_entries WHERE account_id = ?

Simple, but O(n) as the ledger grows — every balance read scans the full entry history.

Option B (materialized): SELECT balance FROM account_balances WHERE account_id = ?

O(1) read. The trade-off is write complexity — every POST /transactions must also update account_balances atomically. The atomic transaction guarantee makes this safe: the balance is always exactly the sum of all journal entries, because they are always updated together.

Why not a pre-check SELECT for idempotency?

A check-then-insert pattern (SELECT COUNT(*) ... then INSERT) has a TOCTOU race: two concurrent requests with the same key can both pass the check and both insert. Relying on the UNIQUE constraint delegates race resolution to the database, which handles it correctly.

Why one Lambda per endpoint instead of one monolith?

  • Independent deployments — update get-settlement without redeploying post-transaction
  • Independent scaling — settlement polling generates many more reads than transaction posts
  • Smaller binaries — each function only compiles its own handler + shared internal/
  • Cleaner IAM — read-only Lambdas can be given read-only DB permissions in a future iteration

The trade-off is 6 CloudFormation resources instead of 1, and 6 build artifacts (mitigated by a Makefile).

Why simulated onchain settlement instead of real blockchain calls?

Real blockchain calls would add API keys, rate limits, and testnet faucet headaches without changing the interesting parts. The settlement state machine, the idempotent status transitions, the atomic UPDATE ... WHERE status = 'PENDING' — that's the actual engineering. The simulation is intentionally thin: one function call is the only thing that would change if this were wired to a real node.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment