Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
a72d01e
Initialize Go module targeting go1.24
Nandukrishna-S Jul 10, 2026
a5a8197
Fix go.mod/go.sum to actually pin go1.24-compatible versions
Nandukrishna-S Jul 10, 2026
e946394
Add wallets/transfers/ledger_entries migrations
Nandukrishna-S Jul 10, 2026
56fcc3e
Add Snowflake ID generator
Nandukrishna-S Jul 10, 2026
82fde01
Add domain layer: Wallet, Transfer, Status, typed errors
Nandukrishna-S Jul 10, 2026
3923b48
Add config package with env binding and startup validation
Nandukrishna-S Jul 10, 2026
e46f3c2
Add platform bootstrap: postgres, redisclient, migrate, logger
Nandukrishna-S Jul 10, 2026
5e28b06
Add correlation-id middleware
Nandukrishna-S Jul 10, 2026
596287a
Add HTTP handler layer: router, health check, DTOs, transfer stubs
Nandukrishna-S Jul 10, 2026
80e5267
Wire cmd/server/main.go
Nandukrishna-S Jul 10, 2026
0df1279
Add Docker, Compose, Makefile, lint config, and env template
Nandukrishna-S Jul 10, 2026
6d62ee7
Add migration: ledger_entries.id becomes DB-generated identity
Nandukrishna-S Jul 11, 2026
fcad5c3
Update dependencies for Phase 2
Nandukrishna-S Jul 11, 2026
16d5398
Add IDEMPOTENCY_KEY_REUSED error code
Nandukrishna-S Jul 11, 2026
0c6755a
Drop doc citations from comments, state the point inline
Nandukrishna-S Jul 11, 2026
52f9817
Add repository layer: wallet/transfer persistence, redis cache
Nandukrishna-S Jul 11, 2026
b25e3c8
Add transfer service:
Nandukrishna-S Jul 11, 2026
3f031dd
Rewrite HTTP handler layer for the transfer endpoints
Nandukrishna-S Jul 11, 2026
f2bde80
Wire cmd/server/main.go for the transfer service
Nandukrishna-S Jul 11, 2026
2048d26
Add test-integration and seed Makefile targets, fix docker compose
Nandukrishna-S Jul 11, 2026
7a7a0f8
Add demo seed fixtures and rewrite README for the service
Nandukrishna-S Jul 11, 2026
dc8f6f9
Fix silent overflow in amount-to-minor-units conversion
Nandukrishna-S Jul 11, 2026
b5b876b
Remove internal/service's transitive dependency on Gin
Nandukrishna-S Jul 11, 2026
efe753f
Release wallet locks before the conflict-check read
Nandukrishna-S Jul 11, 2026
df3757e
Reject LOCK_TIMEOUT under 1ms, clamp defensively at point of use
Nandukrishna-S Jul 11, 2026
decbc13
Send amount as a bare JSON number, not a quoted string
Nandukrishna-S Jul 11, 2026
32ad16f
Split AMOUNT_UNREPRESENTABLE from INVALID_AMOUNT
Nandukrishna-S Jul 11, 2026
84dde19
Squash ledger_entries identity column and amount check into base migr…
Nandukrishna-S Jul 11, 2026
78c127c
Fail fast on clock regression in Snowflake ID generation instead of c…
Nandukrishna-S Jul 11, 2026
64881de
Move wallet lock ordering into GetForUpdate itself
Nandukrishna-S Jul 11, 2026
14abd79
Give InitTransfer failures a typed, client-safe error
Nandukrishna-S Jul 11, 2026
831b287
Restore checkCache's fall-back-to-Postgres behavior on Redis errors
Nandukrishna-S Jul 11, 2026
a898fc4
Check cache before Postgres in GetTransfer
Nandukrishna-S Jul 11, 2026
dc8eac6
Combine debit and credit into one UpdateBalances round trip
Nandukrishna-S Jul 11, 2026
4b7938b
Run health checks concurrently
Nandukrishna-S Jul 11, 2026
de11dd3
Give every request validator a typed *domain.Error
Nandukrishna-S Jul 11, 2026
b0e65e6
Remove unused Query from the Querier interface
Nandukrishna-S Jul 11, 2026
1264fb7
Consolidate duplicated integration test fixtures into internal/testutil
Nandukrishna-S Jul 11, 2026
52c2fc8
Move writeCache off the request's hot path
Nandukrishna-S Jul 11, 2026
eb3d74c
Cast UpdateBalances' parameters to numeric explicitly
Nandukrishna-S Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
WORKER_ID=1
POSTGRES_DSN=postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable
REDIS_ADDR=localhost:6379
PORT=8080
LOCK_TIMEOUT=5s
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ build/
.tmp/
.env
.env.*
!.env.example
14 changes: 14 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: "2"

linters:
default: standard
enable:
- unconvert
- unparam
- misspell
- revive

formatters:
enable:
- gofmt
- goimports
26 changes: 26 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM golang:1.24-alpine AS build

WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/wallet-service ./cmd/server

FROM alpine:3.20

RUN apk add --no-cache ca-certificates wget \
&& addgroup -S app && adduser -S app -G app

WORKDIR /app

COPY --from=build --chown=app:app /out/wallet-service ./wallet-service
COPY --chown=app:app migrations ./migrations

USER app

EXPOSE 8080

ENTRYPOINT ["./wallet-service"]
32 changes: 32 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.PHONY: run migrate lint fmt-check test test-integration seed

run:
docker compose up -d postgres redis
go run ./cmd/server

migrate:
go run -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate -path migrations -database "$${POSTGRES_DSN}" up

lint:
golangci-lint run ./...

fmt-check:
@test -z "$$(gofmt -l .)" || (echo "The following files are not gofmt'd:"; gofmt -l .; exit 1)

test:
go test ./...

# Integration tests run against postgres+redis brought up by docker
# compose, not testcontainers — testcontainers' docker-client dependency
# chain forces go1.25+, which conflicts with this project's go1.24
# target. TEST_POSTGRES_DSN/TEST_REDIS_ADDR default to docker-compose.yml's
# ports, override for CI.
test-integration:
docker compose up -d postgres redis
TEST_POSTGRES_DSN="postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable" \
TEST_REDIS_ADDR="localhost:6379" \
go test -race -tags=integration ./...

seed:
docker compose up -d postgres
docker compose exec -T postgres psql -U wallet -d wallet < scripts/seed.sql
182 changes: 157 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,167 @@
# Wallet Transfer Assignment Repository
# Wallet Transfer Service

This repository is a reusable coding assignment template for evaluating backend engineers on wallet transfers, idempotency, concurrency control, and double-entry ledger design.
A Go/Gin service implementing idempotent wallet-to-wallet transfers with
a double-entry ledger. Correctness, transactional safety, and clean
layering take priority over feature breadth.

## Included
- **Idempotent by construction** — a two-step `init` → `transfer` flow
where the minted Snowflake ID doubles as the idempotency key, PK, and
ledger FK target.
- **Concurrency-safe** — wallet locks acquired in a fixed order, verified
under real contention by a concurrency stress test and a double-click
race test, both run with the Go race detector.
- **Double-entry ledger** — exactly two `ledger_entries` rows (one DEBIT,
one CREDIT) per processed transfer, written only when the transfer
actually succeeds.
- **Typed domain errors** mapped to precise HTTP status codes, not a
generic 4xx/5xx catch-all.

- `ASSIGNMENT.md` - candidate-facing prompt
- `.github/pull_request_template.md` - required PR structure
- `.github/workflows/ci.yml` - lint, format, test placeholder workflow
- `.github/workflows/sonarqube.yml` - SonarQube pull request analysis
- `.github/copilot-instructions.md` - repository-level Copilot review guidance
- `evaluation_guide.md` - reviewer rubric
- `branch-protection-checklist.md` - GitHub setup checklist
Full design rationale — schema, the idempotency/locking flow, every
considered and rejected alternative — lives in
[docs/DESIGN.md](./docs/DESIGN.md). This project started from the
take-home brief in [ASSIGNMENT.md](./ASSIGNMENT.md); this README
documents the resulting service, not the assignment process.

## Intended use
## Requirements

1. Mark this repository as a GitHub template repository.
2. Create one private repository per candidate from the template.
3. Add the candidate as a collaborator.
4. Ask them to submit via a pull request into `main`.
5. Enable required checks, SonarQube, and Copilot review in GitHub.
- Go 1.24+
- Docker with the Compose v2 plugin (`docker compose`, not the
deprecated standalone `docker-compose` binary)

## Notes
## Quick start

- Copilot automatic pull request review is configured in GitHub repository or organization settings, not purely through files in the repo.
- The `copilot-instructions.md` file included here provides repository-specific review guidance once Copilot review is enabled.
- The CI workflow is language-agnostic by default and expects you to set the `LINT_CMD`, `FORMAT_CHECK_CMD`, and `TEST_CMD` repository variables or replace the commands directly.
```bash
cp .env.example .env
make run
```

## How to Submit Assignment
`make run` starts Postgres + Redis via Docker Compose, applies pending
migrations automatically, and starts the server on `:8080`.

1. **Fork this repository** to your own GitHub account.
2. Complete the assignment described in [`ASSIGNMENT.md`](./ASSIGNMENT.md).
3. **Raise a Pull Request** back to this repository (`main` branch) with your full solution.
```bash
make seed # inserts demo wallets — see "Manual testing" below
curl -X POST localhost:8080/transfers/init
```

Your PR branch should be named: `solution/<your-name>` (e.g., `solution/jane-doe`).
## API

### `POST /transfers/init`
Mints a fresh idempotency key. Nothing is persisted.

```json
{ "transactionId": "123456789012345678" }
```

`transactionId` is always a JSON **string** — it's a 63-bit Snowflake ID,
which routinely exceeds what JavaScript's `Number` can represent exactly.

### `POST /transfers`

```json
{
"idempotencyKey": "<transactionId from init>",
"fromWalletId": "<uuid>",
"toWalletId": "<uuid>",
"amount": 10.00
}
```

| Status | Meaning |
|---|---|
| `200` | Transfer processed |
| `422` | Domain failure — `SAME_WALLET`, `INVALID_AMOUNT`, `WALLET_NOT_FOUND`, or `INSUFFICIENT_BALANCE` |
| `409` | `idempotencyKey` reused with a different `fromWalletId`/`toWalletId`/`amount` |
| `400` | Malformed request body |

### `GET /transfers/{id}`
Always `200` if the transfer exists, `404` otherwise. The body's
`status` field (`PROCESSED` \| `FAILED`) carries the real outcome — HTTP
status here reflects whether the *lookup* succeeded, not the transfer.

### `GET /health`
`200` if both Postgres and Redis are reachable, `503` otherwise, with
per-dependency status in the body.

## Configuration

Set via environment variables (see `.env.example`):

| Variable | Purpose |
|---|---|
| `WORKER_ID` | Snowflake generator worker ID (0–1023) |
| `POSTGRES_DSN` | Postgres connection string |
| `REDIS_ADDR` | Redis address |
| `PORT` | HTTP port |
| `LOCK_TIMEOUT` | Postgres `lock_timeout` applied per transfer transaction |

## Testing

```bash
make test # unit tests — fast, no Docker required
make test-integration # + real Postgres/Redis via docker compose, run with -race
make lint # golangci-lint
make fmt-check # gofmt
```

`make test-integration` includes the two tests that matter most for a
service like this: a concurrency stress test (N concurrent transfers on
the same wallet pair land on the exact right final balance) and a
double-click race test (two near-simultaneous requests with the same
idempotency key produce exactly one transfer and identical responses).

## Project layout

```
cmd/server wiring only
internal/domain pure business types, no infra imports
internal/platform generic infra bootstrap (postgres/redis/migrate/logger)
internal/config env struct binding
internal/repository persistence only, no business rules
internal/service orchestration — the idempotency/locking flow lives here
internal/middleware cross-cutting concerns, attached per route-group
internal/handler thin HTTP layer + router
internal/idgen Snowflake ID generator
migrations/ paired .up.sql/.down.sql, applied automatically at startup
scripts/ manual/demo fixtures — never used by automated tests
```

See [docs/DESIGN.md](./docs/DESIGN.md) §8 for the full layering rules.

## Manual testing / demo wallets

`make seed` inserts a fixed set of wallets against whatever Postgres
`POSTGRES_DSN` points at (defaults to the one `docker compose up -d
postgres` starts). Re-running it is safe — it never resets a wallet's
balance if the row already exists. Fixtures live in `scripts/seed.sql`,
which is deliberately *not* a golang-migrate migration (schema migrations
stay data-free); this is demo/manual-testing data only.

| Wallet | ID | Starting balance |
|---|---|---|
| Alice | `018f0000-0000-7000-8000-000000000001` | 1000.00 |
| Bob | `018f0000-0000-7000-8000-000000000002` | 500.00 |
| Carol | `018f0000-0000-7000-8000-000000000003` | 0.00 (for `INSUFFICIENT_BALANCE` demos) |

Example walkthrough against a running server (`make run`, then in
another shell):

```bash
# 1. Mint an idempotency key
curl -s -X POST localhost:8080/transfers/init
# → {"transactionId":"<snowflake>"}

# 2. Execute the transfer (Alice -> Bob, 10.00)
curl -s -X POST localhost:8080/transfers \
-H 'Content-Type: application/json' \
-d '{
"idempotencyKey": "<transactionId from step 1>",
"fromWalletId": "018f0000-0000-7000-8000-000000000001",
"toWalletId": "018f0000-0000-7000-8000-000000000002",
"amount": 10.00
}'
# → 200 {"id":"...","status":"PROCESSED",...}

# 3. Fetch it back
curl -s localhost:8080/transfers/<id from step 2>
# → 200 {"id":"...","status":"PROCESSED",...}
```
Loading