Team-scoped task management API in Go (Gin + pgx + sqlc + PostgreSQL) with JWT auth, idempotent writes, and transactional task assignment.
This is the deliverable for the GDC back-end developer take-home test. It implements:
- JWT authentication (register / login, HS256, bcrypt cost 12, no email enumeration).
- CRUD on
/tasksplusPOST /tasks/:id/assign. - Idempotency on
POST /tasksviaIdempotency-Keyheader. Survives N concurrent duplicate requests using a DB UNIQUE constraint andINSERT ... ON CONFLICT DO UPDATEwith a 30-second lease — exactly one task row is created, even under 100 parallel goroutines. - Transactional assign: update assignee + write
task_logs+ send notification, all inside onepgx.BeginTxFunc. Any step's failure rolls back the entire operation. - Structured JSON access logs with
request_id(UUID v4) attached to every log line for correlation; level rules<400 INFO / 4xx WARN / 5xx ERROR. - Unified error envelope (
status, code, message, timestamp, request_id) emitted by a central middleware; handlers only callc.Error(err). - Unit tests with hand-written mocks (no DB), including a 100-goroutine race-condition proof.
docker compose up --buildThis starts:
postgres(16-alpine) with named volume.migrate(one-shot) that runs all migrations.apilistening onhttp://localhost:8080.
Verify:
curl http://localhost:8080/healthz
# {"status":"ok"}Swagger UI: http://localhost:8080/swagger/index.html
Prerequisites: Go 1.22+, PostgreSQL 16+, sqlc, golang-migrate.
cp .env.example .env # fill in DATABASE_URL and a strong JWT_SECRET (>=32 bytes)
make migrate-up # applies db/migrations
make run # starts the API on $PORT (default 8080)Regenerate sqlc-typed SQL after editing db/queries/*.sql:
make sqlcRegenerate Swagger after editing handler annotations:
make swagAll authenticated routes expect Authorization: Bearer <JWT>. All errors return the same envelope:
{
"status": "error",
"code": "TASK_NOT_FOUND",
"message": "Task not found",
"details": { },
"timestamp": "2026-05-22T10:00:00.123Z",
"request_id": "5f9c4d2e-..."
}| Method | Path | Notes |
|---|---|---|
| POST | /auth/register |
Creates a team (auto if team_name empty) + user, returns JWT |
| POST | /auth/login |
Returns JWT; constant-time on missing user |
curl -X POST http://localhost:8080/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"abduromanov@example.com","password":"secret-password","name":"Abdur","team_name":"Sprout"}'| Method | Path | Notes |
|---|---|---|
| POST | /tasks |
Idempotent. Requires Idempotency-Key: <uuid> header. |
| GET | /tasks |
Filters: status, q (title substring), page (1-based), limit (≤100) |
| GET | /tasks/:id |
Team-scoped — 404 if outside requester's team |
| PUT | /tasks/:id |
Full replace of mutable fields |
| DELETE | /tasks/:id |
Creator only |
| POST | /tasks/:id/assign |
Transactional (assignee + log + notification rollback together) |
POST /tasks requires an Idempotency-Key header in UUID format. The server treats the same key (per user) as the same logical request:
- A second call within the 24-hour window with the same key returns the original
201response byte-for-byte and does not create a new task. This holds regardless of whether the request body matches the first call; if the body differs the server still replays the original response and logsevent=idempotency.body_mismatchat WARN so operators can spot client bugs. - A second call while the first is still in flight returns
409 IDEMPOTENCY_IN_FLIGHT. - After 24 hours the key becomes reclaimable and a fresh
POSTwith the same key creates a new task.
Solid arrows are runtime call flow, dotted arrows are compile-time dependencies on domain. The usecase layer importing nothing from Gin, pgx, or zap is what makes the hand-written mocks possible.
Request lifecycle: RequestID → AccessLog → Recovery → ErrorHandler → SecurityHeaders → BodyLimit → CORS → (route group → Auth | RateLimit) → handler → usecase → repository.
POST /tasks/:id/assign chains four steps inside pgx.BeginTxFunc. Any returned error rolls all of them back atomically.
- Why
ON CONFLICT DO UPDATEfor idempotency (not Redis): Postgres' unique constraint serialises all concurrent attempts through the tuple lock. Of N concurrent goroutines, exactly one row is inserted; the rest observe the existing row and either replay the stored response, see "in-flight", or — if the previous holder crashed — reclaim the lease. No extra dependency, no eventually-consistent cache, and correctness is provable at the SQL layer. The 30-secondlease_expires_atis the recovery mechanism for hard crashes (kill -9, OOM). - Why clean architecture: handlers know HTTP, usecases know business rules, repositories know SQL. The usecase layer imports nothing from gin/pgx/zap, so the unit tests use hand-written fakes that implement the domain ports and exercise every business invariant without spinning up a database.
- Why sqlc: compile-time-typed SQL. Schema changes break the build, not production. SQL stays in
.sqlfiles where DBAs (orEXPLAIN) can read it directly. No risk of ORM-hidden N+1 queries. - Why pgx/v5 (not database/sql): native PostgreSQL types (
pgtype.Timestamptz,uuid.UUID, JSONB), better performance, andpgx.BeginTxFuncfor ergonomic transactions with automatic rollback on error. - Pagination — offset vs keyset: the API uses
LIMIT/OFFSETwithoffset ≤ 1000enforced. Acceptable for a take-home; the production choice would be keyset pagination on(created_at, id)— the existing composite index already supports it.
teams (id, name, created_at)
users (id, email CITEXT UNIQUE, password_hash, name, team_id, created_at, updated_at)
tasks (id, team_id, created_by, assignee_id?, title, description, status enum, priority enum, due_date?, created_at, updated_at)
task_logs (id, task_id, actor_id, action, payload jsonb, created_at)
idempotency_keys (PK(user_id, idempotency_key), request_hash, status_code, response_body jsonb, lease_expires_at, created_at)
make migrate-up # applies all
make migrate-down # reverts the lastMigration files live in db/migrations/ (golang-migrate format: NNN_name.up.sql / NNN_name.down.sql).
| Index | Reason |
|---|---|
tasks(team_id, status, created_at DESC) |
Powers GET /tasks?status=… ordered newest-first, team-scoped |
tasks(created_by) |
Owner-deletion / audit lookups |
tasks(assignee_id) WHERE assignee_id IS NOT NULL |
Partial: most rows start unassigned; smaller, cheaper index |
tasks USING GIN (title gin_trgm_ops) |
Fast ILIKE '%q%' title search |
idempotency_keys(created_at) |
For 24h-window sweeps / observability |
users(email) (unique, CITEXT) |
Case-insensitive uniqueness without LOWER() workarounds |
- bcrypt cost 12 for password hashes.
- JWT HS256, secret enforced ≥32 bytes at startup. Claims include
sub, iss, aud, exp (15m), iat, jti. - No email enumeration on
/auth/login— same 401 response and constant-time path (bcrypt against a dummy hash) whether the email exists or not. - SQL injection: all queries are sqlc-generated with
$Nparameter binding — no string concatenation. - Input validation via
validator.v10(min,max,oneof,email). - 1 MB request-body cap via
http.MaxBytesReader. - Security headers:
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer. - CORS allowlist via
CORS_ORIGINSenv (comma-separated). - Rate-limit
/auth/*with a per-IP token bucket (default 5 rpm). - Log redaction: zap field encoder drops
Authorization,password,password_hash,token,jwt,secretkeys. - HTTP server timeouts:
ReadHeader 5s / Read 10s / Write 15s / Idle 60s. - pgxpool limits:
MaxConns 20,MinConns 2,MaxConnLifetime 30m. - Error envelopes never expose stack traces or internal causes — the full cause is recorded in logs only.
| Field | Type | When |
|---|---|---|
request_id |
uuid str | every log line within a request (echoed as X-Request-ID header) |
method, path, status, latency_ms, client_ip |
- | access log |
user_id, team_id |
uuid str | after JWT validation |
task_id |
uuid str | per task operation |
idempotency_key |
uuid str | POST /tasks |
event |
str | task.created, task.assigned, idempotency.hit, auth.login.failed, server.start, notification.sent, … |
error, error_code |
str | non-2xx responses |
Every request gets a fresh UUID (or the client's X-Request-ID if supplied as a UUID). It is bound to the per-request domain.Logger via logger.Into(ctx, ...) and rides on context.Context everywhere downstream.
make test # plain (no -race)
make test-race # with -race (requires CGO)Tests live in internal/usecase/*_test.go and use hand-written fakes in internal/usecase/mocks/. No database is required. CI runs them with the race detector on every PR.
The mocks expose a txSnapshotter interface and the fake UnitOfWork.InTx snapshots every repository's state before the closure runs, then restores those snapshots if the closure returns an error. That makes the rollback assertions in the assign tests real assertions about transactional behaviour, not just "the error was returned".
Coverage summary:
| Test | What it proves |
|---|---|
TestCreateTask_Idempotency_Sequential |
Same key, same body, second call returns identical bytes; only one task row exists |
TestCreateTask_Idempotency_100ConcurrentSameKey |
100 goroutines on the same key produce exactly one task; every successful caller gets identical bytes; in-flight callers see ErrIdemInFlight |
TestCreateTask_DifferentBody_SameKey_ReturnsFirstResponse |
Same key, different body, still replays the first response and creates no new task (per spec) |
TestCreateTask_BodyShape |
Response JSON matches the documented TaskView schema |
TestAssignTask_HappyPath |
Assign updates assignee, writes task_logs, fires notifier |
TestAssignTask_CrossTeam_Forbidden |
Assign to a user outside the actor's team is 403, with no DB mutation |
TestAssignTask_RollsBack_OnLogFailure |
task_logs insert failure rolls the assignee mutation back too |
TestAssignTask_NotifierFailure_RollsBack |
Notifier failure (last step) rolls the assignee mutation AND the task_logs row back |
internal/usecase/task_usecase_test.go::TestCreateTask_Idempotency_100ConcurrentSameKey fires 100 goroutines at TaskUsecase.Create with the same Idempotency-Key. The test asserts:
- Exactly one task row was inserted (
tasks.InsertCount() == 1). - Every successful goroutine returned the identical response bytes (
require.JSONEq). - Any failed goroutine returned
domain.ErrIdemInFlight— never a duplicate insert.
After docker compose up:
TOKEN=$(curl -sS -X POST http://localhost:8080/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"reviewer@example.com","password":"reviewer-password","name":"R"}' \
| jq -r .access_token)
KEY=$(uuidgen)
# (a) first POST — expect 201
curl -sS -X POST http://localhost:8080/tasks \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"title":"ship it","priority":"high"}' -w "\nHTTP %{http_code}\n"
# (b) replay — expect 201 with IDENTICAL body
curl -sS -X POST http://localhost:8080/tasks \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"title":"ship it","priority":"high"}' -w "\nHTTP %{http_code}\n"
# (c) 50 parallel — expect exactly 1 task row
seq 50 | xargs -P50 -I{} curl -sS -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:8080/tasks \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"title":"ship it","priority":"high"}' | sort | uniq -c
docker compose exec -T postgres psql -U postgres -d tasks -c \
"SELECT count(*) AS task_rows FROM idempotency_keys WHERE idempotency_key='$KEY';"
# expect task_rows = 1| Variable | Default | Notes |
|---|---|---|
PORT |
8080 |
|
LOG_LEVEL |
info |
debug/info/warn/error |
ENV |
development |
production activates gin.ReleaseMode. Log encoding is JSON in every mode (assignment requires structured logs). |
DATABASE_URL |
- | Required. e.g. postgres://user:pass@host:5432/db?sslmode=disable |
DB_MAX_CONNS |
20 |
|
DB_MIN_CONNS |
2 |
|
JWT_SECRET |
- | Required, ≥32 bytes |
JWT_TTL |
15m |
Go duration |
JWT_ISSUER |
tasks-api |
|
JWT_AUDIENCE |
tasks-api |
|
CORS_ORIGINS |
http://localhost:3000 |
Comma-separated allowlist |
AUTH_RATE_LIMIT_RPM |
5 |
Per-IP token bucket for /auth/* |
These were deliberately deferred to stay within the 2x24h window and stay focused on the rubric. They are noted here so reviewers can see the trade-offs explicitly:
- Refresh tokens / token revocation: JWTs carry
jtiso a deny-list is straightforward; no store was wired. - Distributed (Redis-backed) rate limiting: in-memory limiter only works per replica.
- Transactional outbox for notifications: the notifier currently logs a structured event inside the tx. A production system would insert into a
notificationstable and let a worker pick it up after commit. - Integration tests against a real Postgres: assignment requires only unit tests without DB; a future addition would use
testcontainers-go. - Keyset pagination: the index on
(team_id, status, created_at DESC)already supports it; the API offers offset/limit for simplicity. - OpenTelemetry traces: only request_id correlation is implemented.
.
├── cmd/api/main.go # composition root
├── internal/
│ ├── apperr/ # AppError envelope + domain-sentinel mapping
│ ├── auth/ # password hasher + JWT issuer
│ ├── config/ # env-based configuration
│ ├── domain/ # pure types + repository/UoW/Logger/Notifier ports
│ ├── handler/ # Gin handlers + DTOs + router
│ ├── logger/ # zap setup, context helpers, redaction
│ ├── middleware/ # request_id, access log, recovery, error envelope,
│ │ # security headers, body limit, auth, rate limit
│ ├── repository/pg/ # pgx-backed repositories + UnitOfWork
│ │ └── sqlc/ # sqlc-generated typed SQL (do not edit)
│ └── usecase/ # business logic + tests
│ └── mocks/ # hand-written fakes for unit tests
├── db/
│ ├── migrations/ # golang-migrate files
│ └── queries/ # sqlc input
├── docs/ # swaggo-generated OpenAPI
├── .github/workflows/ci.yml
├── Dockerfile # multi-stage, distroless
├── docker-compose.yml # postgres + migrate + api
├── Makefile
├── sqlc.yaml
├── .env.example
├── .golangci.yml
└── README.md


