I’m building a wallet service in Go. Users add money from their bank, the balance sits in the wallet, and they spend it on the platform. Small payments – the kind where going through a full bank authentication flow every time is more friction than the transaction is worth.

The pitch is simple: top up once, spend without thinking. No OTP for every payment. No redirect to the bank’s page. One click, done.

The implementation is not simple.

A wallet is a ledger

The tempting design is a balance field on the user record. Credit adds to it. Debit subtracts from it. Query it to show the user how much they have. That’s what I started with, and it works right up until you need to answer the question “why does this user have this balance?”

A balance field is a running total with no memory. If something goes wrong – a duplicate credit, a debit that was applied twice, a refund that didn’t land – the balance is wrong and you can’t trace why without digging through logs.

So the wallet is a ledger. Every operation is a row: user ID, amount, type (credit or debit), reference ID, timestamp. The balance is derived – sum of all credits minus sum of all debits for that user. The balance field still exists as a cache for fast reads, but the ledger is the source of truth.

type Transaction struct {
    ID        string
    UserID    string
    Amount    int64
    Type      string // "credit" or "debit"
    RefID     string
    CreatedAt time.Time
}

Amount is an integer in milli-units – so 1.50 in currency is stored as 1500. No floats anywhere in the money path. Floats and money don’t mix.

Idempotency

The worst thing a wallet can do is process the same transaction twice. A user tops up 500, the request times out on the client, the client retries, and now the wallet has credited 1000. Or a debit goes through, the response gets lost, the caller retries, and the user is charged twice.

Every wallet operation takes an idempotency key. The caller generates it – usually a combination of the operation type and a unique reference. Before processing, the wallet checks Redis for the key. If it exists, the operation was already processed and we return the cached result. If it doesn’t, we process it and write the key with a TTL.

func (w *Wallet) Execute(key string, op Operation) (*Result, error) {
    val, err := w.redis.Get(key).Result()
    if err == nil {
        // already processed -- unmarshal and return
        var cached Result
        json.Unmarshal([]byte(val), &cached)
        return &cached, nil
    }

    result, err := op.Run()
    if err != nil {
        return nil, err
    }

    data, _ := json.Marshal(result)
    w.redis.Set(key, data, 24*time.Hour)
    return result, nil
}

The TTL matters. Too short and a slow retry from the caller misses the window. Too long and Redis fills up. Twenty-four hours covers any reasonable retry pattern.

This isn’t optional. Without idempotency, every network hiccup between the caller and the wallet is a potential duplicate transaction. I spent real time on this one – the alternative is spending time reconciling mismatches.

The state machine

A wallet transaction isn’t atomic the way a database write is. A top-up involves receiving money from an external source and crediting the wallet. A spend involves debiting the wallet and confirming the payment to the platform. Each operation has at least two steps, and either step can fail independently.

The states are straightforward:

initiated → processing → success
                       → failed

initiated means we’ve accepted the request and written the ledger entry with a pending status. processing means the external call is in flight – a background worker handles the actual bank interaction for top-ups. success or failed are terminal, updated when the worker gets a response or times out.

The tricky part is the gap between processing and success. The external call succeeded but the status update in our system failed – the user’s money moved but the ledger doesn’t reflect it. This is where the idempotency layer helps: a retry will hit the cached result and return success without reprocessing. But if the cache has expired and the ledger says processing, we need a fallback.

We run a periodic status check. Transactions stuck in processing beyond a threshold get reconciled – check the external system for the actual status and update the ledger accordingly.

What a wallet isn’t

A wallet isn’t a bank. It doesn’t earn interest. It doesn’t lend. It holds a balance and moves it. The operational complexity comes not from what it does but from doing it correctly at scale – every credit matches a real deposit, every debit matches a real spend, and the balance the user sees is always accurate.

The Go implementation is clean once the data model is right. The ledger is append-only. The balance is a materialized view. Idempotency is a Redis lookup. The state machine is a small set of transitions with no ambiguity.

The data model took longer to get right than the code that uses it.