Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
.github
.agents
.codex
coverage.out
README.md
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ build/
.tmp/
.env
.env.*
coverage.out
wallet-api
*.exe
.idea/
.vscode/
177 changes: 152 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,162 @@
# 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 PostgreSQL-backed wallet transfer API written in Go. The implementation uses
Clean Architecture boundaries, stored wallet balances, an immutable
double-entry ledger, transactional idempotency, and row-level locking.

## Included
## Architecture

- `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
```text
HTTP handler -> transfer service -> repository contracts -> PostgreSQL adapter
|
domain
```

## Intended use
- `cmd/api`: dependency wiring and HTTP server lifecycle
- `internal/domain`: wallet, transfer, ledger, and state-transition models
- `internal/service`: transfer orchestration and business rules
- `internal/repository`: persistence and transaction contracts
- `internal/repository/postgres`: PostgreSQL implementation
- `internal/handler/http`: request validation and HTTP mapping
- `migrations`: database schema
- `tests/integration`: PostgreSQL behavior and concurrency tests

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.
Amounts are signed 64-bit integers in the currency's smallest unit. Floating
point values are intentionally not accepted.

## Notes
## Transaction and concurrency model

- 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.
Each new request runs in one PostgreSQL transaction:

## How to Submit Assignment
1. Insert the idempotency key and canonical request hash.
2. Create a `PENDING` transfer and link it to the idempotency record.
3. Lock both wallets with `SELECT ... FOR UPDATE`, ordered by wallet ID.
4. Validate funds, update both stored balances, and insert one `DEBIT` plus one
`CREDIT` ledger entry.
5. Move the transfer to `PROCESSED`, or commit a deterministic business failure
as `FAILED`.

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.
The primary key on `idempotency_records.idempotency_key` serializes concurrent
duplicates. A duplicate with the same request hash reads the original terminal
transfer. Reusing the key for a different payload returns `409 Conflict`.
Infrastructure failures roll back the entire transaction and are safe to retry.

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

Requirements: Docker with Compose support.

```bash
docker compose up --build
```

The migration is applied automatically when PostgreSQL initializes. Seed two
wallets:

```bash
docker compose exec postgres psql -U wallet -d wallet -c \
"INSERT INTO wallets (id, balance) VALUES ('wallet_1', 1000), ('wallet_2', 250);"
```

Create a transfer:

```bash
curl -i http://localhost:8080/transfers \
-H "Content-Type: application/json" \
-d '{
"idempotencyKey": "abc123",
"fromWalletId": "wallet_1",
"toWalletId": "wallet_2",
"amount": 100
}'
```

Re-send the same request to receive the original result without another debit.
Change the amount while retaining `abc123` to receive `409 Conflict`.

To delete local data and rerun the initialization migration:

```bash
docker compose down -v
```

## Run locally

Start PostgreSQL and apply the migration:

```bash
psql "$DATABASE_URL" -f migrations/001_init.sql
```

Set the database URL and run the API:

```bash
export DATABASE_URL='postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable'
go run ./cmd/api
```

On PowerShell:

```powershell
$env:DATABASE_URL = 'postgres://wallet:wallet@localhost:5432/wallet?sslmode=disable'
go run ./cmd/api
```

The API listens on `:8080` by default. Override it with `HTTP_ADDRESS`.

## API

### `POST /transfers`

Request:

```json
{
"idempotencyKey": "abc123",
"fromWalletId": "wallet_1",
"toWalletId": "wallet_2",
"amount": 100
}
```

Responses:

- `201 Created`: transfer is `PROCESSED`
- `400 Bad Request`: invalid input or JSON
- `404 Not Found`: one or both wallets do not exist; transfer is `FAILED`
- `409 Conflict`: idempotency key was used with a different payload
- `422 Unprocessable Entity`: insufficient balance; transfer is `FAILED`
- `500 Internal Server Error`: transaction was rolled back and may be retried

`GET /healthz` provides a process health endpoint.

## Tests

Run unit tests:

```bash
go test ./internal/...
```

Run every test. Integration tests skip when no database URL is configured:

```bash
go test ./...
```

Run PostgreSQL integration and concurrency tests against a dedicated test
database:

```bash
export INTEGRATION_DATABASE_URL='postgres://wallet:wallet@localhost:5432/wallet_test?sslmode=disable'
go test ./tests/integration -count=1
```

PowerShell:

```powershell
$env:INTEGRATION_DATABASE_URL = 'postgres://wallet:wallet@localhost:5432/wallet_test?sslmode=disable'
go test ./tests/integration -count=1
```

The integration suite truncates the wallet service tables between tests. Do not
point it at a database containing data you need.
75 changes: 75 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"

httpapi "github.com/sumiyapuletipalli/wallet-transfer-assignment/internal/handler/http"
"github.com/sumiyapuletipalli/wallet-transfer-assignment/internal/platform/config"
"github.com/sumiyapuletipalli/wallet-transfer-assignment/internal/platform/database"
"github.com/sumiyapuletipalli/wallet-transfer-assignment/internal/platform/logging"
"github.com/sumiyapuletipalli/wallet-transfer-assignment/internal/repository/postgres"
"github.com/sumiyapuletipalli/wallet-transfer-assignment/internal/service"
)

func main() {
logger := logging.New()
if err := run(logger); err != nil {
logger.Error("application stopped", "error", err)
os.Exit(1)
}
}

func run(logger *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return err
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

pool, err := database.OpenPostgres(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()

unitOfWork := postgres.NewUnitOfWork(pool)
transferService := service.NewTransferService(unitOfWork)
handler := httpapi.NewHandler(transferService, logger)

server := &http.Server{
Addr: cfg.HTTPAddress,
Handler: handler.Routes(),
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
}

serverError := make(chan error, 1)
go func() {
logger.Info("HTTP server started", "address", cfg.HTTPAddress)
serverError <- server.ListenAndServe()
}()

select {
case err := <-serverError:
if !errors.Is(err, http.ErrServerClosed) {
return err
}
case <-ctx.Done():
logger.Info("shutdown requested")
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return err
}
}
return nil
}
15 changes: 15 additions & 0 deletions deployments/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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 -ldflags="-s -w" -o /out/wallet-api ./cmd/api

FROM alpine:3.21

RUN addgroup -S app && adduser -S app -G app
COPY --from=build /out/wallet-api /usr/local/bin/wallet-api
USER app
EXPOSE 8080
ENTRYPOINT ["wallet-api"]
38 changes: 38 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: wallet
POSTGRES_USER: wallet
POSTGRES_PASSWORD: wallet
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wallet -d wallet"]
interval: 2s
timeout: 3s
retries: 15

api:
build:
context: .
dockerfile: deployments/docker/Dockerfile
environment:
DATABASE_URL: postgres://wallet:wallet@postgres:5432/wallet?sslmode=disable
HTTP_ADDRESS: :8080
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"]
interval: 5s
timeout: 3s
retries: 10

volumes:
postgres_data:
14 changes: 14 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module github.com/sumiyapuletipalli/wallet-transfer-assignment

go 1.24.0

require github.com/jackc/pgx/v5 v5.7.5

require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
28 changes: 28 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
12 changes: 12 additions & 0 deletions internal/domain/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package domain

import "errors"

var (
ErrInvalidIdempotencyKey = errors.New("idempotency key is required and must not exceed 255 characters")
ErrInvalidWalletID = errors.New("wallet IDs are required and must not exceed 255 characters")
ErrSameWallet = errors.New("source and destination wallets must differ")
ErrInvalidAmount = errors.New("amount must be greater than zero")
ErrInvalidTransition = errors.New("invalid transfer state transition")
ErrIdempotencyConflict = errors.New("idempotency key was already used with a different request")
)
7 changes: 7 additions & 0 deletions internal/domain/idempotency.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package domain

type IdempotencyRecord struct {
Key string
RequestHash string
TransferID string
}
Loading