Skip to content

Wallet Transfer Service: idempotent, double-entry, concurrency-safe (Feroz Khatri)#101

Open
ferozyk wants to merge 12 commits into
Robustrade:mainfrom
ferozyk:solution/feroz-khatri
Open

Wallet Transfer Service: idempotent, double-entry, concurrency-safe (Feroz Khatri)#101
ferozyk wants to merge 12 commits into
Robustrade:mainfrom
ferozyk:solution/feroz-khatri

Conversation

@ferozyk

@ferozyk ferozyk commented Jun 26, 2026

Copy link
Copy Markdown

Summary

Implements the wallet-transfer service: an HTTP API to move money between
wallets with exactly-once semantics, safe concurrency (no double-spend,
no deadlock), and a double-entry ledger for auditability. Built in clean,
layered ports-and-adapters style (domain → service → handler/repository),
backed by PostgreSQL via pgx/v5, with embedded migrations applied on startup and
graceful shutdown. Money is int64 minor units end to end — no floating point.

Full design rationale: docs/DESIGN.md. Run/test instructions:
README.md.

AI disclosure

  1. Tool: Claude Code (Anthropic), driving Claude (Opus-class) models — the
    only AI tool used.
  2. How it was used: Documentation-first and contract-driven. The design
    (API, schema, idempotency, concurrency) was written before code; a shared
    domain/ports foundation was fixed first; independent layers (repository,
    handler, docs) were implemented in parallel while the author/orchestrator
    owned the domain foundation, the service orchestration, the cmd/walletd
    wiring, and the end-to-end concurrency tests. TDD throughout. All generated
    code, config, lint, and tests were reviewed and verified by the author.
  3. Transcript: The full session transcript will be attached to the repo or
    emailed with this submission. This PR, docs/AI_USAGE.md,
    and the commit history also capture the prompts and workflow.

See docs/AI_USAGE.md for the detailed disclosure.

Schema Design

PostgreSQL, money as BIGINT minor units with invariants enforced by CHECK
constraints at the database boundary.

  • walletsid TEXT PK, balance BIGINT CHECK (balance >= 0),
    timestamps. The non-negative check is a last-line overdraft guard.
  • transfersid UUID PK, idempotency_key TEXT UNIQUE,
    from_wallet_id/to_wallet_id FK → wallets, amount BIGINT CHECK (> 0),
    status CHECK IN (PENDING,PROCESSED,FAILED), failure_reason, timestamps,
    CHECK (from <> to). Indexed on from_wallet_id and to_wallet_id.
  • ledger_entriesid UUID PK, wallet_id FK, transfer_id FK,
    type CHECK IN (DEBIT,CREDIT), amount BIGINT CHECK (> 0),
    UNIQUE (transfer_id, type) (at most one DEBIT and one CREDIT per transfer).
    Indexed on wallet_id and transfer_id.
  • idempotency_recordskey TEXT PK, request_hash, transfer_id FK,
    response_body JSONB, status CHECK IN (PENDING,COMPLETED), timestamps.

Migrations are embedded in the binary and applied idempotently on startup
(tracked in schema_migrations). Per-constraint rationale is in
docs/DESIGN.md §3.

Idempotency Strategy

Each POST /transfers carries a client idempotencyKey. The service claims it
by inserting an idempotency_records row inside the transfer transaction;
the key PRIMARY KEY is the single serialization point.

  • First request wins the insert, executes the transfer, stores a response
    snapshot, marks COMPLETED.
  • A concurrent duplicate blocks on the unique index until the first
    transaction commits, then observes the conflict and replays the stored
    result — money moves exactly once. Replays return Idempotent-Replayed: true.
  • Durable, so retries after a restart still replay correctly.
  • Same key + different payload (hash mismatch) → 409.

Mechanics in docs/DESIGN.md §4.

Concurrency Strategy

One transfer runs in one transaction. Both wallets are locked with
SELECT ... FOR UPDATE acquired in sorted wallet-id order, which makes
opposing transfers (A→B / B→A) deadlock-free. Funds are checked under the
lock, both balances updated, the DEBIT/CREDIT ledger pair written, and the
transfer flipped to PROCESSED — atomically. Insufficient funds commits a
durable FAILED transfer (an idempotent failure) returned as 422. Pessimistic
locking was chosen over optimistic concurrency for this write-heavy,
correctness-critical path; the tradeoff is discussed in
docs/DESIGN.md §5.

How to Run

  • Docker Compose: make compose-up (builds the app, starts
    postgres:16-alpine, waits for pg_isready, runs migrations on startup).
    API on http://localhost:8080. Tear down with make compose-down.
  • Local: set DATABASE_URL and make run (go run ./cmd/walletd).
  • Config via env: DATABASE_URL (required), HTTP_ADDR (:8080),
    READ_TIMEOUT/WRITE_TIMEOUT (10s), SHUTDOWN_TIMEOUT (15s).
  • A copy-paste demo (seed wallets, transfer, replay, read balance) is in the
    README.

How to Test

  • make testgo test ./... -race -cover (unit + integration).
  • make test-integration — Postgres-backed repository/concurrency tests.
  • make lint / make fmt-check / make vet — the CI gates locally.
  • Unit tests (domain, service-with-fakes, handler) need nothing external.
    Integration tests start a real postgres:16-alpine via testcontainers and
    require Docker; when Docker is unavailable they self-skip, so the suite
    is green without Docker and fully exercised with it.

Tradeoffs / Assumptions

  • Single currency; int64 minor units (no floats).
  • No authentication / authorization (out of scope).
  • Wallets are pre-provisioned (seeded) — there is no wallet-creation
    endpoint
    by design.
  • Pessimistic FOR UPDATE locking trades hot-wallet parallelism for simplicity
    and obvious correctness.
  • Health endpoint is liveness-only; metrics and readiness are noted as next
    steps.

Full list in docs/DESIGN.md §10.

Checklist

  • Tests pass
  • Lint passes
  • Format check passes
  • README or notes updated
  • PR description explains schema, idempotency, and concurrency

ferozyk added 10 commits June 26, 2026 14:53
Lay the foundation contract the rest of the service builds on:

- domain: Wallet (Debit/Credit invariants), Transfer state machine
  (PENDING -> PROCESSED|FAILED, guarded transitions), double-entry
  ledger constructor, idempotency record, and money as integer minor
  units. 100% unit-tested under -race.
- service: ports (Wallet/Transfer/Ledger/Idempotency repositories,
  TxManager) and the TransferService contract + DTOs, with the
  consumer (service) owning the interfaces.
- config: env-driven configuration with sane defaults.
- schema: initial PostgreSQL migration encoding correctness in the
  database -- positive amounts, non-negative balances, unique
  idempotency keys, and one DEBIT + one CREDIT per transfer.

Toolchain pinned to Go 1.24.13 via the toolchain directive to match CI.
Implement the persistence ports over pgx/v5:

- DB wraps a pgxpool and is the TxManager: WithinTx begins a
  transaction, propagates it on the context, and commits or rolls back
  (rolling back and re-panicking on panic). Repositories resolve the
  ambient tx via a private context key, so call sites never thread a
  handle by hand.
- WalletRepository.GetForUpdate issues SELECT ... FOR UPDATE to
  serialise concurrent debits on a wallet.
- IdempotencyRepository maps unique violations (pgconn 23505) to
  domain.ErrDuplicateIdempotencyKey, the signal the service uses to
  replay rather than re-execute.
- Embedded, versioned, idempotent migration runner (schema_migrations).
- Integration tests use a shared testcontainers Postgres and skip
  gracefully when no Docker host is present; they assert real
  FOR UPDATE lock-blocking, the ledger UNIQUE(transfer_id, type)
  guard, and the non-negative-balance CHECK.
Implement the TransferService use case:

- Exactly-once via a durable idempotency key: claim the key inside the
  transaction; a duplicate insert is detected and the original result
  is replayed. Key reuse with a different payload is rejected.
- Wallets are locked with GetForUpdate in sorted-id order to avoid
  deadlocks between opposing transfers.
- Insufficient funds is recorded as a durable, idempotent FAILED
  transfer (committed, not rolled back) and surfaced as
  ErrInsufficientFunds; a balanced DEBIT/CREDIT ledger pair is written
  only on success.

Behavioural tests run against in-memory fakes with a deterministic id
generator, covering happy path, idempotent replay, key reuse,
insufficient funds, validation, and missing wallets. Coverage 83%.
Thin handlers over the TransferService interface:

- POST /transfers returns 201 on a fresh transfer, 200 with an
  Idempotent-Replayed header on replay (byte-identical body), and 422
  with the durable FAILED transfer on insufficient funds.
- GET /transfers/{id} and GET /wallets/{id}/balance, plus /healthz.
- Strict, bounded JSON decoding (MaxBytesReader, DisallowUnknownFields)
  and a single domain-error-to-HTTP mapper that never leaks internals.

Handler tests use httptest with a fake service. Coverage 95%.
…tdown

Wire config, the PostgreSQL pool, and the chi handler together. On startup
the binary applies embedded migrations (bounded by a 30s context) and then
serves HTTP, draining in-flight requests on SIGINT/SIGTERM within the
configured shutdown budget. Structured JSON logging via slog.
Two defects surfaced by the end-to-end tests against real PostgreSQL that
the in-memory fakes could not expose:

- Deadlock (40P01): inserting the transfer takes a FOR KEY SHARE lock on
  both wallet rows via the foreign keys. With the transfer inserted before
  the SELECT ... FOR UPDATE, concurrent debits on one wallet each held a
  share lock and then tried to upgrade to exclusive, deadlocking despite
  sorted lock ordering. Fix: acquire the FOR UPDATE locks first, then
  insert the transfer.
- Transaction abort (25P02): claiming the idempotency key by catching a
  raised unique violation aborted the transaction, making the replay read
  impossible. Fix: claim with INSERT ... ON CONFLICT (key) DO NOTHING and
  detect duplicates via zero rows affected, which keeps the transaction
  healthy while preserving the unique-index blocking that serialises
  concurrent duplicates.
Drive the full HTTP -> service -> PostgreSQL stack against a testcontainers
Postgres. Proves the two properties the assignment cares about most:

- 25 concurrent debits of an underfunded wallet: exactly the affordable
  number succeed, the rest fail with 422, the wallet never goes negative,
  and the ledger stays balanced (no double-spend).
- 25 concurrent requests with the same idempotency key: exactly one
  executes, all others replay the same transfer, one DEBIT + one CREDIT.
- idempotent replay returns 200 with Idempotent-Replayed and moves money
  once.

The suite self-skips when no Docker host is reachable, so go test ./...
stays green locally and runs in full in CI.
Satisfy the golangci-lint v1.64.8 gate (misspell, goimports, gocritic):
normalise comments to US spelling, separate the local import group, and
reword a test comment that tripped the commented-out-code check. No
behavioural changes.
- Multi-stage Dockerfile producing a static (CGO-free) binary on a
  distroless nonroot runtime.
- docker-compose stack (postgres:16-alpine with healthcheck + app) for a
  one-command local run; the app self-migrates on startup.
- Makefile targets for build/run/test/test-integration/lint/fmt/vet.
- .golangci.yml tuned for v1.64.8 (govet, staticcheck, gosec, revive,
  gocritic, misspell, and friends).
…ption

- README: architecture, data model, idempotency/concurrency summaries,
  API reference with curl demo, run/test instructions, layout, tradeoffs.
- docs/DESIGN.md: the documentation-first write-up -- contract, schema
  rationale, the ON CONFLICT idempotency mechanics, the lock-before-insert
  deadlock analysis, failure modes, and the pessimistic-vs-optimistic
  tradeoff.
- docs/AI_USAGE.md and docs/PR_DESCRIPTION.md per the assignment.
Copilot AI review requested due to automatic review settings June 26, 2026 16:25
@ferozyk
ferozyk requested a review from amitlambakulu as a code owner June 26, 2026 16:25
…signment

Align the Go module path with the GitHub repository owner. Mechanical
rename of the module directive and all internal import paths; no
behavioural change.

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

Introduces a complete wallet transfer HTTP service with Postgres persistence, designed to provide exactly-once transfer creation via idempotency keys, concurrency-safe balance updates using row locks, and double-entry ledger recording, plus accompanying documentation, tooling, and tests.

Changes:

  • Implemented domain models, service-layer orchestration, and HTTP handlers for transfers and balance reads.
  • Added Postgres adapters with embedded migrations and transactional context propagation.
  • Added unit and integration (testcontainers) coverage, plus local dev tooling (Makefile/Docker/Compose/lint config).

Reviewed changes

Copilot reviewed 38 out of 40 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/integration/transfer_e2e_test.go End-to-end concurrency/idempotency tests
README.md Service overview, API, run/test docs
Makefile Local build/lint/test/compose commands
internal/service/transfer_service.go Transfer orchestration + idempotency replay
internal/service/transfer_service_test.go Service unit tests with fakes
internal/service/ports.go Service-owned repository/Tx ports
internal/service/fakes_test.go In-memory fakes for service tests
internal/service/contracts.go Service input/output DTOs/contracts
internal/repository/postgres/wallet.go Wallet persistence + row locking
internal/repository/postgres/transfer.go Transfer persistence adapter
internal/repository/postgres/postgres_test.go Postgres adapter + migration tests
internal/repository/postgres/migrations/0001_init.sql Initial schema + constraints
internal/repository/postgres/ledger.go Ledger persistence adapter
internal/repository/postgres/idempotency.go Idempotency persistence adapter
internal/repository/postgres/db.go Pool, tx context, embedded migrations
internal/handler/transfer_handler.go Transfer/balance HTTP handlers
internal/handler/handler.go Router wiring + middleware
internal/handler/handler_test.go Handler unit tests
internal/handler/errors.go Error mapping + JSON envelope
internal/handler/dto.go HTTP request/response DTO mapping
internal/domain/wallet.go Wallet entity + debit/credit invariants
internal/domain/wallet_test.go Wallet invariant tests
internal/domain/transfer.go Transfer entity + state machine
internal/domain/transfer_test.go Transfer + ledger construction tests
internal/domain/money.go Money type + amount validation
internal/domain/ledger.go Double-entry ledger entity/helpers
internal/domain/idempotency.go Idempotency record entity
internal/domain/errors.go Domain sentinel errors
internal/config/config.go Env-driven runtime configuration
go.sum Dependency lockfile updates
go.mod Module definition + dependency set
docs/PR_DESCRIPTION.md PR body template content
docs/DESIGN.md Detailed design rationale
docs/AI_USAGE.md AI usage disclosure
Dockerfile Build + distroless runtime image
deployments/docker-compose.yml Local stack (app + Postgres)
.golangci.yml golangci-lint configuration
.gitignore Go build/test artifact ignores
.dockerignore Docker build context exclusions

Comment thread internal/service/transfer_service.go
Comment thread internal/service/ports.go Outdated
Comment thread internal/repository/postgres/postgres_test.go
Comment thread internal/repository/postgres/postgres_test.go Outdated
- requestHash: length-prefix the fields (netstring-style) so wallet ids
  containing the delimiter cannot collide into the same hash, which would
  have let a different payload pass the same-key/different-payload (409)
  check. Add a regression test for the previously-colliding case.
- ports: correct the IdempotencyRepository.Get contract doc to match both
  adapters (a non-nil error on absence; absence is unexpected because Get
  is only called after a duplicate), replacing the misleading
  "nil record with no error" wording.
- tests: fix testCtleanup -> testCleanup typo.
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