A REST API in Go that computes gross-to-net pay with mock tax withholding. Built as a hands-on project to deepen Go fluency and explore patterns common in financial calculation services.
Note: This is a personal learning project, not a production payroll system. Tax calculations use simplified mock rates; real-world payroll requires jurisdiction-specific tax tables, compliance handling, and audit trails.
- Language: Go 1.22+
- Web framework: Gin
- Database: PostgreSQL 16
- Containerization: Docker, Docker Compose
- Testing: Go standard
testingpackage,httptest - Logging:
log/slog(structured logging)
Given an employee's hourly rate and hours worked, the service:
- Computes gross pay
- Applies mock tax withholdings (federal, social security, medicare)
- Returns a payslip with the breakdown
- Persists the payslip to PostgreSQL
- Allows retrieval of past payslips by ID or by employee
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │─────▶│ Gin Router │─────▶│ Handlers │
└─────────────┘ └──────────────┘ └──────┬───────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌───────────────┐
│ Calculator │ │ Storage │ │ Validation │
│ (payroll) │ │ (postgres) │ │ (input) │
└───────────────┘ └──────┬───────┘ └───────────────┘
│
▼
┌──────────────┐
│ PostgreSQL │
└──────────────┘
The project follows a layered architecture: HTTP handlers are thin and delegate to the payroll package (business logic) and the storage package (persistence). The calculator has no knowledge of HTTP or the database, making it easy to test in isolation.
.
├── cmd/
│ ├── cli/ # Command-line interface (Milestone 1)
│ │ └── main.go
│ └── server/ # HTTP server (Milestone 2+)
│ └── main.go
├── internal/
│ ├── payroll/ # Core calculation logic
│ │ ├── calculator.go
│ │ └── calculator_test.go
│ ├── handlers/ # HTTP handlers (Gin)
│ │ ├── api.go # API struct + consumer-side store interface
│ │ ├── router.go
│ │ ├── employee.go
│ │ ├── payslip.go
│ │ ├── health.go
│ │ ├── dto.go # Request/response DTOs + dollar↔cent conversion
│ │ ├── *_test.go
│ │ └── store_fake_test.go # In-memory fake for handler tests
│ └── storage/ # PostgreSQL persistence (pgx/v5)
│ ├── postgres.go
│ └── postgres_test.go # Integration tests, gated by -short / TEST_DATABASE_URL
├── migrations/
│ └── 001_init.sql # Database schema (also auto-applied on first DB boot via compose)
├── deploy/
│ └── k8s/ # Kubernetes manifests (optional, Milestone 6)
├── Dockerfile # Multi-stage build: golang:1.26-alpine → distroless static
├── docker-compose.yml # Local dev stack: app + Postgres
├── .dockerignore
├── go.mod
├── go.sum
├── .env.example
└── README.md
The fastest way to run the service. You need Docker Desktop (or Docker Engine + Compose v2).
git clone https://github.com/mritunjayupadhyay/go-payroll-api.git
cd go-payroll-api
docker compose up --buildThat starts two containers:
db— Postgres 16, withmigrations/001_init.sqlauto-applied on first bootapp— the API onhttp://localhost:8080, waits for the DB healthcheck before starting
Sanity check from another terminal:
curl http://localhost:8080/health
# {"status":"ok"}
curl -X POST http://localhost:8080/api/employees \
-H 'Content-Type: application/json' \
-d '{"name":"Jane Doe","hourly_rate":25.00}'
curl -X POST http://localhost:8080/api/payslips/calculate \
-H 'Content-Type: application/json' \
-d '{"employee_id":1,"hours_worked":40}'Tear-down:
docker compose down # stop containers, keep DB data in the named volume
docker compose down -v # also wipe the DB volume (next `up` re-applies migrations from scratch)Image: the runtime stage is built on gcr.io/distroless/static-debian12:nonroot. The final image is ~25 MB and runs as a non-root user with no shell or package manager.
Note on migrations in compose: migrations/ is mounted into Postgres' /docker-entrypoint-initdb.d, which runs the SQL files exactly once on a fresh data directory. This is intentionally simple for local dev — a production deployment would use a real migration tool (e.g. golang-migrate, goose).
- Go 1.22 or later
- PostgreSQL 16 (or run via Docker)
- Make (optional, for convenience commands)
# Install dependencies
go mod download
# Copy environment template
cp .env.example .env
# Edit .env with your database credentials
# Run database (via Docker)
docker run --name payroll-db \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=payroll \
-p 5432:5432 \
-d postgres:16
# Apply the schema (psql lives in the container, so no host install needed)
docker exec -i payroll-db psql -U postgres -d payroll < migrations/001_init.sql
# Run the server (reads DATABASE_URL from the environment)
DATABASE_URL='postgres://postgres:password@localhost:5432/payroll?sslmode=disable' \
go run ./cmd/server# Fast suite — handler tests use an in-memory fake store, no DB required.
go test -short ./...
# Full suite — runs the storage integration tests against a real Postgres.
docker exec payroll-db psql -U postgres -c 'CREATE DATABASE payroll_test;'
TEST_DATABASE_URL='postgres://postgres:password@localhost:5432/payroll_test?sslmode=disable' \
go test ./...
# Coverage
go test -cover ./...The storage package's TestMain re-applies migrations/001_init.sql on each
run, and each test truncates with RESTART IDENTITY for a clean slate.
curl http://localhost:8080/healthResponse:
{ "status": "ok" }curl -X POST http://localhost:8080/api/employees \
-H "Content-Type: application/json" \
-d '{"name": "Jane Doe", "hourly_rate": 25.00}'Response (201 Created):
{
"id": 1,
"name": "Jane Doe",
"hourly_rate": 25,
"created_at": "2026-04-26T02:22:35.906633+07:00"
}curl http://localhost:8080/api/employees/1Returns the same shape as POST /api/employees. 404 if the employee does not exist.
curl -X POST http://localhost:8080/api/payslips/calculate \
-H "Content-Type: application/json" \
-d '{"employee_id": 1, "hours_worked": 40}'Response (201 Created):
{
"id": 1,
"employee_id": 1,
"hours_worked": 40,
"gross_pay": 1000,
"federal_tax": 120,
"social_security": 62,
"medicare": 14.5,
"net_pay": 803.5,
"created_at": "2026-04-26T02:22:36.004076+07:00"
}The endpoint:
- Looks up the employee (
404if not found) - Computes the payslip from the employee's stored
hourly_rateand the request'shours_worked - Persists the result and returns it with its new ID
Validation errors return 400 {"error": "..."} for: missing/zero employee_id, negative hours_worked, or malformed JSON.
curl http://localhost:8080/api/payslips/1Returns the same shape as the calculate response. 404 if not found.
curl http://localhost:8080/api/employees/1/payslipsReturns an array of payslips for that employee, ordered by created_at descending. Always returns [] (not null) when empty.
DATABASE_URL='postgres://postgres:password@localhost:5432/payroll?sslmode=disable' \
go run ./cmd/server
# server listens on :8080The service uses simplified, illustrative tax rates. These are not real tax rates and should not be used for actual payroll.
| Tax Type | Rate |
|---|---|
| Federal | 12.00% |
| Social Security | 6.20% |
| Medicare | 1.45% |
A real payroll system would account for filing status, exemptions, year-to-date earnings, jurisdiction-specific rules, pre-tax deductions, and many other factors.
The project ships a small CLI that runs the calculation locally — no HTTP, no database. Useful as a quick sanity check and as a demonstration of the core logic in isolation.
go run ./cmd/cli -name="Jane" -rate=25 -hours=40Output:
Payslip for Jane
Gross: $1000.00
Federal Tax: $120.00
Social Security: $62.00
Medicare: $14.50
Net: $803.50
Go compiles to a single static binary with no runtime dependency.
go build -o bin/payroll-cli ./cmd/cli
./bin/payroll-cli -name="Jane" -rate=25 -hours=40The binary is self-contained — copy it anywhere and run it.
| Flag | Type | Description |
|---|---|---|
-name |
string | Employee name (required) |
-rate |
float64 | Hourly rate in dollars (required, > 0) |
-hours |
float64 | Hours worked (required, ≥ 0) |
Bad input is rejected with a stderr message and exit code 1:
$ go run ./cmd/cli -rate=25 -hours=40
error: name is required
$ echo $?
1go test ./... # all packages
go test -v ./internal/payroll # verbose, calculator only
go test -cover ./... # with coverageFill this section in as you build the project — interviewers love it.
- Idiomatic Go vs. Node patterns: [e.g., explicit error returns instead of try/catch, struct composition instead of class inheritance, interfaces being implicitly satisfied]
- Concurrency: [if you use goroutines anywhere, note what you learned]
- Standard library depth: [Go's stdlib covers a lot of ground that requires libraries in Node — note what you found useful]
- Build and deployment: [single binary output, multi-stage Docker builds, image size]
- Testing: [table-driven tests, the testing package, httptest]
- Core calculation logic with unit tests
- REST API with input validation
- PostgreSQL persistence with migrations and integration tests
- Dockerized deployment (multi-stage build, docker-compose)
- Kubernetes manifests for local cluster
- Structured logging with request tracing
- Configurable tax rates via environment / config file
- Authentication (JWT)
- Rate limiting
MIT — feel free to use this as a reference for your own learning.
Built by Mritunjay Upadhyay — Senior Full-Stack Engineer based in Bangkok. Reach me at mupadhyay00@gmail.com.