onstructs paired mutations within a single unit of work. Notice the explicit cumulative balance calculation, which enables point-in-time reconstruction without scanning the entire history.
async recordTransfer(
operationId: string,
sourceId: string,
targetId: string,
value: string,
manager: EntityManager
): Promise<void> {
const sourceBalance = await this.getCumulativeBalance(sourceId, manager);
const targetBalance = await this.getCumulativeBalance(targetId, manager);
const debitMutation = manager.create(LedgerMutation, {
accountId: sourceId,
operationId,
mutationType: 'DEBIT',
amount: value,
cumulativeBalance: (BigInt(sourceBalance) - BigInt(value)).toString(),
});
const creditMutation = manager.create(LedgerMutation, {
accountId: targetId,
operationId,
mutationType: 'CREDIT',
amount: value,
cumulativeBalance: (BigInt(targetBalance) + BigInt(value)).toString(),
});
await manager.save([debitMutation, creditMutation]);
}
2. Deterministic Concurrency Control
Application-level balance checks fail under concurrent load. The database must enforce serialization at the row level. Using SELECT ... FOR UPDATE guarantees that no two transactions can modify the same account simultaneously.
async acquireAccountLocks(
sourceId: string,
targetId: string,
manager: EntityManager
): Promise<[AccountSnapshot, AccountSnapshot]> {
// Critical: Always lock in ascending ID order to prevent deadlocks
const [firstId, secondId] = [sourceId, targetId].sort();
const [firstAccount, secondAccount] = await Promise.all([
manager.findOne(AccountSnapshot, {
where: { id: firstId },
lock: { mode: 'pessimistic_write' },
}),
manager.findOne(AccountSnapshot, {
where: { id: secondId },
lock: { mode: 'pessimistic_write' },
}),
]);
return [firstAccount, secondAccount];
}
The sorting step is non-negotiable. Without it, Thread A locking Account X then Y, while Thread B locks Y then X, creates a classic deadlock. Ascending ID ordering serializes lock acquisition deterministically.
3. Idempotency Enforcement Layer
Network retries and message redelivery guarantee duplicate requests. The system must recognize and safely replay identical operations without mutating state twice.
export class IdempotencyGuard {
constructor(private readonly cache: CacheStore) {}
async execute<T>(
requestId: string,
handler: () => Promise<T>,
ttlSeconds: number = 60
): Promise<T> {
const cached = await this.cache.get(requestId);
if (cached?.status === 'COMPLETED') {
return cached.payload as T;
}
if (cached?.status === 'PROCESSING') {
throw new ConflictError('Concurrent execution detected');
}
const acquired = await this.cache.setnx(requestId, { status: 'PROCESSING' }, ttlSeconds);
if (!acquired) {
throw new ConflictError('Duplicate request');
}
try {
const result = await handler();
await this.cache.set(requestId, { status: 'COMPLETED', payload: result }, ttlSeconds * 2);
return result;
} catch (error) {
await this.cache.set(requestId, { status: 'FAILED', error: error.message }, ttlSeconds);
throw error;
}
}
}
The guard uses a SETNX (set if not exists) pattern for distributed locking. The TTL extension on completion prevents premature cache eviction during downstream processing. For long-running operations, a background watchdog should refresh the lock before expiration.
4. Transactional Outbox Integration
Publishing to Kafka inside a database transaction couples financial correctness to broker availability. The outbox pattern breaks this coupling by staging events in the same transaction, then delegating delivery to a separate process.
@Entity('event_staging')
export class EventStaging {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
aggregateId: string;
@Column({ type: 'varchar' })
eventType: string;
@Column({ type: 'jsonb' })
payload: Record<string, unknown>;
@Column({ type: 'enum', enum: ['PENDING', 'DELIVERED', 'FAILED'] })
deliveryStatus: 'PENDING' | 'DELIVERED' | 'FAILED';
@Column({ type: 'int', default: 0 })
retryCount: number;
@CreateDateColumn()
createdAt: Date;
}
The staging table lives in the same database as the ledger. Events are written atomically with financial mutations.
async stageEvent(
aggregateId: string,
eventType: string,
payload: Record<string, unknown>,
manager: EntityManager
): Promise<void> {
const record = manager.create(EventStaging, {
aggregateId,
eventType,
payload,
deliveryStatus: 'PENDING',
});
await manager.save(record);
}
A dedicated relay process polls pending records and publishes them to Kafka. Once acknowledged, it marks them delivered.
export class OutboxRelay {
constructor(
private readonly repo: Repository<EventStaging>,
private readonly broker: MessageBroker
) {}
async processBatch(limit: number = 50): Promise<void> {
const pending = await this.repo.find({
where: { deliveryStatus: 'PENDING' },
take: limit,
order: { createdAt: 'ASC' },
});
for (const event of pending) {
try {
await this.broker.publish(event.eventType, event.payload, event.aggregateId);
event.deliveryStatus = 'DELIVERED';
} catch (error) {
event.retryCount++;
event.deliveryStatus = event.retryCount > 5 ? 'FAILED' : 'PENDING';
if (event.deliveryStatus === 'FAILED') {
await this.alerting.notify('Outbox delivery permanently failed', event);
}
}
await this.repo.save(event);
}
}
}
5. Atomic Execution Flow
All components converge in a single orchestrator. The database transaction wraps ledger mutations, outbox staging, and state updates. If any step fails, PostgreSQL rolls back everything.
async executeTransfer(request: TransferRequest): Promise<TransferResult> {
return this.idempotencyGuard.execute(request.idempotencyKey, async () => {
return this.dataSource.transaction(async (manager) => {
const [source, target] = await this.lockService.acquireAccountLocks(
request.sourceId,
request.targetId,
manager
);
if (BigInt(source.cumulativeBalance) < BigInt(request.amount)) {
throw new InsufficientFundsError();
}
const operationId = v4();
await this.ledgerService.recordTransfer(
operationId,
request.sourceId,
request.targetId,
request.amount,
manager
);
await this.outboxService.stageEvent(
operationId,
'transfer.executed',
{ sourceId: request.sourceId, targetId: request.targetId, amount: request.amount },
manager
);
return { operationId, status: 'COMPLETED' };
});
});
}
This flow guarantees four properties: atomicity (all-or-nothing), isolation (pessimistic locks), durability (PostgreSQL WAL), and reliable communication (outbox relay). The architecture separates concerns cleanly: the database enforces financial invariants, Redis handles request deduplication, and Kafka distributes state changes asynchronously.
Pitfall Guide
1. The "Kafka in Transaction" Trap
Explanation: Publishing directly to a message broker inside a database transaction couples financial correctness to network reliability. If Kafka is unreachable, the transaction rolls back, rejecting a valid financial operation.
Fix: Always use the Transactional Outbox pattern. Write events to a database table in the same transaction, then delegate delivery to a separate process.
2. Lock Ordering Deadlocks
Explanation: Acquiring pessimistic locks in arbitrary order (e.g., Thread A locks Wallet X then Y, Thread B locks Y then X) creates circular wait conditions. PostgreSQL will abort one transaction, causing unnecessary retries.
Fix: Sort account IDs lexicographically or numerically before acquiring locks. Deterministic ordering eliminates deadlocks entirely.
3. Idempotency TTL Expiration Mid-Flight
Explanation: If a database transaction takes longer than the Redis lock TTL, the lock expires. A retry acquires a new lock, causing duplicate execution.
Fix: Implement a watchdog mechanism that extends the lock TTL at regular intervals (e.g., every 15 seconds) while the operation is in progress. Alternatively, use database-level advisory locks for long-running financial operations.
4. Ignoring Running Balance Invariants
Explanation: Storing only the current balance loses historical context. Reconciliation becomes impossible, and audit compliance fails.
Fix: Maintain an append-only ledger with cumulative balances. Implement a background verification job that periodically checks the net-zero invariant across all ledger entries.
5. Outbox Relay Polling Storms
Explanation: Polling the outbox table too frequently (e.g., every 100ms) creates unnecessary I/O pressure and connection pool exhaustion.
Fix: Use adaptive polling intervals. Start with 2-second intervals, back off to 5-10 seconds when the queue is empty, and implement exponential backoff on failures. Consider PostgreSQL LISTEN/NOTIFY or Debezium CDC for event-driven relay triggers.
6. Consumer Non-Idempotency
Explanation: The outbox guarantees at-least-once delivery. Downstream services that process events without deduplication will execute financial logic multiple times.
Fix: Every consumer must implement idempotency checks using the event's aggregate ID or a deduplication table. Design consumers to be pure functions of event state, not side-effect generators.
7. Decimal Precision Loss
Explanation: Using floating-point numbers for financial calculations introduces rounding errors that compound over time, leading to balance drift.
Fix: Store all monetary values as integers (cents/smallest unit) or use DECIMAL/NUMERIC types with explicit precision. Perform calculations using BigInt or dedicated decimal libraries. Never use number for currency.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Low throughput (<100 TPS), strict compliance | Pessimistic locking + Double-entry ledger | Guarantees correctness, simplifies audit | Moderate (storage for ledger) |
| High throughput (>10k TPS), eventual consistency | Optimistic locking + Balance reservation | Reduces lock contention, scales horizontally | Low (application complexity) |
| Multi-region deployment | Outbox + Kafka with regional brokers | Decouples regions, enables async replication | High (infrastructure, latency) |
| Regulatory audit requirements | Append-only ledger + running balance | Full historical reconstruction, tamper-evident | Moderate (storage, indexing) |
| Third-party webhook notifications | Outbox relay + retry queue | Guarantees delivery without blocking transactions | Low (relay process overhead) |
Configuration Template
# NestJS / TypeORM + Redis + Kafka Configuration
database:
type: postgres
host: ${DB_HOST}
port: 5432
username: ${DB_USER}
password: ${DB_PASSWORD}
database: wallet_engine
synchronize: false
logging: ['error', 'warn']
poolSize: 20
extra:
max: 30
idleTimeoutMillis: 30000
redis:
host: ${REDIS_HOST}
port: 6379
idempotencyTtl: 60
lockExtensionInterval: 15
kafka:
brokers:
- ${KAFKA_BROKER_1}
- ${KAFKA_BROKER_2}
consumerGroup: wallet-outbox-relay
topics:
- transfer.executed
- account.updated
retryLimit: 5
retryBackoffMs: 1000
outbox:
pollIntervalMs: 2000
batchSize: 50
alertingWebhook: ${ALERT_WEBHOOK_URL}
Quick Start Guide
- Initialize the database schema: Run migrations to create
ledger_mutations, account_snapshots, and event_staging tables. Ensure proper indexes on aggregateId, deliveryStatus, and createdAt.
- Deploy the idempotency guard: Configure Redis connection and set TTL values. Test duplicate request handling using a mock HTTP client with retry logic.
- Start the outbox relay: Launch the relay process with a 2-second polling interval. Verify it picks up pending events and publishes to Kafka. Monitor retry behavior by simulating broker unavailability.
- Execute a test transfer: Send a concurrent request pair with identical idempotency keys. Confirm only one mutates state, and verify the ledger shows balanced debit/credit pairs.
- Validate downstream consumption: Spin up a test Kafka consumer that logs received events. Confirm idempotency checks prevent duplicate processing. Run the invariant checker to verify net-zero sums.