A production-grade banking backend built with TypeScript, Express.js, PostgreSQL, and Docker — designed to handle money transfers correctly and consistently under concurrent load.
This project is not about building a full banking product. It is about learning how to build a backend that never loses money, never double-spends, and never corrupts data — even when many users are sending requests at the same time.
- Project Overview
- Features
- Architecture
- Data Consistency & Financial Integrity
- Immutable Ledger System
- Ledger-Derived Balances
- Reconciliation
- Outbox Pattern
- Rate Limiting
- Database Design
- Database Protections
- Performance Optimizations
- Logging & Observability
- API Documentation
- Testing
- CI/CD
- Docker
- Key Engineering Concepts Learned
- Future Improvements
- Getting Started
The Bank Transfer API enables users to register, create accounts, and securely transfer money.
The primary engineering focus is correctness under concurrency: resolving race conditions, handling network retries, and preventing data drift.
TypeScript, Express.js, PostgreSQL, Docker, JWT, bcrypt, Pino, GitHub Actions, OpenAPI.
- JWT & bcrypt: Secures endpoints with signed tokens and protects passwords via cryptographic hashing.
- Ownership Validation: Strictly enforces that users can only view or transfer from accounts they explicitly own (returns
403 Forbiddenotherwise).
- Create accounts, view details, and retrieve real-time balances.
The core transfer mechanism prevents self-transfers, validates participants, and tracks lifecycle states (PENDING, PROCESSING, COMPLETED, FAILED).
Why states matter: Explicit tracking ensures safe idempotency, resilient failure recovery, and reliable downstream event emission even if processes interrupt mid-flight.
📐 Architecture diagram coming soon — will be inserted here.
[ Client ]
|
v
[ Express API (TypeScript) ]
| |
| v
| [ Rate Limiter ]
|
v
[ Auth Middleware (JWT) ]
|
v
[ Route Handlers ]
|
├──> [ Account Service ]
| |
├──> [ Transfer Service ]
| |
| ├──> [ PostgreSQL Transaction ]
| | ├──> Row-Level Locks
| | ├──> Ledger Entries
| | ├──> Idempotency Check
| | └──> Outbox Event
| |
v v
[ PostgreSQL Database ]
|
└──> [ Background Worker (Outbox Processor) ]
Financial systems must guarantee that money is never created, destroyed, or duplicated.
When moving funds, deducting from the sender and crediting the receiver must be an all-or-nothing operation. ACID guarantees (Atomicity, Consistency, Isolation, Durability) ensure that if a crash occurs mid-transfer, the database rolls back seamlessly, preventing "lost" money.
// Executed atomically
await db.transaction(async (trx) => {
await deductFromSender(trx, senderId, amount);
await creditToReceiver(trx, receiverId, amount);
await createLedgerEntries(trx, transferId, amount);
await createOutboxEvent(trx, transferId);
});To prevent double-spending race conditions (e.g., two concurrent requests attempting to transfer the remaining $100 balance), we use SELECT ... FOR UPDATE. This statement locks the row during the transaction, forcing concurrent reads to wait and serializing access.
SELECT * FROM accounts WHERE id = $1 FOR UPDATE;// Lock accounts before reading balances
const sender = await trx('accounts').where({ id: senderId }).forUpdate().first();Deadlocks occur when two transactions wait on each other's locks (e.g., A locks Acc1 waiting for Acc2, while B locks Acc2 waiting for Acc1).
Solution: Deterministic Lock Ordering. By always acquiring locks by ascending account ID, concurrent transactions queue efficiently instead of deadlocking.
const [firstId, secondId] = senderId < receiverId ? [senderId, receiverId] : [receiverId, senderId];
await lockAccount(trx, firstId);
await lockAccount(trx, secondId);Networks fail. If a client retries a successful transfer after a timeout, the money could move twice.
Solution: Clients generate a unique UUID Idempotency-Key. The server caches the transfer result against this key. Duplicate requests return the cached response without reprocessing the transaction.
const existing = await getIdempotencyRecord(trx, idempotencyKey);
if (existing) return existing.result; // Return cached response
// ... process transfer
await storeIdempotencyRecord(trx, idempotencyKey, result);Storing just the current balance (e.g., $500) destroys auditability. If a bug alters the balance, there's no historical proof. We solve this via a Ledger: a permanent, append-only record of every event.
Every transfer atomically creates two entries (TRANSFER_OUT and TRANSFER_IN).
The ledger is the source of truth, but summing historical rows is slow. Instead, we use a Cached Balance Pattern: the accounts.balance is updated simultaneously within the ledger transaction for instant reads.
Reconciliation compares these two independent sources to ensure they align, catching bugs or unauthorized data modifications:
SELECT a.id, a.balance AS cached_balance, COALESCE(SUM(l.amount), 0) AS ledger_balance
FROM accounts a LEFT JOIN ledger_entries l ON l.account_id = a.id
GROUP BY a.id HAVING a.balance != COALESCE(SUM(l.amount), 0);After a transfer, we update the database and emit an event (e.g., to Kafka). If the database commits but the event emission fails, the systems drift out of sync. You cannot atomically write to two disparate systems.
Instead of emitting events directly, we write them to an outbox_events table within the same database transaction.
- Transaction Phase:
UPDATE accounts,INSERT ledger_entries, andINSERT outbox_eventsoccur atomically. - Delivery Phase: A background worker polls
outbox_eventsfor unprocessed rows and delivers them with At-Least-Once guarantees.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
processed BOOLEAN DEFAULT false
);Rate limiting protects the API from abuse, such as brute-forcing login endpoints or spamming transfers. When limits are exceeded, the server returns an HTTP 429 Too Many Requests status, preserving resources for legitimate traffic.
Our data layer employs robust schemas and constraints as a final line of defense against application-level bugs.
users&accounts: Users have one or many accounts.transfers: Records requests (PENDING,PROCESSING,COMPLETED,FAILED).ledger_entries: Immutable balance movements.idempotency_keys: Caches responses for duplicate prevention.outbox_events: Queues downstream notifications.
- Foreign Keys: Prevent orphaned data.
- Check Constraints: Enforce strictly positive transfer amounts and non-negative balances.
- Unique Constraints: Ensure
idempotency_keyuniqueness to reject parallel duplicate requests natively. - Triggers: A PostgreSQL trigger throws exceptions on any
UPDATEorDELETEattempt onledger_entries, making financial history strictly immutable even to DB admins.
Without indexes, the database falls back to slow full table scans. We created indexes on hot query paths (e.g., user_id on accounts, sender_account_id/receiver_account_id on transfers, and unprocessed outbox_events). Query efficiency was verified during development using EXPLAIN ANALYZE.
Every significant event is logged as structured JSON (using Pino). Unlike plain text, structured logs enable easy searching and aggregation in log management platforms (e.g., Datadog, CloudWatch) by filtering on properties like transferId or durationMs.
The API is documented using OpenAPI 3.0, served at /api/docs. It acts as a clear contract between the backend and its consumers, detailing schemas, auth requirements, and example payloads to eliminate integration ambiguity.
Integration tests exercise the full request lifecycle — from HTTP request through middleware, business logic, database operations, and back to the HTTP response. They verify that:
- Authentication works end-to-end
- Account creation and retrieval returns correct data
- Transfer validation correctly rejects invalid requests (insufficient funds, self-transfer, non-owned accounts)
- Idempotency keys prevent duplicate transfers
- Transfer state transitions follow the correct lifecycle
The most critical tests for a financial system simulate multiple simultaneous requests hitting the same endpoint.
// Simulate 20 concurrent transfer requests from the same account
const transfers = Array.from({ length: 20 }, () =>
request(app)
.post('/transfers')
.send({ from: senderAccountId, to: receiverAccountId, amount: 10 })
.set('Authorization', `Bearer ${token}`)
);
const results = await Promise.all(transfers);These tests verify that:
- The total money across all accounts is conserved (no money created or destroyed)
- No transfer overdrafts the sender beyond their balance
- The database remains consistent after parallel writes
- Row-level locking correctly serialises conflicting operations
Why concurrency testing matters in financial systems: A backend that works correctly in development (single requests, no parallel load) can silently corrupt data in production (hundreds of concurrent requests). Concurrency tests are the only way to catch race conditions before real money is involved.
To verify the system's performance, throughput, and correctness under intense, sustained concurrent load, we run load tests using k6.
- Virtual Users (VUs): Ramping from 50 to 400 concurrent VUs.
- Duration: 2 minutes.
- Target Endpoint:
POST /api/transfers(utilizing a pre-seeded account with a high balance to support sustained volume).
JWT_TOKEN="your_jwt_token" k6 run load-test.jsBelow are the benchmark results of the API running locally under simulated stress:
| Metric | Result | Target/Threshold | Status |
|---|---|---|---|
| Total Requests | 39,664 | N/A | - |
| Request Throughput | ~325 reqs/sec | N/A | - |
| Success Rate (HTTP 200/201) | 100.00% (39,664/39,664) | > 95.00% | Pass (0% errors) |
| p(95) Response Time | 1.67s | < 500ms | Fail (High DB contention) |
| Average Response Time | 425.03ms | N/A | - |
| Median Response Time | 202.30ms | N/A | - |
| Max Response Time | 2.19s | N/A | - |
- Correctness: 100% of the requests completed successfully without a single server error (
500) or database deadlock, proving the design of our Deterministic Lock Ordering mechanism is highly effective. - Latency & Database Contention: The
p(95)response time reached 1.67 seconds, exceeding our target threshold of500ms. Under 400 concurrent VUs executing transactions simultaneously, the database experienced significant lock queuing. This is expected behavior under severe lock contention as transactions queue sequentially to acquire locks on the sender and receiver accounts, trade-offing tail latency for absolute financial correctness.
Automated pipelines run on every push and pull_request. A fresh PostgreSQL container is spun up to execute all integration and concurrency tests, ensuring no code merges unless the system functions perfectly.
The application and its database are containerized to eliminate "it works on my machine" issues. With docker-compose up, the entire API and PostgreSQL stack launches instantly in an isolated, reproducible environment.
| Concept | Why It Matters |
|---|---|
| Authentication | Proves who you are; prevents unauthorised access |
| Authorization | Proves what you are allowed to do; prevents privilege escalation |
| ACID Transactions | Guarantees that multi-step operations are atomic; no partial writes |
| Concurrency Control | Prevents race conditions when multiple requests touch the same data |
| Row-Level Locking | Serialises access to individual rows; eliminates double-spending |
| Deadlock Prevention | Consistent lock ordering prevents two transactions blocking each other forever |
| Idempotency | Makes retries safe; prevents duplicate operations |
| Immutable Ledgers | Creates an unforgeable audit trail; enables reconciliation |
| Ledger-Derived Balances | Balances can always be recalculated from history; no single point of corruption |
| Reconciliation | Catches discrepancies between cached data and ground truth |
| Outbox Pattern | Solves the dual-write problem; guarantees event delivery |
| Background Workers | Decouples event processing from the request lifecycle |
| Rate Limiting | Protects against abuse, brute-force, and accidental overload |
| Database Constraints | Enforces data integrity at the lowest level, independent of application bugs |
| Indexing | Makes queries fast at scale; verified with EXPLAIN ANALYZE |
| API Documentation | Documents the contract between backend and consumers |
| Integration Testing | Verifies end-to-end correctness of the request lifecycle |
| Concurrency Testing | Validates correctness under parallel load — the hardest bugs to find |
| Docker | Reproducible, portable environments; eliminates "works on my machine" |
| CI/CD | Automated quality gates on every code change |
To handle production scale, the following enhancements are required:
- Redis Rate Limiting: Move from in-memory counters to Redis to support horizontal scaling across multiple API instances.
- Message Broker: Replace the in-process outbox handler with Kafka or RabbitMQ for durable event streaming to multiple consumer services.
- Read Replicas: Route read-heavy workloads (balance lookups) to PostgreSQL replicas.
- Monitoring & Alerting: Integrate Prometheus/Datadog for anomaly detection, real-time metrics dashboards, and latency alerting.
- Webhooks: Let clients subscribe to transfer lifecycle events instead of polling.
- Docker and Docker Compose
# Clone the repository
git clone https://github.com/your-username/bank-transfer-api.git
cd bank-transfer-api
# Copy environment variables
cp .env.example .env
# Start the API and database
docker compose up
# The API is now running at http://localhost:3000
# Swagger docs at http://localhost:3000/api/docsnpm install
npm run migrate
npm run dev# All tests
npm test
# Concurrency tests only
npm run test:concurrency| Variable | Description | Example |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | postgres://user:pass@localhost:5432/bank |
JWT_SECRET |
Secret for signing JWTs | your-secret-key |
JWT_EXPIRES_IN |
Token expiry duration | 1h |
PORT |
API server port | 3000 |
NODE_ENV |
Environment (development / production) |
development |
MIT — feel free to use this project as a learning reference or starting point for your own backend work.
Built as a deep-dive into backend correctness, financial data integrity, and concurrent systems design.