Solution/nandu#113
Open
Nandukrishna-S wants to merge 40 commits into
Open
Conversation
Pulls the dependency set from docs/DESIGN.md §9 (gin, pgx/v5, go-redis/v9, golang-migrate, decimal, caarlos0/env, godotenv, google/uuid).
wallets (UUID v7 PK, balance with a DB-level CHECK backstop), transfers (Snowflake BIGINT PK doubling as the idempotency key, request_hash for replay detection, a status CHECK allowing PENDING/PROCESSED/FAILED), and ledger_entries (explicit indexes on transfer_id/wallet_id, since Postgres does not auto-index the referencing side of a FK).
Time-ordered 64-bit IDs (41-bit ms timestamp, 10-bit worker ID, 12-bit sequence)
Pure business types with zero infra imports. Status is restricted to PROCESSED/FAILED — PENDING is never persisted, since the debit/credit/ledger sequence is one atomic DB transaction.
Binds WORKER_ID, POSTGRES_DSN, REDIS_ADDR, PORT, LOCK_TIMEOUT via caarlos0/env + godotenv. Validate() checks required fields and delegates the WORKER_ID range check to idgen.NewGenerator itself rather than duplicating its bit-width constants, so idgen stays the single source of truth for its own valid range. Called immediately after Load() in main.go, before any connection is attempted.
Generic infra construction. postgres.New and redisclient.New deliberately don't ping — main.go does that explicitly so the server never binds if a dependency is unreachable. migrate.Run applies pending golang-migrate migrations at startup. logger.New sets up structured JSON logging via log/slog.
Attaches a per-request correlation ID to the request context (reusing an inbound X-Correlation-ID header if present) and echoes it back on the response.
router.go registers /health with no middleware group and a /transfers group carrying it. health_handler.go checks Postgres and Redis directly with a 2s timeout each, returning 503 with per-dependency status on failure.
Boot order: load config -> validate (exit on failure) -> build logger -> build postgres pool -> ping postgres (exit on failure) -> run migrations (exit on failure) -> build redis client -> ping redis (exit on failure) -> build id generator -> build handlers/router -> serve -> graceful shutdown on SIGTERM/SIGINT. The server never binds if a dependency is unreachable at startup.
Dockerfile: multi-stage build, for a reproducible static binary, running as a dedicated non-root user. docker-compose.yml brings up postgres+redis+ app with the app healthcheck pointed at GET /health. Makefile exposes run/migrate/lint/fmt-check/test/test-integration. .golangci.yml adds unconvert/unparam/misspell on top of the standard preset. .env.example documents WORKER_ID/POSTGRES_DSN/REDIS_ADDR/PORT/LOCK_TIMEOUT;
Ledger entry IDs are never a client-facing idempotency key or FK target for anything else, unlike transfers.id — none of the reasons that justified hand-rolling a Snowflake ID apply here, so the DB's own sequence is the simplest correct choice. The table is still empty at this point, so the ALTER is safe.
go.sum gained entries for dktest and OpenTelemetry packages after adding golang-migrate. These are pulled in because golang-migrate's own test suite depends on them (dktest spins up real DBs via Docker for its tests) -- not because our code imports or uses them anywhere.
New typed error for the 409 case: an idempotency key reused with a different fromWalletId/toWalletId/amount than it was first claimed with.
WalletRepo and TransferRepo implement the mechanical locking/read/write side of the transfer flow, with no business decisions. Wallet locks are sequential single-row FOR UPDATE calls in caller-given order, not a combined ANY() query, since Postgres does not guarantee lock acquisition follows ORDER BY output order. InsertTransfer writes the final status directly via ON CONFLICT DO NOTHING, no separate claim-then-update step. TransferCache is the Redis read-through cache, latency optimization only. Includes integration tests against real Postgres.
TransferService.Transfer runs, in order: same-wallet/invalid-amount checks, a Redis cache check, a pre-transaction Postgres check for an already-committed id, then — only if truly new — locks both wallets, decides PROCESSED or FAILED/INSUFFICIENT_BALANCE, and claims the transfer with that outcome already written. Includes unit tests (mocked repo/cache/transactor) covering every branch, plus the concurrency stress test and double-click race test against real Postgres/Redis, run with -race.
InitTransfer mints via the service and returns transactionId as a JSON string. CreateTransfer runs tier-1 shape validation (validateAmount, validateWalletID, validateIdempotencyKey), calls the service, and maps *domain.Error to its typed HTTP status via errors.As; anything else is a 500 with details logged and a generic body returned. GetTransfer is always 200 if found, 404 otherwise. toTransferView/statusForTransfer are the one shared response builder used by both POST and GET. Includes handler tests covering status-code mapping with a mocked service.
Constructs the postgres/cache repos, the transactor, and the TransferService, then passes it into the handler constructor in place of the Phase 1 stub. No other change to the boot sequence.
test-integration brings up postgres+redis via docker compose and runs the integration suite with -race. seed loads scripts/seed.sql into the running postgres container. Also fixes every docker-compose (hyphenated v1 binary) invocation to docker compose (v2 plugin) -- the standalone binary isn't installed here, so make run/test-integration/seed were all silently broken before this.
scripts/seed.sql inserts three fixed, documented wallets for manual curl testing -- idempotent, never a golang-migrate migration, schema migrations stay data-free. README.md now documents the actual service (API reference, config, testing, project layout, a full curl walkthrough) instead of the original assignment-template administration content, which no longer describes this repository's purpose now that a solution lives here.
There was a problem hiding this comment.
Pull request overview
Implements a Go/Gin wallet-to-wallet transfer service with atomic execution in Postgres, pessimistic wallet row locking, idempotency via a durable transfer ID, and a double-entry ledger, plus unit + integration coverage and runnable local tooling (Docker/Makefile/seed).
Changes:
- Adds DB schema + migrations for wallets, transfers, and ledger entries (including identity IDs for ledger entries).
- Implements transfer orchestration (cache → existing-transfer precheck → lock wallets → execute → claim transfer → ledger entries) with Redis read-through caching.
- Adds HTTP handlers/routes, configuration/bootstrap, Docker/Compose, and unit/integration tests (including concurrency + double-click race “hero” tests).
Reviewed changes
Copilot reviewed 50 out of 52 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/seed.sql | Manual/demo wallet fixtures |
| README.md | Service-focused documentation + API reference |
| migrations/0004_ledger_entries_identity_id.up.sql | Makes ledger entry IDs DB-generated identity |
| migrations/0004_ledger_entries_identity_id.down.sql | Reverts ledger entry identity |
| migrations/0003_create_ledger_entries.up.sql | Creates ledger_entries table + indexes |
| migrations/0003_create_ledger_entries.down.sql | Drops ledger_entries |
| migrations/0002_create_transfers.up.sql | Creates transfers table |
| migrations/0002_create_transfers.down.sql | Drops transfers |
| migrations/0001_create_wallets.up.sql | Creates wallets table |
| migrations/0001_create_wallets.down.sql | Drops wallets |
| Makefile | Adds run/lint/test/integration/seed targets |
| internal/service/transfer_service.go | Core idempotent + transactional transfer flow |
| internal/service/transfer_service_test.go | Unit tests with hand-rolled doubles |
| internal/service/transfer_service_integration_test.go | Concurrency + double-click race integration tests |
| internal/service/ports.go | Service-layer ports/interfaces + request type |
| internal/service/main_integration_test.go | Integration TestMain bootstrapping (migrate/pool/redis) |
| internal/service/fixtures_integration_test.go | Per-test wallet fixtures + test ID generator |
| internal/repository/tx.go | Tx/Transactor abstractions for service-owned boundaries |
| internal/repository/postgres/wallet_repo.go | Wallet locking + balance update repository |
| internal/repository/postgres/wallet_repo_integration_test.go | Wallet repo integration coverage (incl. lock serialization) |
| internal/repository/postgres/transfer_repo.go | Transfer claim insert + lookup + ledger insert |
| internal/repository/postgres/transfer_repo_integration_test.go | Transfer repo integration coverage |
| internal/repository/postgres/transactor.go | pgxpool transactor adapter |
| internal/repository/postgres/main_integration_test.go | Postgres repo integration TestMain |
| internal/repository/postgres/fixtures_integration_test.go | Postgres repo test wallet fixture helper |
| internal/repository/cache/redis_cache.go | Redis transfer cache with TTL+jitter |
| internal/platform/redisclient/redisclient.go | Redis client bootstrap |
| internal/platform/postgres/postgres.go | Postgres pool bootstrap |
| internal/platform/migrate/migrate.go | Startup migration runner |
| internal/platform/logger/logger.go | slog JSON logger setup |
| internal/middleware/correlation_id.go | Correlation ID middleware + slog helper |
| internal/idgen/snowflake.go | Snowflake transfer ID generator |
| internal/idgen/snowflake_test.go | Snowflake generator tests |
| internal/handler/validate.go | Tier-1 request shape validation/parsing |
| internal/handler/transfer_view.go | Shared transfer response mapping + status mapping |
| internal/handler/transfer_handler.go | /transfers init/create/get handlers |
| internal/handler/transfer_handler_test.go | Handler tests for status mapping + validation paths |
| internal/handler/router.go | Route registration + middleware grouping |
| internal/handler/health_handler.go | /health handler (postgres+redis checks) |
| internal/handler/dto.go | Request/response DTOs |
| internal/domain/wallet.go | Domain wallet model |
| internal/domain/transfer.go | Domain transfer model + status |
| internal/domain/errors.go | Domain error codes + typed error |
| internal/config/config.go | Env config loading + validation |
| go.sum | Dependency checksums |
| go.mod | Module definition + dependencies |
| Dockerfile | Multi-stage build + non-root runtime |
| docker-compose.yml | Postgres/Redis/App local stack |
| .golangci.yml | Lint/formatter configuration |
| .gitignore | Tracks .env.example exception |
| .env.example | Example env configuration |
decimal.IntPart() truncates (wraps) silently if the value doesn't fit
in an int64. Detect that by converting the truncated int64 back to a
decimal and comparing against the pre-truncation value -- since the
amount regex already guarantees at most 2 decimal places, the shifted
value is always an exact integer decimal, so any mismatch here can
only mean overflow, never a real fractional truncation.
Returns a *domain.Error{Code: INVALID_AMOUNT} instead of a bare
fmt.Errorf, and the handler now checks for that specific case and
routes it through the existing typed-error mapping (422), instead of
every validateAmount failure taking the same flat 400 path regardless
of what kind of problem it actually is.
internal/service imported internal/middleware only for its correlation- ID context helpers, which pulled Gin into the service package's import graph even though the service itself never touches HTTP. Moves WithCorrelationID/CorrelationIDFromContext/FromContext into internal/platform/logger, which already has zero Gin dependency. middleware.CorrelationID (the actual Gin middleware) now calls logger.WithCorrelationID to set the value; internal/service and internal/handler both call logger.FromContext to read it back, with no path to Gin anywhere in internal/service's dependency graph. Verified with `go list -deps ./internal/service/...`.
When InsertTransfer loses a genuine claim race, roll back explicitly before calling checkConflictAfterRace instead of relying on the deferred rollback at function return. The deferred rollback only fires after checkConflictAfterRace has already run its own read (via the pool, not this transaction), so without this, the wallet locks this transaction holds stay held for that entire extra round trip for no reason.
Duration.Milliseconds() truncates to 0 for any value under 1ms, and "SET LOCAL lock_timeout = '0ms'" means "no timeout" in Postgres -- the opposite of what a lock_timeout is supposed to protect against. config.Validate() now rejects LOCK_TIMEOUT < 1ms outright at startup with a clear error. setLockTimeout also clamps to 1ms defensively as a second layer, for any path that constructs a Duration without going through config validation.
The DTO's Amount field (json.Number) already decodes a bare JSON number correctly, with no float64 precision loss -- json.Number captures the literal text of the number token directly, it never passes through a float. Sending amount as a quoted string worked too (json.Number's underlying type is string, so any JSON string decodes into it), but didn't match the actual contract or the original assignment spec's payload example. Updates every README curl example and Go test fixture accordingly. No DTO or decode-logic change -- the type was already correct. TestCreateTransfer_InvalidAmountShape's "abc" case now 400s at the JSON-bind step instead of the amount-shape regex, since a bare `abc` isn't valid JSON syntax -- still the correct status, just a different code path than the other three cases in that table.
An amount that overflows int64 once shifted into minor units was being reported as INVALID_AMOUNT (422, business-meaning failure), the same code used for amount <= 0. The two are different failure tiers: an overflowing amount is a tier-1 structural problem (the value literally doesn't fit), not a tier-2 business rule violation, so it needs its own code and its own 400 status rather than sharing INVALID_AMOUNT's 422. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ations ledger_entries.id was hand-rolled as a plain BIGINT PK in migration 0003, then converted to GENERATED ALWAYS AS IDENTITY by a follow-up migration 0004 — but the table doesn't exist anywhere outside local dev yet, so there's no deployed schema to preserve compatibility with. Folding the identity column and the amount > 0 check directly into 0002/0003 and dropping 0004 keeps the migration history reflecting the intended schema from the start instead of its work-in-progress path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…olliding NextID only handled the clock staying flat or moving forward — if it moved backward (NTP step, VM migration/snapshot restore), the old code fell through the same-timestamp branch's guard and could mint a sequence value that collides with an ID already issued at that now-revisited millisecond. A regression can be seconds or minutes long, so spinning until the clock catches up (as sequence rollover already does within a millisecond) isn't the right response here — NextID now returns an error immediately and lets the caller decide, rather than blocking a request indefinitely or risking a duplicate transfer ID. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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
Wallet-to-wallet transfer service in Go, built around a single durable
identifier that serves as primary key, idempotency key, and ledger
foreign-key target (see Schema Design). Transfers execute atomically
with pessimistic row locking, a double-entry ledger, and a two-phase
init → transfer flow so the client's idempotency key survives
refreshes/re-clicks without a second round trip on every retry.
AI disclosure
Tool used: Claude (claude.ai, chat interface) for design and
architecture — every schema, concurrency, and idempotency decision in
this PR was worked out there before any code was written. Claude Code
(VS Code extension) for implementation — writing, testing, and iterating
on the actual Go code against that design.
How I generally used it:
The chat interface was used as a design partner, not a code generator— I proposed ideas (e.g. wallet balance strategy, locking approach, the two-step init/transfer idempotency flow), it walked through tradeoffs, and I made the actual calls.
Every design decision was captured in docs/DESIGN.md as we went, so the reasoning (including rejected alternatives and why) is preserved, not just the final answer.
Context persistence technique: since Claude Code sessions don't carry memory between sessions, I used a CLAUDE.md file at the repo root (Claude Code's project-memory mechanism) that imports docs/DESIGN.md via @docs/DESIGN.md. This meant every new Claude Code session — I worked in phases (boilerplate → transfer service → tests) across multiple sessions — started with the full accumulated design context and a set of hard layering/style rules, rather than me re-explaining the architecture each time or Claude Code guessing at conventions already settled earlier.
Claude Code was used to implement against that spec, run tests
Phase 1: Boilerplate only. Do not implement transfer business logic yet —
that's Phase 2.
Read docs/DESIGN.md in full before starting. Follow all rules in
CLAUDE.md.
Environment: Go 1.24. Every dependency must resolve to a version whose
own go.mod targets go1.24 or lower — pin explicitly where the latest
release forces go1.25+ (this affects gin, pgx/v5, and golang.org/x/net
at minimum). The Dockerfile must build successfully on golang:1.24-alpine.
I already have a finished, tested Snowflake generator — do NOT
regenerate it. I'm placing internal/idgen/snowflake.go and
internal/idgen/snowflake_test.go myself; treat them as given and just
wire NewGenerator(cfg.WorkerID) into main.go per its own doc comments.
Set up:
go.mod (module github.com/Nandukrishna-S/wallet-transfer-assignment)
and the directory structure exactly as in docs/DESIGN.md §8.
Migrations (golang-migrate, paired .up.sql/.down.sql):
created_at, updated_at. No user_id, no currency.
(FK -> wallets), amount, status (CHECK IN ('PENDING', 'PROCESSED',
'FAILED') — PENDING is an unused safety margin in the constraint
only; nothing in Go models it as a real state), failure_reason,
created_at, updated_at. No response_status/body columns.
NULL, explicit index), wallet_id (FK -> wallets NOT NULL, explicit
index), entry_type (CHECK IN ('DEBIT','CREDIT')), amount (CHECK
amount > 0), created_at.
internal/config — env struct binding (caarlos0/env + godotenv):
WORKER_ID, POSTGRES_DSN, REDIS_ADDR, PORT, LOCK_TIMEOUT.
Add a Validate() method: required fields non-empty, and delegate the
WORKER_ID range check to idgen.NewGenerator itself rather than
duplicating its bit-width constants — idgen stays the single source
of truth for its own valid range. Called immediately after loading
config, before anything else in main.go — on failure, log the
specific invalid/missing field and os.Exit(1) before attempting any
connection.
internal/platform:
once immediately after construction — on failure, log the error
and os.Exit(1); the HTTP server must never bind if this fails.
requirement as postgres.
at startup, before the server binds. Failure here is also fatal.
internal/domain — pure business types, zero DB/framework imports:
Wallet, Transfer, Status (PROCESSED | FAILED only — this is a Go-level
restriction independent of the DB's more permissive CHECK constraint),
errors.go with the typed ErrorCode/Error and sentinel values from
docs/DESIGN.md §11.
internal/middleware/correlation_id.go — request-scoped correlation ID
into slog via context. This is the one sanctioned use of context.Value
in the whole codebase. No other middleware yet.
internal/handler:
/transfers/:id, AND a /health route (no group, no middleware needed
— this must work even if correlation-id middleware itself somehow
failed to init, so keep it dependency-free besides the DB/Redis
clients it's checking).
against redis, each with a ~2s timeout. Both OK → 200
{"status":"ok","postgres":"ok","redis":"ok"}. Either fails → 503
with per-dependency status shown.
cmd/server/main.go — wiring only, in this order: load config →
validate config (exit on failure) → build logger → build postgres
pool → ping postgres (exit on failure) → run migrations (exit on
failure) → build redis client → ping redis (exit on failure) → build
idgen → build stub handler (incl. health handler) → register routes
→ start server → graceful shutdown on SIGTERM/SIGINT.
Dockerfile: multi-stage, golang:1.24-alpine → alpine:3.20,
CGO_ENABLED=0 GOOS=linux go build -trimpath for a reproducible static
binary, running as a dedicated non-root user (create the user, COPY
--chown, USER directive before ENTRYPOINT — don't just create the
user and leave the container running as root).
docker-compose.yml (postgres + redis + app, standard ports 5432/6379/
8080, app healthcheck via GET /health, depends_on with
service_healthy conditions), Makefile (run, migrate, lint, fmt-check,
test, test-integration — using
docker compose, the v2 plugin syntax,not the standalone
docker-composev1 binary, which may not beinstalled), .golangci.yml, .env.example, go.mod requiring
shopspring/decimal, google/uuid, redis/go-redis/v9, jackc/pgx/v5,
golang-migrate, caarlos0/env, joho/godotenv, gin-gonic/gin.
.gitignore already has
.envand.env.*— add a!.env.exampleexception so the template file itself stays trackable.
One commit per logical step. If anything is ambiguous or conflicts with
docs/DESIGN.md, stop and ask rather than guessing.
Do not write internal/service or internal/repository business logic yet.
Comments: never cite docs/DESIGN.md or CLAUDE.md by section number as a
substitute for explanation (e.g. "see docs/DESIGN.md §8") — state the
actual reasoning inline, point-wise and minimal, so a reader never has
to context-switch to another file to understand a comment.
Stop after boilerplate is in place and confirm: go build ./... succeeds,
make lint passes, make fmt-check passes, docker compose up brings up
postgres+redis+app with migrations applying against real Postgres, AND
curl localhost:/health returns 200 with both dependencies reported ok.
Phase 2 Prompt — Transfer Endpoints
Phase 2: implement POST /transfers/init, POST /transfers, GET
/transfers/{id} end to end, plus their tests. Boilerplate (Phase 1) is
done and pushed — build on top of it, don't restructure it.
Read docs/DESIGN.md in full again before starting. Follow CLAUDE.md's
hard rules throughout, especially: handlers never contain business
logic, repositories never contain business rules, small single-purpose
functions per step, one commit per logical change.
Testing ground rule, applies to every test that touches real Postgres
(integration tests, the concurrency stress test, the double-click race
test): each test creates its OWN wallet fixtures in its Arrange step —
via direct repository calls or raw SQL insert — with exactly the
balances that scenario needs. Never reference a shared/pre-seeded wallet
across tests. Write a small per-test fixture helper (e.g.
newTestWallet(t, pool, balance)) called fresh inside each test, not a
shared-setup helper reused across tests.
Integration tests run against real Postgres+Redis brought up by
docker compose up -d postgres redis, not testcontainers-go — itsdocker-client dependency pulls in an OTel exporter chain that forces
go1.25+ at every version recent enough to be usable, which conflicts
with this project's go1.24 target. Tests read TEST_POSTGRES_DSN/
TEST_REDIS_ADDR env vars, defaulting to docker-compose.yml's ports.
internal/repository/tx.go — package repository: Querier (the Exec/
Query/QueryRow subset of pgx.Tx and *pgxpool.Pool) and Tx (Querier +
Commit/Rollback) interfaces, plus a Transactor interface
(Begin(ctx) (Tx, error)). This is the shared contract between the
postgres implementation and the service layer, so service can own
the transaction boundary without importing pgxpool directly.
internal/repository/postgres/wallet_repo.go:
AT A TIME via sequential
SELECT ... WHERE id = $1 FOR UPDATEcalls, in the exact order walletIDs is given (callers must pass it
pre-sorted ascending). Do NOT use a single
WHERE id = ANY($1) ORDER BY id FOR UPDATE— Postgres's rowlocking happens during the scan feeding the query, and when a Sort
node is needed to satisfy ORDER BY, that scan can visit rows in an
order that doesn't match the final sorted output, so ORDER BY does
not reliably control lock acquisition order. This must be verified
under real concurrent load (see the concurrency stress test below),
not just reasoned about.
computed new balance, no sufficiency decision here.
domain/service.
internal/repository/postgres/transfer_repo.go:
amount, status, failureReason) — ON CONFLICT (id) DO NOTHING
RETURNING *, writing the transfer's FINAL status and failure_reason
directly in this one INSERT. By the time this is called, the
caller must already hold both wallet locks and have decided the
outcome — there is no separate claim-then-update step. Returns
(nil, nil) if id was already claimed by another transfer.
pre-transaction existing-id check and for GET.
amount) — exactly 2 rows, only called on the PROCESSED path. Entry
IDs are DB-generated identity values (add a migration changing
ledger_entries.id to GENERATED ALWAYS AS IDENTITY) — unlike
transfers.id, an entry ID is never a client-facing idempotency key
or FK target for anything else, so there's nothing to gain from
hand-rolling it with idgen.
internal/repository/cache/redis_cache.go:
single key, immutable, self-healing. Set applies its own TTL +
jitter internally (a caching-policy concern, not the caller's).
internal/service/ports.go — WalletRepository, TransferRepository,
Cache, IDGenerator interfaces (consumer-defined, referencing
repository.Tx for tx parameters) and the TransferRequest struct.
internal/service/transfer_service.go — TransferService.Transfer runs,
in this exact order:
a. validateTransferRules(req) — same-wallet and invalid-amount
checks. Touches neither Redis nor Postgres. Keep this in the
service layer, not the handler — same-wallet and amount<=0 are
request meaning, not shape (a same-wallet or zero-amount
request is syntactically well-formed JSON; it's semantically
invalid), and CLAUDE.md's hard rule reserves business-meaning
validation for service/domain so it stays testable independent
of HTTP.
b. computeHash(fromWalletID, toWalletID, amountMinorUnits) — sha256
canonicalization, excluding the transfer ID itself.
c. checkCache(ctx, id, hash) — Redis GET. Hit+match → replay. Hit+
mismatch → 409 (IDEMPOTENCY_KEY_REUSED, a new domain.ErrorCode).
Miss → fall through.
d. checkExistingTransfer(ctx, id, hash) — a PLAIN Postgres read (no
transaction, no lock) for an already-committed id, run BEFORE any
wallet lock is attempted. Found+match → replay. Found+mismatch →
409. Not found → fall through. This step must run before wallet
locking, not only in a post-conflict branch — see the wallet-lock
ordering note below for why.
e. lockAndExecuteTransfer(ctx, req, hash) — only reached if truly new:
BEGIN, SET LOCAL lock_timeout, lock BOTH wallets first (via
lockAndValidateWallets — FOR UPDATE, ascending sorted order,
WALLET_NOT_FOUND if either is missing, ROLLBACK on that path),
THEN decide the outcome (executeTransfer: insufficient balance →
FAILED/INSUFFICIENT_BALANCE, no debit; sufficient → debit, credit,
PROCESSED), THEN call InsertTransfer with that outcome already
decided. If InsertTransfer returns nil (lost a genuine claim race
to a concurrent request for the same new id), roll back and
re-resolve via checkExistingTransfer against the now-committed
winner. If claimed, and PROCESSED, InsertLedgerEntries, then
COMMIT, then best-effort cache Set (a cache failure must never
fail an already-committed request).
Wallets MUST lock before the claim INSERT, not after, for two
independent reasons:
- Deadlock: the claim INSERT references both wallet FKs, and
Postgres takes an implicit shared FOR KEY SHARE lock on the
referenced rows as part of any INSERT that references them.
If the claim ran first, N concurrent transfers on the same
wallet pair would all acquire that shared lock, then all try
to upgrade to the explicit FOR UPDATE lock at once — a
lock-upgrade deadlock. (A weaker FOR NO KEY UPDATE lock does
NOT avoid this either — it still conflicts with FOR KEY
SHARE per Postgres's own lock-compatibility table.) This must
be verified empirically by the concurrency stress test below,
not assumed correct from reasoning alone.
- Idempotency precedence: checking for an already-committed id
BEFORE touching wallets (step d, above) means a reused id
with a mismatched payload always resolves to 409, regardless
of whether the mismatched payload's wallets happen to be
valid — never a stale WALLET_NOT_FOUND from re-validating
against wallets that were never relevant to the idempotency
question in the first place.
INSUFFICIENT_BALANCE is the only tier-2/3 failure that gets
persisted (as a FAILED row) — its outcome depends on mutable state
(balance can change between deliveries of the same id), so the
answer must be pinned to whatever it was the first time. SAME_WALLET,
INVALID_AMOUNT, and WALLET_NOT_FOUND are deterministic and never
persisted.
internal/handler/validate.go — validateAmount, validateWalletID,
validateIdempotencyKey as plain named functions (not struct-tag
validation), covering tier-1 shape only: amount regex + decimal
parse into minor units (zero passes this check — amount>0 is the
service's job), UUID syntax (not v7-specifically), int64-shaped
idempotency key string.
internal/handler/transfer_handler.go — replace the 501 stubs:
JSON STRING (a bare number would silently corrupt under
JavaScript's Number precision limit for a 63-bit Snowflake ID).
map *domain.Error via errors.As to HTTP status, map unrecognized
errors to 500 with a generic body and full details logged.
field carries the true PROCESSED/FAILED outcome, not the HTTP
status.
builder used identically by POST-fresh, POST-replay, and GET.
Tests:
transactor interfaces (hand-rolled test doubles, not a mocking
framework), covering every branch — same-wallet, invalid-amount,
cache hit replay/mismatch, pre-transaction existing-transfer
replay/mismatch, claim-race replay/mismatch, wallet-not-found,
insufficient-balance, success.
integration): real constraint enforcement (including the FK
constraint actually rejecting an unknown wallet), real FOR UPDATE
serialization (a test that proves a second transaction's lock
attempt genuinely blocks until the first releases), real ON
CONFLICT behavior.
final balance, exactly 2N ledger rows) AND the double-click race
test (two goroutines, same idempotency key: exactly one transfers
row, identical responses) — run both with -race, and run the
concurrency stress test at least a few times in a row before
trusting it, since a lock-ordering bug here can be intermittent.
only, mocked service.
scripts/seed.sql + a
make seedtarget — three wallets with fixed,documented UUIDs and starting balances (including one at zero, for
INSUFFICIENT_BALANCE demos), idempotent (ON CONFLICT DO NOTHING),
for manual/demo curl testing only. NOT a golang-migrate migration —
schema migrations stay data-free. Document the exact UUIDs in
README.md, and rewrite README.md generally to describe the actual
service (requirements, quick start, API reference, config, testing,
project layout, the seed-wallet curl walkthrough) rather than
assignment-template administration instructions.
Function naming: prefer names that describe WHAT a function does over
internal jargon — e.g. InsertTransfer not InsertClaim, executeTransfer
not a synonym invented for its own sake. Ask before renaming an
already-agreed name, don't just do it as a side effect of an unrelated
change.
Comments: never cite docs/DESIGN.md or CLAUDE.md by section number —
state the actual reasoning inline, point-wise and minimal.
One commit per logical step. If any of this flow is ambiguous once
you're actually writing the SQL/Go for it, stop and ask rather than
guessing — this is the most detail-sensitive part of the whole project.
Stop after this phase and confirm: go build ./... succeeds, make lint
and make fmt-check pass, make test (unit) passes, make test-integration
(Docker, real Postgres+Redis via docker compose, -race) passes including
the concurrency and double-click race tests, and a manual curl
walkthrough against seeded wallets (init → transfer → get, replay,
hash-mismatch, insufficient-balance, wallet-not-found) works end to end.
Schema Design
Schema Design
The solution uses PostgreSQL with the following tables:
wallets– Stores wallet balances.transfers– Stores each transfer attempt, its amount, and status.The transfer
iditself doubles as the idempotency key, so noseparate idempotency table is needed — duplicate requests are
detected via a unique constraint on this same column.
ledger_entries– Records the debit and credit entries for everysuccessful transfer (double-entry).
The schema includes primary keys, foreign keys, unique constraints,
check constraints (e.g. non-negative balances, valid transfer status),
and explicit indexes on foreign key columns to ensure data integrity
and query performance.
Idempotency Strategy
Each transfer is preceded by a call to
POST /transfers/init, whichmints a unique
id(used as the idempotency key) with no databasewrite — this lets the same key survive a page refresh or duplicate
button click before the actual transfer is submitted.
idreturned by/initis passed asidempotencyKeyonPOST /transfers.transfers.id— the same column used as theprimary key — guarantees at most one transfer is ever created per key;
no separate idempotency table is needed.
original stored result is returned unchanged.
rejected with a
409 Conflict.including under concurrent/simultaneous resubmission of the same key.
Concurrency Strategy
To prevent race conditions and double spending:
transaction.
SELECT ... FOR UPDATE, always in a consistent (ascending ID) orderacross both wallets, to prevent deadlocks between two different
concurrent transfer pairs.
repeatedly with Go's race detector.
atomically within that same transaction.
transaction is rolled back, ensuring the database remains consistent
and no partial state is ever persisted.
How to Run
cp .env.example .env
docker compose up -d --build
curl localhost:8080/health # {"status":"ok","postgres":"ok","redis":"ok"}
make seed # optional: fixed-UUID demo wallets for manual testing
How to Test
make test # unit tests, mocked repos, no Docker required
make test-integration # real Postgres + Redis via docker-compose,
# includes the concurrency stress test and the
# double-click race test
make lint
make fmt-check
Tradeoffs / Assumptions
Single implicit currency system-wide (no currency column/conversion).
Idempotency keys are minted server-side (
POST /transfers/init) as Snowflake IDs — time-ordered, giving sequential B-tree inserts with no index fragmentation. A hand-crafted or random key is processed identically — the unique constraint, request-hash check, and row locks all apply regardless of an id's provenance, so correctness never depends on where the id came from.No auth/user model — wallets are pre-seeded, not created via the API; POST /transfers/init cannot verify a submitted idempotencyKey was actually issued by a prior call to it, since /init persists nothing
by design.
No distributed tracing (OpenTelemetry) — a single-service system with no cross-service calls doesn't need it; a request-correlation ID threaded through structured slog logs covers the actual debugging need at this scale.
Lint/format/tests verified locally;
Checklist