Skip to content

Solution/nandu#113

Open
Nandukrishna-S wants to merge 40 commits into
Robustrade:mainfrom
Nandukrishna-S:solution/nandu
Open

Solution/nandu#113
Nandukrishna-S wants to merge 40 commits into
Robustrade:mainfrom
Nandukrishna-S:solution/nandu

Conversation

@Nandukrishna-S

@Nandukrishna-S Nandukrishna-S commented Jul 11, 2026

Copy link
Copy Markdown

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

  1. 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.

  2. 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

  1. Transcript / prompts

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:

  1. go.mod (module github.com/Nandukrishna-S/wallet-transfer-assignment)
    and the directory structure exactly as in docs/DESIGN.md §8.

  2. Migrations (golang-migrate, paired .up.sql/.down.sql):

    • wallets: id (UUID v7, PK), balance (DECIMAL, CHECK balance >= 0),
      created_at, updated_at. No user_id, no currency.
    • transfers: id (BIGINT PK), request_hash, from_wallet_id/to_wallet_id
      (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.
    • ledger_entries: id (BIGINT PK), transfer_id (FK -> transfers NOT
      NULL, explicit index), wallet_id (FK -> wallets NOT NULL, explicit
      index), entry_type (CHECK IN ('DEBIT','CREDIT')), amount (CHECK
      amount > 0), created_at.
  3. 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.

  4. internal/platform:

    • postgres: pgxpool.New wrapper. main.go must call pool.Ping(ctx)
      once immediately after construction — on failure, log the error
      and os.Exit(1); the HTTP server must never bind if this fails.
    • redisclient: go-redis/v9 constructor, same fail-fast Ping(ctx)
      requirement as postgres.
    • migrate: golang-migrate library call, auto-runs pending migrations
      at startup, before the server binds. Failure here is also fatal.
    • logger: log/slog setup (structured, JSON handler).
  5. 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.

  6. 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.

  7. internal/handler:

    • router.go — route groups for /transfers/init, /transfers,
      /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).
    • health_handler.go — GET /health: SELECT 1 against postgres and PING
      against redis, each with a ~2s timeout. Both OK → 200
      {"status":"ok","postgres":"ok","redis":"ok"}. Either fails → 503
      with per-dependency status shown.
    • dto.go — amount decodes as json.Number, NOT float64.
    • transfer_handler.go — stub methods returning 501 for now.
  8. 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.

  9. 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-compose v1 binary, which may not be
    installed), .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 .env and .env.* — add a !.env.example
    exception 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 — its
docker-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.

  1. 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.

  2. internal/repository/postgres/wallet_repo.go:

    • GetForUpdate(ctx, tx, walletIDs ...string) — locks wallets ONE ROW
      AT A TIME via sequential SELECT ... WHERE id = $1 FOR UPDATE
      calls, 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 row
      locking 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.
    • UpdateBalance(ctx, tx, walletID, newBalance) — takes the already-
      computed new balance, no sufficiency decision here.
    • Persistence only — no balance-sufficiency decision here, that's
      domain/service.
  3. internal/repository/postgres/transfer_repo.go:

    • InsertTransfer(ctx, tx, id, requestHash, fromWalletID, toWalletID,
      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.
    • FindByID(ctx, id) — no lock, plain read, used both for the
      pre-transaction existing-id check and for GET.
    • InsertLedgerEntries(ctx, tx, transferID, fromWalletID, toWalletID,
      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.
  4. internal/repository/cache/redis_cache.go:

    • Get(ctx, id) / Set(ctx, id, transfer) for the transfer:{id} key —
      single key, immutable, self-healing. Set applies its own TTL +
      jitter internally (a caching-policy concern, not the caller's).
  5. internal/service/ports.go — WalletRepository, TransferRepository,
    Cache, IDGenerator interfaces (consumer-defined, referencing
    repository.Tx for tx parameters) and the TransferRequest struct.

  6. 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.

  7. 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.

  8. internal/handler/transfer_handler.go — replace the 501 stubs:

    • InitTransfer: mint via the service, return transactionId as a
      JSON STRING (a bare number would silently corrupt under
      JavaScript's Number precision limit for a 63-bit Snowflake ID).
    • CreateTransfer: bind DTO, run tier-1 validation, call the service,
      map *domain.Error via errors.As to HTTP status, map unrecognized
      errors to 500 with a generic body and full details logged.
    • GetTransfer: always 200 if found, 404 if not — the body's status
      field carries the true PROCESSED/FAILED outcome, not the HTTP
      status.
    • A shared toTransferView/statusForTransfer/domainErrorHTTPStatus
      builder used identically by POST-fresh, POST-replay, and GET.
  9. Tests:

    • internal/service/transfer_service_test.go: mocked repo/cache/
      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.
    • internal/repository/postgres/*_integration_test.go (//go:build
      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.
    • The concurrency stress test (N goroutines, same wallet pair: exact
      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.
    • internal/handler/transfer_handler_test.go: status-code mapping
      only, mocked service.
  10. scripts/seed.sql + a make seed target — 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 id itself doubles as the idempotency key, so no
    separate 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 every
    successful 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, which
mints a unique id (used as the idempotency key) with no database
write — this lets the same key survive a page refresh or duplicate
button click before the actual transfer is submitted.

  • The id returned by /init is passed as idempotencyKey on
    POST /transfers.
  • A unique constraint on transfers.id — the same column used as the
    primary key — guarantees at most one transfer is ever created per key;
    no separate idempotency table is needed.
  • If the same key is submitted again with the same payload, the
    original stored result is returned unchanged.
  • If the same key is submitted with a different payload, the request is
    rejected with a 409 Conflict.
  • This prevents duplicate transfers and ensures exactly-once behavior,
    including under concurrent/simultaneous resubmission of the same key.

Concurrency Strategy

To prevent race conditions and double spending:

  • All transfer operations are executed inside a single PostgreSQL
    transaction.
  • The source and destination wallet rows are locked using
    SELECT ... FOR UPDATE, always in a consistent (ascending ID) order
    across both wallets, to prevent deadlocks between two different
    concurrent transfer pairs.
  • Wallets are locked before the transfer is claimed. Confirmed data safety via a dedicated concurrency stress test, run
    repeatedly with Go's race detector.
  • Wallet balances, ledger entries, and transfer status are updated
    atomically within that same transaction.
  • If any step fails (insufficient balance, a wallet not found), the
    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

  • Tests pass (locally — make test and make test-integration)
  • Lint passes (locally — make lint)
  • Format check passes (locally — make fmt-check)
  • README or notes updated
  • PR description explains schema, idempotency, and concurrency

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.
@Nandukrishna-S
Nandukrishna-S marked this pull request as ready for review July 11, 2026 09:13
Copilot AI review requested due to automatic review settings July 11, 2026 09:13

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/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

Comment thread internal/handler/dto.go
Comment thread internal/handler/validate.go Outdated
Comment thread internal/service/transfer_service.go
Comment thread internal/service/transfer_service.go
Comment thread internal/service/transfer_service.go
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.

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

Copilot reviewed 51 out of 53 changed files in this pull request and generated 4 comments.

Comment thread internal/service/transfer_service.go
Comment thread internal/handler/transfer_handler.go
Comment thread migrations/0002_create_transfers.up.sql Outdated
Comment thread migrations/0004_ledger_entries_identity_id.up.sql Outdated
Nandukrishna-S and others added 14 commits July 11, 2026 18:39
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>
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