Back to Blogs
June 5, 20264 min read

Building Fintech Features That Don't Break

Financial software has a different margin for error than most. Here's how I think about reliability, idempotency, and trust in fintech engineering.

The Margin Is Different

In most software, a bug means a bad user experience. In fintech, a bug can mean money lost, a transaction posted twice, or a customer's balance showing the wrong number. The tolerance for failure is close to zero.

This changes how you write code. Not the language or the framework — the assumptions.

Idempotency Is Not Optional

In a payment flow, the same operation might be attempted multiple times: network retry, user clicking submit twice, a job queue delivering the same message twice. An idempotent operation produces the same result regardless of how many times you call it.

func (s *PaymentService) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*Payment, error) {
    // Idempotency key from the caller
    key := req.IdempotencyKey

    // Check if we've already processed this request
    existing, err := s.repo.FindByIdempotencyKey(ctx, key)
    if err != nil && !errors.Is(err, ErrNotFound) {
        return nil, err
    }
    if existing != nil {
        return existing, nil // Return the same result
    }

    // Process the payment
    payment, err := s.processPayment(ctx, req)
    if err != nil {
        return nil, err
    }

    // Store with idempotency key
    if err := s.repo.SaveWithKey(ctx, payment, key); err != nil {
        return nil, err
    }
    return payment, nil
}

The idempotency key is provided by the caller — usually a UUID generated client-side. The server stores it atomically with the result. A duplicate request returns the stored result rather than creating a duplicate.

Double-Entry Is the Foundation

Every financial system eventually rediscovers double-entry bookkeeping. Every transaction has at least two sides: money leaves one account and enters another. The sum of all entries always equals zero.

-- ledger_entries table
CREATE TABLE ledger_entries (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    txn_id      UUID NOT NULL REFERENCES transactions(id),
    account_id  UUID NOT NULL REFERENCES accounts(id),
    amount      BIGINT NOT NULL,  -- positive = debit, negative = credit
    currency    CHAR(3) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Every insert must balance
INSERT INTO ledger_entries (txn_id, account_id, amount, currency) VALUES
    ('txn-1', 'sender-account',   -10000, 'MYR'),  -- debit sender
    ('txn-1', 'receiver-account',  10000, 'MYR');  -- credit receiver

Store amounts as integers (smallest currency unit — sen, cents, paise). Floating point arithmetic is not safe for money.

Optimistic Locking for Balance Checks

A race condition in balance validation is one of the most dangerous bugs in fintech. If two concurrent withdrawals both read the same balance, both might succeed even though together they exceed the available funds.

func (r *AccountRepo) Debit(ctx context.Context, id string, amount int64, version int) error {
    result, err := r.db.ExecContext(ctx, `
        UPDATE accounts
        SET balance = balance - $1,
            version = version + 1
        WHERE id = $2
          AND version = $3
          AND balance >= $1
    `, amount, id, version)
    if err != nil {
        return err
    }
    rows, _ := result.RowsAffected()
    if rows == 0 {
        return ErrStaleOrInsufficientBalance
    }
    return nil
}

The version column is a monotonically increasing integer. If a concurrent update has already changed the row, the WHERE version = $3 clause matches zero rows, and the operation fails safely. The caller retries with a fresh read.

Audit Trails Are Business Requirements

In financial software, you rarely delete records. You mark them as void, cancelled, or reversed. Every significant state change gets a log entry with who did it, when, and why.

type AuditEvent struct {
    ID         string
    EntityType string
    EntityID   string
    Action     string
    ActorID    string
    Before     json.RawMessage
    After      json.RawMessage
    Metadata   map[string]string
    CreatedAt  time.Time
}

This is not just for debugging — regulators require it. In Malaysia, Bank Negara guidelines require financial institutions to maintain audit trails for a minimum of 7 years.

Testing Financial Logic Exhaustively

Unit tests for financial code should cover boundary conditions that feel unlikely but happen in production:

  • Exact zero balance after a transaction
  • Transaction exactly equal to the available balance
  • Simultaneous debits on the same account
  • Currency conversion with rounding in both directions
  • Refunds larger than the original transaction (should fail)
  • Transactions in unsupported currencies
func TestDebitExactBalance(t *testing.T) {
    account := Account{Balance: 10000, Version: 1}
    err := account.Debit(10000) // Exactly the available balance
    assert.NoError(t, err)
    assert.Equal(t, int64(0), account.Balance)
}

func TestDebitOverBalance(t *testing.T) {
    account := Account{Balance: 10000, Version: 1}
    err := account.Debit(10001) // One unit over
    assert.ErrorIs(t, err, ErrInsufficientBalance)
}

The Mindset Shift

The biggest difference between general software engineering and fintech engineering is not technical — it's the assumption about failure. General software assumes success is the common case and handles errors. Fintech software assumes something will go wrong and designs so that when it does, the system stays consistent.

Write the failure path first. Then write the success path. Then test both exhaustively.

Written by

Zikri Akmal Santoso

Software Engineer

More Articles