Electronic Wallet Design: Safe Money Transfer with Double-Entry Ledger

1. Overview

Transferring money between two wallets sounds simple: subtract from one side, add to the other. When there are thousands of simultaneous requests, client retries, flaky networks, and audit requirements, the “update balance column” approach will soon produce incorrect balances.

This article walks through designing an electronic wallet based on a double-entry ledger. The design covers requirements, data model, API, processing flow, then tackles difficult points: lock contention, consistency with other systems, and traffic spikes. After reading, you can build a money transfer service using Spring Boot 3 and PostgreSQL. This service won’t lose money, never double-charges, and can be fully reconciled.

2. Problem Requirements

2.1. Functional Requirements

  • Each user has one or more wallets by currency (VND).
  • Deposit, withdraw, and transfer funds between two wallets.
  • View balance and transaction history.
  • A transaction is never recorded twice, even if the client resends the request.

2.2. Scale and Non-Functional Requirements

  • 10 million wallets, averaging 500 transactions/second. Peak of 5,000 transactions/second during flash sales or payday.
  • p99 latency under 300 ms for transfer API.
  • Balance must be strongly consistent. Reading a stale balance to decide whether to allow a transfer is unacceptable.

2.3. Constraints

  • Never allow negative balances, except for system accounts that are permitted to go negative.
  • Every balance change must be traceable to an entry. Never edit or delete entries. To correct a mistake, record a reversing entry.
  • Store money as integers in the smallest unit (cents). Never use double.

3. What is Double-Entry Ledger

Every transaction consists of at least two entries. The sum of entries in a transaction always equals zero. Transferring 100,000đ from An to Binh produces two entries:

transaction T1
  account An    : -100000
  account Binh  : +100000
  ----------------------
  sum           :       0

Deposits also follow this rule. The money “comes from somewhere” is a system account, like BANK_CLEARING, and this account is allowed to go negative. This gives us a very strong invariant: the sum of all entries in the system is always zero. If the sum differs, there’s definitely a bug.

4. Data Model

CREATE TABLE account (
    id              BIGINT PRIMARY KEY,
    owner_id        BIGINT      NOT NULL,
    currency        CHAR(3)     NOT NULL,
    balance         BIGINT      NOT NULL DEFAULT 0,
    allow_negative  BOOLEAN     NOT NULL DEFAULT FALSE,
    CHECK (allow_negative OR balance >= 0)
);

CREATE TABLE journal_transaction (
    id               UUID PRIMARY KEY,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    type             VARCHAR(32) NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE ledger_entry (
    id              BIGSERIAL PRIMARY KEY,
    transaction_id  UUID   NOT NULL REFERENCES journal_transaction(id),
    account_id      BIGINT NOT NULL REFERENCES account(id),
    amount          BIGINT NOT NULL,
    balance_after   BIGINT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_entry_account ON ledger_entry(account_id, id);

CREATE TABLE outbox_event (
    id          BIGSERIAL PRIMARY KEY,
    topic       VARCHAR(64) NOT NULL,
    payload     JSONB       NOT NULL,
    published   BOOLEAN     NOT NULL DEFAULT FALSE
);

The tables above serve these purposes:

  • ledger_entry is the source of truth. This table is append-only. A negative amount is a debit, positive is a credit.
  • account.balance is a cache of the sum of entries. We keep this column to check balance quickly without summing millions of rows.
  • The CHECK constraint is the final safety net. If code has a bug, the database still rejects negative balances.
  • idempotency_key UNIQUE prevents duplicate transaction recording.
  • outbox_event is used to send events to Kafka (section 7.2).

5. API

POST /v1/transfers
Idempotency-Key: 6f1c2a9e-...-client-generated
Content-Type: application/json

{ "fromAccountId": 1001, "toAccountId": 2002, "amount": 100000 }
201 Created
{ "transactionId": "a3f0...", "status": "COMPLETED" }

The client generates its own Idempotency-Key for each transfer intent and reuses the same key when retrying. The server must return the same result for the same key.

The controller is very thin:

public record TransferRequest(@NotNull Long fromAccountId,
                              @NotNull Long toAccountId,
                              @Positive long amount) {}

public record TransferResult(UUID transactionId, String status) {}

@RestController
@RequestMapping("/v1/transfers")
class TransferController {
    private final TransferService service;

    TransferController(TransferService service) { this.service = service; }

    @PostMapping
    ResponseEntity<TransferResult> create(@RequestHeader("Idempotency-Key") String key,
                                          @Valid @RequestBody TransferRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(service.transfer(key, req));
    }
}

6. Money Transfer Processing Flow

We use spring-boot-starter-jdbc to see each SQL statement clearly:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

The entire flow runs in a single database transaction:

@Service
public class TransferService {
    private final JdbcTemplate jdbc;

    public TransferService(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    @Transactional
    public TransferResult transfer(String key, TransferRequest req) {
        if (req.fromAccountId().equals(req.toAccountId())) {
            throw new IllegalArgumentException("Cannot transfer to yourself");
        }
        UUID txId = UUID.randomUUID();
        int inserted = jdbc.update("""
            INSERT INTO journal_transaction(id, idempotency_key, type)
            VALUES (?, ?, 'TRANSFER') ON CONFLICT (idempotency_key) DO NOTHING
            """, txId, key);
        if (inserted == 0) {                                        // (1)
            UUID existing = jdbc.queryForObject(
                "SELECT id FROM journal_transaction WHERE idempotency_key = ?", UUID.class, key);
            return new TransferResult(existing, "COMPLETED");
        }

        long fromBalance = lockAndGet(req.fromAccountId(), req.toAccountId()); // (2)
        if (fromBalance < req.amount()) {
            throw new InsufficientFundsException(req.fromAccountId());          // (3)
        }
        post(txId, req.fromAccountId(), -req.amount());                         // (4)
        post(txId, req.toAccountId(), req.amount());

        jdbc.update("INSERT INTO outbox_event(topic, payload) VALUES ('wallet.transfers', ?::jsonb)",
            """
            {"transactionId":"%s","from":%d,"to":%d,"amount":%d}
            """.formatted(txId, req.fromAccountId(), req.toAccountId(), req.amount()));
        return new TransferResult(txId, "COMPLETED");
    }

    private long lockAndGet(long fromId, long toId) {
        long fromBalance = 0;
        for (long id : new long[]{Math.min(fromId, toId), Math.max(fromId, toId)}) {
            long balance = jdbc.queryForObject(
                "SELECT balance FROM account WHERE id = ? FOR UPDATE", Long.class, id);
            if (id == fromId) fromBalance = balance;
        }
        return fromBalance;
    }

    private void post(UUID txId, long accountId, long amount) {
        Long after = jdbc.queryForObject(
            "UPDATE account SET balance = balance + ? WHERE id = ? RETURNING balance",
            Long.class, amount, accountId);
        jdbc.update("""
            INSERT INTO ledger_entry(transaction_id, account_id, amount, balance_after)
            VALUES (?, ?, ?, ?)""", txId, accountId, amount, after);
    }
}

class InsufficientFundsException extends RuntimeException {
    InsufficientFundsException(long accountId) { super("Insufficient balance: " + accountId); }
}

Key points in the code:

  • (1) Idempotency. ON CONFLICT DO NOTHING returns 0 rows if the key already exists. Suppose two requests with the same key run in parallel. PostgreSQL makes the second request wait at the unique index until the first commits. Then the second request gets 0 rows and returns the old transaction. There is no second debit.
  • (2) Lock in ID order. Flow A transfers 1→2 and flow B transfers 2→1. If each flow locks the source account first, both flows hold one lock and wait for the other, causing deadlock. When we always lock the smaller ID first, all flows request locks in the same order, so no wait cycles occur.
  • (3) Check balance after locking. Checking before locking is a classic check-then-act bug. Two requests both read “sufficient balance” and both debit.
  • (4) Two entries and outbox are in the same transaction. If any step fails, everything rolls back, including the journal_transaction row. So the client can safely retry when told there are insufficient funds.

Note: Don’t use @Transactional on private methods or call it from within the same class (self-invocation). Spring’s proxy won’t wrap the transaction and the entire locking mechanism falls apart.

7. Difficult Points

7.1. Contention on Hot Accounts

A large merchant’s wallet may receive thousands of payments per second. Every transaction queues waiting for FOR UPDATE on a single row, so throughput is capped around a few hundred transactions/second.

A common solution is to split the hot account into N sub-accounts. When crediting, pick a sub-account at random. The merchant’s true balance is the sum of sub-accounts:

long creditTarget(long merchantAccountId, int shards) {
    int shard = ThreadLocalRandom.current().nextInt(shards);
    return jdbc.queryForObject(
        "SELECT id FROM account WHERE parent_id = ? AND shard_no = ?",
        Long.class, merchantAccountId, shard);
}

With 16 shards, contention drops about 16x. This approach works for the credit direction, because adding money doesn’t require checking balance. The debit direction, like merchant withdrawal, happens much less often. You can sweep money back to the main account with a periodic job, and that job is also a double-entry transaction.

Don’t shard regular personal wallets. Personal wallets rarely see contention, and sharding makes balance checks much more complex.

7.2. Consistency with Other Systems: Transactional Outbox

After transfer completes, notifications, fraud detection, and loyalty points need to know. If you call kafkaTemplate.send() directly in transfer(), you may hit one of two failures:

  • Kafka receives the message but the DB rolls back, so users get notified of a transaction that doesn’t exist.
  • DB commits but sending to Kafka fails, and the event is lost.

In section 6, we already wrote the event to outbox_event in the same transaction. A separate relay will read and push this table to Kafka:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
class OutboxRelay {
    private final JdbcTemplate jdbc;
    private final KafkaTemplate<String, String> kafka;

    OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 200)
    @Transactional
    public void publish() {
        var rows = jdbc.queryForList("""
            SELECT id, topic, payload::text AS payload FROM outbox_event
            WHERE published = FALSE ORDER BY id LIMIT 100
            FOR UPDATE SKIP LOCKED""");
        for (var row : rows) {
            kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
            jdbc.update("UPDATE outbox_event SET published = TRUE WHERE id = ?", row.get("id"));
        }
    }
}

Remember to enable @EnableScheduling on a configuration class so @Scheduled runs. SKIP LOCKED lets multiple relay instances run in parallel without fetching duplicate rows.

This mechanism ensures at-least-once delivery. If the relay dies after sending but before committing, the message is resent. So the consumer must deduplicate by transactionId.

7.3. Traffic Spikes

When request volume increases 10x, the first bottleneck is database connection pool, not CPU. There are three layers of defense:

  1. Per-user rate limiting at the edge using Redis.
  2. Limit connection pool size (HikariCP) so the DB doesn’t get overloaded, with a short timeout to fail fast.
  3. Defer non-critical work like notifications and loyalty points to Kafka. We already have that via outbox.

Example: rate limit to 10 requests/second per user with fixed-window, using spring-boot-starter-data-redis:

@Component
class TransferRateLimiter {
    private final StringRedisTemplate redis;

    TransferRateLimiter(StringRedisTemplate redis) { this.redis = redis; }

    boolean allow(long userId) {
        String key = "rl:transfer:%d:%d".formatted(userId, Instant.now().getEpochSecond());
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) redis.expire(key, Duration.ofSeconds(2));
        return count != null && count <= 10;
    }
}
spring.datasource.hikari.maximum-pool-size=40
spring.datasource.hikari.connection-timeout=500
spring.jpa.open-in-view=false

When allow() returns false, return 429 Too Many Requests. When unable to get a connection within 500 ms, return 503. Clients have Idempotency-Key so retries are safe.

Note: Never use Redis to store balance or to “debit first then sync back to DB later”. Redis is very fast but doesn’t give you ACID transactions together with the ledger. One failover and money is lost.

8. Testing Concurrent Behavior

The most important test is firing many requests in parallel at the same wallet. Wallet A has 50,000đ and receives 100 commands to transfer 1,000đ each. The correct result is exactly 50 succeed, balance becomes 0, and the sum of entries is 0. The test below needs a real PostgreSQL, for example via Testcontainers, with initialization data in @Sql.

@SpringBootTest
@Sql(statements = {
    "TRUNCATE ledger_entry, journal_transaction, outbox_event, account",
    "INSERT INTO account(id, owner_id, currency, balance) VALUES (1, 1, 'VND', 50000), (2, 2, 'VND', 0)"
})
class TransferServiceConcurrencyTest {

    @Autowired TransferService service;
    @Autowired JdbcTemplate jdbc;

    @Test
    void neverOverdraftsUnderConcurrency() throws Exception {
        AtomicInteger success = new AtomicInteger();
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100; i++) {
                String key = "k-" + i;
                pool.submit(() -> {
                    try {
                        service.transfer(key, new TransferRequest(1L, 2L, 1_000));
                        success.incrementAndGet();
                    } catch (InsufficientFundsException ignored) { }
                });
            }
        }
        assertThat(success.get()).isEqualTo(50);
        assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class)).isZero();
        assertThat(jdbc.queryForObject("SELECT COALESCE(SUM(amount),0) FROM ledger_entry", Long.class)).isZero();
    }

    @Test
    void sameIdempotencyKeyReturnsSameTransaction() {
        var req = new TransferRequest(1L, 2L, 1_000);
        var first = service.transfer("dup", req);
        var second = service.transfer("dup", req);

        assertThat(second.transactionId()).isEqualTo(first.transactionId());
        assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class))
            .isEqualTo(49_000L);
    }
}

If you remove FOR UPDATE from lockAndGet, the first test will fail randomly. Sometimes success is greater than 50, sometimes the CHECK constraint throws an error. Without ON CONFLICT, the second test will debit 2,000đ.

Besides testing, run a reconciliation job every night. This job checks two things: the sum of entries is zero, and account.balance equals the sum of ledger_entry for each account:

SELECT a.id, a.balance, COALESCE(SUM(e.amount), 0) AS ledger_sum
FROM account a LEFT JOIN ledger_entry e ON e.account_id = a.id
GROUP BY a.id, a.balance
HAVING a.balance <> COALESCE(SUM(e.amount), 0);

Returning 0 rows means the books match.

9. Common Mistakes and When Not to Use This Design

  • Using double/float for money. The calculation 0.1 + 0.2 doesn’t equal 0.3. Use long in the smallest unit, or BigDecimal if you need exchange rates.
  • Directly updating balance without recording an entry. This runs fast at first, but when customers dispute a charge, you can’t answer “where did the money go?”
  • Editing or deleting erroneous entries. Record a reversing entry as a new transaction instead.
  • Locking out of order causes deadlocks under load. PostgreSQL breaks deadlock by killing a transaction, and users see random errors.
  • Calling external APIs while holding locks, like calling a bank’s payment gateway. Locks are held for seconds and throughput collapses. Separate bank operations into a state machine (PENDING → COMPLETED/FAILED) with holding (escrow) accounts.
  • Don’t use this design for data that isn’t money or assets, like page view counters. There, small discrepancies are acceptable and double-entry ledger is just unnecessary overhead.

10. Conclusion

The core of a safe electronic wallet doesn’t lie in new technology but in a few rigorous principles:

  • Double-entry append-only ledger, with sum of entries always zero.
  • Idempotency key for every write command.
  • Pessimistic locking in fixed order, and balance check after acquiring locks.
  • CHECK constraint as the final safety net.

On top of that, handle hot accounts with sub-accounts, synchronize with other systems via Transactional Outbox and Kafka, and defend against traffic spikes with Redis rate limiting and a capped connection pool.

When scaling further, you can separate bank operations into a state machine with escrow accounts. You can also partition ledger_entry by time when the table grows to billions of rows. No matter how you scale, keep concurrent tests and reconciliation jobs always running.

system-designspring-bootpostgresqlkafkafintech