LedgerX Architecture Book

Engineering LedgerX: Deterministic Money Movement Under High Concurrency

This page is intentionally written as a deep technical field guide. It explains not only what I built, but why each architectural decision was made for financial correctness, operability, and scale.

Chapter 1: The Problem Space (Moving Money is Hard)

The naive implementation works in local demos and fails in production.

The first instinct in a payment system is simple: read a balance, subtract an amount, and write it back. That appears correct under single-threaded traffic. Under concurrent load, it is a correctness disaster. Two requests can read the same starting balance and both succeed, producing lost updates and accidental overspending.

Example anti-pattern: balance = balance - amount. This mutates state in place and assumes isolation that does not exist in distributed systems.

Chapter 2: Immutable Double-Entry Accounting

Money is never edited in place. It is represented by immutable debits and credits.

In LedgerX, every transfer is expressed as two immutable ledger facts: one DEBIT from the source account and one CREDITto the destination account. I do not rewrite historical entries. I append new entries and let transaction state represent lifecycle changes.

Core Schema

transactions
------------
id (uuid, pk)
idempotency_key (unique)
status (PENDING | COMPLETED | FAILED)
created_at

ledger_entries
--------------
id (uuid, pk)
transaction_id (fk -> transactions.id)
account_id (fk -> accounts.id)
direction (DEBIT | CREDIT)
amount
currency
created_at

The transactions row binds both ledger entries together as a single atomic business event. Within each transaction boundary, the sum of debits and credits is zero by construction. This gives deterministic reconciliation and prevents silent money creation or destruction.

Atomic Double-Entry Write

@Transactional
public Transaction processTransfer(
    String fromAccount,
    String toAccount,
    BigDecimal amount,
    String currency,
    String idempotencyKey
) {
  Transaction tx = transactionRepository.save(
      Transaction.pending(idempotencyKey)
  );

  ledgerEntryRepository.save(LedgerEntry.debit(tx, fromAccount, amount, currency));
  ledgerEntryRepository.save(LedgerEntry.credit(tx, toAccount, amount, currency));

  tx.markCompleted();
  return tx;
}

Chapter 3: Concurrency & The Deadlock Problem

Why I chose pessimistic row locks and how deterministic lock ordering eliminates deadlocks.

For hot wallets, pure optimistic locking leads to retry storms and poor throughput under contention. LedgerX uses pessimistic row-level locking (SELECT ... FOR UPDATE) to serialize writes at the account row. That guarantees only one transfer mutates a given wallet at a time.

Spring Data Locking Query

public interface AccountRepository extends JpaRepository<Account, UUID> {

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Query("""
    select a
    from Account a
    where a.accountNumber = :accountNumber
    """)
  Optional<Account> findByAccountNumberForUpdate(
      @Param("accountNumber") String accountNumber
  );
}

Critical edge case: deadlocks. Example: Thread 1 executes A→B while Thread 2 executes B→A simultaneously. If each thread locks its source first, they can block each other forever. The fix is deterministic lock acquisition: sort account identifiers lexicographically and always acquire locks in that order.

Deterministic Lock Acquisition (Deadlock Prevention)

List<String> orderedAccountNumbers =
    Stream.of(fromAccountNum, toAccountNum)
        .sorted() // deterministic lock order
        .toList();

Account firstLocked = accountRepository
    .findByAccountNumberForUpdate(orderedAccountNumbers.get(0))
    .orElseThrow(...);

Account secondLocked = accountRepository
    .findByAccountNumberForUpdate(orderedAccountNumbers.get(1))
    .orElseThrow(...);

Chapter 4: Distributed Systems & Idempotency

Timeouts are ambiguous; retries are mandatory; duplicate charges are unacceptable.

In distributed systems, a client timeout does not prove the transfer failed. If a caller receives a 504 Gateway Timeout, the transfer may still have been committed. Retrying blindly can double-charge users.

Every write request carries an Idempotency-Key. The key is persisted with a unique database constraint. If the same key is retried, LedgerX returns the already-completed transaction instead of creating a second transfer.

Idempotent Transfer Endpoint

@PostMapping("/api/v1/transfers")
public Transaction transfer(
    @RequestHeader("Idempotency-Key") String key,
    @Valid @RequestBody TransferRequestDTO request
) {
  return transactionRepository.findByIdempotencyKey(key)
      .filter(existing -> existing.getStatus() == TransactionStatus.COMPLETED)
      .orElseGet(() -> transferService.processTransfer(
          request.fromAccount(),
          request.toAccount(),
          request.amount(),
          request.currency(),
          key
      ));
}

Chapter 5: Transaction Boundaries & Audit Compliance

Audit events must be emitted only after successful commit.

Standard synchronous logging or plain AOP can fire before the transaction is durably committed. That risks false audit trails if the database commit later fails. LedgerX uses @TransactionalEventListener withTransactionPhase.AFTER_COMMIT so audit records are emitted only when money movement is truly persisted.

After-Commit Audit Listener

@Component
@RequiredArgsConstructor
public class TransferAuditListener {

  private final AuditLogRepository auditLogRepository;

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void onTransferCommitted(TransferCommittedEvent event) {
    auditLogRepository.save(AuditLog.from(event));
  }
}

This approach cleanly separates compliance concerns from core transfer logic while preserving correctness: no commit, no audit event.

Chapter 6: The Interview FAQ

High-signal answers to architecture questions I expect in backend interviews.