Wallet Transfer Service: idempotent, double-entry, concurrency-safe (Feroz Khatri)#101
Open
ferozyk wants to merge 12 commits into
Open
Wallet Transfer Service: idempotent, double-entry, concurrency-safe (Feroz Khatri)#101ferozyk wants to merge 12 commits into
ferozyk wants to merge 12 commits into
Conversation
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.
…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.
There was a problem hiding this comment.
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 |
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
int64minor units end to end — no floating point.Full design rationale:
docs/DESIGN.md. Run/test instructions:README.md.AI disclosure
only AI tool used.
(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/walletdwiring, and the end-to-end concurrency tests. TDD throughout. All generated
code, config, lint, and tests were reviewed and verified by the author.
emailed with this submission. This PR,
docs/AI_USAGE.md,and the commit history also capture the prompts and workflow.
See
docs/AI_USAGE.mdfor the detailed disclosure.Schema Design
PostgreSQL, money as
BIGINTminor units with invariants enforced byCHECKconstraints at the database boundary.
wallets—id TEXT PK,balance BIGINT CHECK (balance >= 0),timestamps. The non-negative check is a last-line overdraft guard.
transfers—id 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 onfrom_wallet_idandto_wallet_id.ledger_entries—id 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_idandtransfer_id.idempotency_records—key 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 indocs/DESIGN.md§3.Idempotency Strategy
Each
POST /transferscarries a clientidempotencyKey. The service claims itby inserting an
idempotency_recordsrow inside the transfer transaction;the
keyPRIMARY KEY is the single serialization point.snapshot, marks
COMPLETED.transaction commits, then observes the conflict and replays the stored
result — money moves exactly once. Replays return
Idempotent-Replayed: true.409.Mechanics in
docs/DESIGN.md§4.Concurrency Strategy
One transfer runs in one transaction. Both wallets are locked with
SELECT ... FOR UPDATEacquired in sorted wallet-id order, which makesopposing transfers (
A→B/B→A) deadlock-free. Funds are checked under thelock, both balances updated, the DEBIT/CREDIT ledger pair written, and the
transfer flipped to
PROCESSED— atomically. Insufficient funds commits adurable
FAILEDtransfer (an idempotent failure) returned as422. Pessimisticlocking was chosen over optimistic concurrency for this write-heavy,
correctness-critical path; the tradeoff is discussed in
docs/DESIGN.md§5.How to Run
make compose-up(builds the app, startspostgres:16-alpine, waits forpg_isready, runs migrations on startup).API on
http://localhost:8080. Tear down withmake compose-down.DATABASE_URLandmake run(go run ./cmd/walletd).DATABASE_URL(required),HTTP_ADDR(:8080),READ_TIMEOUT/WRITE_TIMEOUT(10s),SHUTDOWN_TIMEOUT(15s).README.
How to Test
make test—go test ./... -race -cover(unit + integration).make test-integration— Postgres-backed repository/concurrency tests.make lint/make fmt-check/make vet— the CI gates locally.Integration tests start a real
postgres:16-alpinevia testcontainers andrequire Docker; when Docker is unavailable they self-skip, so the suite
is green without Docker and fully exercised with it.
Tradeoffs / Assumptions
int64minor units (no floats).endpoint by design.
FOR UPDATElocking trades hot-wallet parallelism for simplicityand obvious correctness.
steps.
Full list in
docs/DESIGN.md§10.Checklist