Skip to content

feat: implement wallet transfer service with error handling and ledge…#91

Open
nikhil822 wants to merge 2 commits into
Robustrade:mainfrom
nikhil822:solution/nikhil-kumar-sahu
Open

feat: implement wallet transfer service with error handling and ledge…#91
nikhil822 wants to merge 2 commits into
Robustrade:mainfrom
nikhil822:solution/nikhil-kumar-sahu

Conversation

@nikhil822

Copy link
Copy Markdown

Summary

This submission implements a wallet-to-wallet transfer service in Go backed by PostgreSQL. The service exposes a single endpoint, POST /transfers, and guarantees idempotent, atomic, double-entry transfers with safe concurrent execution.

The implementation follows a clean four-layer architecture: handler → service → repository → domain. The handler handles HTTP concerns only. The service owns all business logic and transaction orchestration. Repositories handle SQL. Domain models own state transition rules.


AI Disclosure

  1. Tool Used
    ChatGPT

  2. How I Generally Use the Tool
    I use ChatGPT as a code reviewer. My workflow is:

Review my database schema (schema given in the prompt).
Write test cases in go for the wallet transfer
Review my architecture (given my understanding of the flow).
Used ChatGPT to write structured documentation (like this PR description).


Schema Design

Tables

wallets

CREATE TABLE wallets (
    id         UUID    PRIMARY KEY,
    balance    BIGINT  NOT NULL CHECK (balance >= 0),
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
  • balance >= 0 is a database-level safety net. The application checks balance inside a locked transaction first — this constraint is the last line of defence.
  • BIGINT stores amounts as the smallest currency unit (e.g. cents) to avoid floating-point rounding entirely.

transfers

CREATE TABLE transfers (
    id              UUID           PRIMARY KEY,
    idempotency_key VARCHAR(255)   NOT NULL UNIQUE,
    from_wallet_id  UUID           NOT NULL REFERENCES wallets(id),
    to_wallet_id    UUID           NOT NULL REFERENCES wallets(id),
    amount          BIGINT         NOT NULL CHECK (amount > 0),
    status          transfer_status NOT NULL,
    failure_reason  TEXT,
    created_at      TIMESTAMP      NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMP      NOT NULL DEFAULT NOW()
);
  • UNIQUE on idempotency_key is the primary deduplication mechanism. A duplicate INSERT raises PostgreSQL error 23505, which the repository translates to ErrDuplicateIdempotencyKey for the service layer to handle.
  • CHECK (amount > 0) is enforced at both the application layer and the database.

ledger_entries

CREATE TABLE ledger_entries (
    id          UUID        PRIMARY KEY,
    transfer_id UUID        NOT NULL REFERENCES transfers(id),
    wallet_id   UUID        NOT NULL REFERENCES wallets(id),
    type        ledger_type NOT NULL,
    amount      BIGINT      NOT NULL CHECK (amount > 0),
    created_at  TIMESTAMP   NOT NULL DEFAULT NOW()
);
  • Append-only. Entries are never updated or deleted.
  • Every transfer produces exactly two entries: one DEBIT from the source wallet, one CREDIT to the destination wallet. This is enforced in code and verifiable by querying the table.

Indexes

CREATE INDEX idx_ledger_transfer ON ledger_entries(transfer_id);
CREATE INDEX idx_ledger_wallet   ON ledger_entries(wallet_id);

Enum Types

CREATE TYPE transfer_status AS ENUM ('PENDING', 'PROCESSED', 'FAILED');
CREATE TYPE ledger_type     AS ENUM ('DEBIT', 'CREDIT');

Idempotency Strategy

Duplicate requests are handled using the UNIQUE constraint on transfers.idempotency_key. The flow is:

  1. The service validates that idempotencyKey is non-empty. If it is, it returns ErrMissingIdempotency immediately — no DB interaction.
  2. Inside a transaction, the service attempts INSERT INTO transfers with the given key.
  3. If the INSERT succeeds, the transfer has never been seen. The full workflow continues: lock wallets, check balance, update balances, write ledger entries, mark PROCESSED.
  4. If the INSERT raises 23505 (unique_violation), the key already exists. The transaction is rolled back. The service fetches the original transfer record by key (outside any transaction) and returns it directly.

The caller receives exactly the same response as the first successful call — same transfer ID, same status. No money moves a second time.

Why this approach:

  • It is atomic. There is no window between "check if key exists" and "insert" — the UNIQUE constraint and the transaction make them one operation.
  • It handles FAILED transfers correctly. If the original attempt failed (e.g. insufficient funds), the transfer row exists with status = FAILED. A retry with the same key returns that FAILED result rather than re-attempting the transfer. This is intentional — a client that wants to retry should use a new idempotency key.
  • It handles concurrent duplicate requests. PostgreSQL serializes the UNIQUE constraint violation. Only one INSERT wins; all others see 23505 and return the cached result.

Concurrency Strategy

Row-Level Locking with Ordered Acquisition

When a transfer begins, both wallet rows are locked using SELECT ... FOR UPDATE inside the transaction:

SELECT id, balance
FROM wallets
WHERE id = ANY($1)
ORDER BY id
FOR UPDATE

The ORDER BY id is critical. Without it, two concurrent transfers between wallet A and wallet B could deadlock:

  • Transfer 1 (A→B) locks A, waits for B.
  • Transfer 2 (B→A) locks B, waits for A.

By always acquiring locks in ascending ID order, both transfers attempt to lock the same wallet first. One wins the lock and proceeds; the other waits. No deadlock is possible.

Why Not Optimistic Locking

Optimistic locking (version columns + retry on conflict) is appropriate when conflicts are rare. For wallets under high concurrency — multiple transfers hitting the same wallet — pessimistic locking with FOR UPDATE is more predictable. It serialises access at the database level without requiring application-level retry loops that could amplify load.

Transaction Boundary

The entire transfer is a single PostgreSQL transaction:

BEGIN
  INSERT transfer (PENDING)
  SELECT wallets FOR UPDATE          ← locks both wallet rows
  Check balance
  UPDATE wallet balance (source)
  UPDATE wallet balance (destination)
  INSERT ledger DEBIT
  INSERT ledger CREDIT
  UPDATE transfer → PROCESSED
COMMIT

If anything fails between BEGIN and COMMIT, the deferred tx.Rollback() fires. Balances and ledger entries are never partially applied.

The balance >= 0 CHECK constraint on the wallets table is an additional safety net. Even if a bug bypassed the application-level balance check, PostgreSQL would reject any UPDATE that would push a balance below zero.


How to Run

Prerequisites: Go 1.22+, PostgreSQL 14+.

DATABASE_URL=postgres://user:pass@localhost:5432/wallet go run ./cmd/server

How to Test

Tests are integration tests that require a real PostgreSQL instance.

# Run all tests
TEST_DATABASE_URL='postgres://user:pass@localhost:5432/wallet' go test ./tests -v

Tradeoffs / Assumptions

  • Amounts are integers. The amount field is BIGINT representing the smallest currency unit (e.g. cents for USD). This avoids all floating-point precision issues. Callers are expected to send 100 for $1.00.

  • Balance is stored, not derived. The wallets.balance column is updated on every transfer rather than being computed from the ledger on every read. This is an O(1) read at the cost of keeping the column in sync with the ledger. The ledger remains the audit source of truth — the balance column could be reconstructed from it at any time.

  • FAILED transfers consume their idempotency key. If a transfer fails (e.g. insufficient funds), the idempotency_key is committed to the database with status = FAILED. A retry with the same key returns the FAILED result. This is a deliberate choice: if a client wants to retry a failed transfer, they should generate a new key. It prevents accidentally retrying a business-logic failure as if it were a network transient.


Checklist

  • Tests pass
  • Lint passes
  • Format check passes (gofmt -l . returns no files)
  • README / PR notes updated
  • PR description explains schema, idempotency, and concurrency

…r entries

- Added TransferHandler to handle transfer requests.
- Created repository interfaces and implementations for Ledger, Transfer, and Wallet.
- Implemented TransferService to manage transfer logic, including validation and database interactions.
- Introduced error handling for various transfer scenarios (e.g., insufficient funds, wallet not found).
- Added tests for transfer functionality, including success, failure, and concurrent transfers.
- Established ledger entries for each transfer to maintain transaction history.
Copilot AI review requested due to automatic review settings June 18, 2026 14:38
@nikhil822
nikhil822 requested a review from amitlambakulu as a code owner June 18, 2026 14:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a Go + PostgreSQL wallet transfer API with a service/repository/domain layering, including integration tests for transfer execution, concurrency, and basic idempotency behavior.

Changes:

  • Added POST /transfers handler wired into a minimal HTTP server.
  • Implemented transfer orchestration with DB transactions, wallet row locking, balance updates, and ledger entry writes.
  • Added PostgreSQL-backed repositories and integration tests covering success, validation errors, concurrency, and ledger balancing.

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
backend/tests/transfer_service_test.go Adds integration tests for successful transfers, validation failures, concurrency, and ledger balancing.
backend/service/transfer_service.go Implements the core transfer workflow, including transactions, wallet locking, idempotency insert, balance updates, and ledger writes.
backend/repository/wallet_repository.go Adds wallet repository methods for row locking and balance updates inside a transaction.
backend/repository/transfer_repository.go Adds transfer persistence with duplicate-key detection and status updates.
backend/repository/ledger_repository.go Adds ledger entry inserts for double-entry recording.
backend/repository/errors.go Introduces repository-level error constants (currently unused by the implementation).
backend/handlers/transfer_handler.go Adds HTTP handler that validates JSON and maps service errors to HTTP status codes.
backend/go.mod Defines module, Go version, and dependencies.
backend/go.sum Adds dependency checksums.
backend/domain/wallet.go Introduces the Wallet domain model.
backend/domain/transfer.go Introduces Transfer domain model + basic state transitions (PENDING→PROCESSED/FAILED).
backend/domain/ledger.go Introduces LedgerEntry domain model and DEBIT/CREDIT types.
backend/database/postgres.go Adds pgx pool initialization helper.
backend/cmd/server/main.go Wires DB, repositories, service, handler, and starts the HTTP server.
backend/apperrors/errors.go Adds a shared error for duplicate idempotency key handling.

Comment on lines +113 to +119
if errors.Is(err, apperrors.ErrDuplicateIdempotencyKey) {
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) {
return nil, rollbackErr
}
committed = true
return s.transferRepo.GetByIdempotencyKey(ctx, req.IdempotencyKey)
}
Comment on lines +80 to +91
wallets, err := s.walletRepo.LockByIDs(
ctx,
tx,
[]string{req.FromWalletID, req.ToWalletID},
)
if err != nil {
return nil, err
}

if len(wallets) != 2 {
return nil, ErrWalletNotFound
}
Comment on lines +124 to +127
if source.Balance < req.Amount {

transfer.MarkFailed()

Comment on lines +70 to +74
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return apperrors.ErrDuplicateIdempotencyKey
}
Comment on lines +44 to +51
if err != nil {

switch err {

case service.ErrInvalidAmount,
service.ErrSameWallet,
service.ErrMissingIdempotency:

Comment on lines +5 to +11
var (
ErrInvalidAmount = errors.New("amount must be positive")
ErrSameWallet = errors.New("source and destination wallet cannot be same")
ErrWalletNotFound = errors.New("wallet not found")
ErrInsufficientFunds = errors.New("insufficient funds")
ErrMissingIdempotency = errors.New("missing idempotency key")
)
Comment thread backend/go.mod Outdated
Comment on lines +131 to +149
func TestTransferInsufficientFunds(t *testing.T) {
ctx := context.Background()

walletA := createWallet(t, 50)
walletB := createWallet(t, 500)

_, err := svc.CreateTransfer(ctx, service.CreateTransferRequest{
IdempotencyKey: uuid.NewString(),
FromWalletID: walletA,
ToWalletID: walletB,
Amount: 100,
})

require.Error(t, err)
assert.ErrorIs(t, err, service.ErrInsufficientFunds)

assertWalletBalance(t, walletA, 50)
assertWalletBalance(t, walletB, 500)
}
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants