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.
- What This Demonstrates
- Architecture Overview
- AWS Infrastructure
- Database Design
- Core Concepts
- API Design
- Security Model
- Trade-offs and Design Decisions
| 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 |
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.
- 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
- 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
[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.
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"
}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'))
);accounts (1)
├── (1:1) account_balances
└── (1:N) journal_entries [grouped by transaction_id]
│
└── (1:1 per transaction_id) onchain_settlements
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.
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.
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
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.
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:
- The client generates a UUID v4
Idempotency-Keyand sends it as an HTTP header - The key is stored in the
idempotency_keycolumn ofjournal_entries(UNIQUE constraint) - If the same key is submitted again, PostgreSQL raises a unique constraint violation (
23505), which the handler maps to a409 DUPLICATE_TRANSACTIONresponse - 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
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_idBecause 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.
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 hasMoreThe 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_timecomposite index - Opaque: the client treats it as a black box and passes it back unchanged
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.
| 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
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" }
}Idempotency-Keyheader present → else 400- Exactly 2 entries → else 400
- One DEBIT, one CREDIT → else 400
- Amounts sum to zero → else 400
ENTRIES_UNBALANCED - Each account exists → else 404
ACCOUNT_NOT_FOUND - Entry asset matches account asset → else 400
ASSET_MISMATCH - DB constraint enforces idempotency → else 409
DUPLICATE_TRANSACTION
{ "error": "ENTRIES_UNBALANCED", "message": "Transaction entries must sum to zero" }Amounts are always decimal strings, never floats:
{ "amount": "-1.500000000000000000" } // not -1.5 or -1.500000238418579This avoids IEEE 754 precision loss for values like 0.1 + 0.2.
| 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 |
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_signconstraint enforces double-entry correctness at the DB level; DynamoDB has no equivalent - Decimal arithmetic —
NUMERIC(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.
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.
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.
- Independent deployments — update
get-settlementwithout redeployingpost-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).
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.