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.