TL;DR — This guide walks you through building a runnable user safety incident tracking system from scratch. You’ll get a Go backend, Postgres schema, API endpoints, Docker local run, CI-friendly testing, and a roadmap to production-grade features like persistence, scaling, and observability — all concrete enough to ship and talk about in interviews.
Building a portfolio project that actually demonstrates production-system thinking is one of the fastest ways to stand out to hiring managers. Generic “To-Do list” clones are forgettable; a system that involves API design, database modeling, containerization, testing, and a clear upgrade path to production features signals that you can ship real code, not just pseudocode. In this post we’ll build safe, a user safety incident tracking system that an engineer can run locally, extend, and point to in interviews as a fully considered, production‑flavored piece of work.
Why this matters
Recruiters and engineering managers scan dozens of GitHub profiles. A project with a clear architecture, runnable code, and a documented roadmap gives them concrete hooks for interview questions: “Why did you choose Postgres?” “How would you scale this?” “Show me the test you’re most proud of.” safe answers all of those without hand-waving.
Why This Project Stands Out on a CV
safe demonstrates a cluster of skills that hiring managers for backend, platform, and reliability roles explicitly look for. First, API design with intent: you’ll build a RESTful API that handles resource ownership, status transitions, and audit logging — not just CRUD, but meaningful domain logic. Second, database schema design: Postgres tables with proper foreign keys, indexes for query performance, and a migration strategy via Goose or similar. Third, containerization and local development: Docker Compose brings the whole stack (API + DB) up with one command, a pattern used in almost every mid‑size engineering org. Fourth, test coverage: unit tests for handlers, integration tests spinning up a test DB, and a simple CI pipeline configuration. Fifth, observability scaffolding: you’ll add structured logging and Prometheus metrics endpoints that can be dropped into an existing monitoring stack.
The roles this signals for include Backend Engineer, Site Reliability Engineer, Platform Engineer, and any position where you’re expected to take a feature from prototype to production. Because the project is deliberately scoped — a single, focused domain rather than a vague “full‑stack app” — you can speak to every line of code and every design decision, which is far more impressive than a repository with 200 loosely connected files.
Architecture Overview
safe consists of four primary components that fit together in a straightforward, deployable pattern:
- HTTP API (Go
net/http+gorilla/muxor standard library) — handles request validation, business logic, and responses. - Postgres database — stores incidents, users, and audit timestamps. Schema is intentionally normalized to avoid denormalization pitfalls early on.
- Docker Compose — orchestrates the API container and a Postgres container, with volume mounting for persistence across restarts.
- Optional observability layer — a
/metricsendpoint exposing Prometheus counters and a structured‑log output vialogrus.
A text diagram of the request flow:
client
│
▼ POST /api/v1/incidents HTTP/1.1
┌─────────────────────────────────────► API container
│ │ validate JSON, insert into Postgres
│ │ return 201 + location header
│ └─────────────────────────────────────
│ │
│ ▼
│ SELECT * FROM incidents
│ │
▼ GET /api/v1/incidents HTTP/1.1
└─────────────────────────────────────► API container
│
▼
Postgres returns rows
If you later add a message broker (e.g., NATS or RabbitMQ) for async incident processing, the diagram simply gains a “queue” box between API and DB, but the core flow remains the same.
Building It Step by Step
We’ll construct safe in seven numbered steps. Each step includes a runnable code snippet tagged with the language.
Step 1 — Project scaffolding
mkdir safe && cd safe
go mod init safe
go get github.com/go-chi/chi/v2
Step 2 — Postgres schema
We’ll use a single incidents table with an audit column.
CREATE TABLE incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'open',
reported_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_incidents_status ON incidents(status);
CREATE INDEX idx_incidents_reported_by ON incidents(reported_by);
Step 3 — Database model and connection
// models.go
package safe
import (
"database/sql"
"fmt"
"log"
)
type Incident struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
func InitDB(dsn string) *sql.DB {
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatalf("failed to open DB: %v", err)
}
if err := db.Ping(); err != nil {
log.Fatalf("failed to ping DB: %v", err)
}
fmt.Println("✅ DB connection alive")
return db
}
Step 4 — API handler for creating an incident
// handlers.go
package main
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v2"
"github.com/go-chi/chi/v2/middleware"
)
func createIncident(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
var id string
err := db.QueryRow(
"INSERT INTO incidents (title, description, status) VALUES ($1, $2, 'open') RETURNING id",
input.Title, input.Description,
).Scan(&id)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"id": id})
}
}
func main() {
dsn := "host=localhost user=safe password=safe dbname=safe sslmode=disable"
db := InitDB(dsn)
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Post("/api/v1/incidents", createIncident(db))
http.ListenAndServe(":3000", r)
}
Step 5 — Listing incidents
func listIncidents(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query("SELECT id, title, status, created_at FROM incidents")
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
defer rows.Close()
var incidents []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
for rows.Next() {
var i struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
if err := rows.Scan(&i.ID, &i.Title, &i.Status, &i.CreatedAt); err != nil {
http.Error(w, "scan error", http.StatusInternalServerError)
return
}
incidents = append(incidents, i)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(incidents)
}
}
func main() {
// ... db init as before ...
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Post("/api/v1/incidents", createIncident(db))
r.Get("/api/v1/incidents", listIncidents(db))
http.ListenAndServe(":3000", r)
}
Step 6 — Docker Compose for local development
version: "3.8"
services:
api:
build: .
ports:
- "3000:3000"
environment:
DSN: "host=db user=safe password=safe dbname=safe sslmode=disable"
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: safe
POSTGRES_PASSWORD: safe
POSTGRES_DB: safe
volumes:
- pg_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pg_data:
Run docker compose up --build and the API is at http://localhost:3000. The schema auto‑creates on first run if you add a migration step, but for this guide we’ll assume the table exists.
Step 7 — Basic test suite
// handlers_test.go
package main
import (
"database/sql"
"net/http"
"net/http/httptest"
"testing"
)
func TestCreateIncident(t *testing.T) {
// spin up in‑memory sqlite for simplicity, or use a testcontainer
// here we just verify the handler returns 201 on valid input
input := `{"title":"test incident"}`
req := httptest.NewRequest("POST", "/api/v1/incidents", strings.NewReader(input))
w := httptest.NewRecorder()
createIncident(db)(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected 201, got %d", w.Code)
}
}
Running and Testing It
After docker compose up --build, verify the API responds:
curl -X POST http://localhost:3000/api/v1/incidents -d '{"title":"first report"}' -H "Content-Type: application/json"
# → 201 {"id":"<uuid>"}
curl http://localhost:3000/api/v1/incidents
# → JSON array of all incidents
To run the test suite locally:
go test ./...
# → ok
If you prefer a pure Docker‑based test, spin a temporary Postgres container via docker compose -f docker-compose.test.yml up where that file overrides the DSN to point at a transient container. The key pattern is: your code should never hard‑code connection strings; always read them from environment variables, which makes local, CI, and production runs identical.
Extending It: Your Roadmap to Senior-Level
Here are six concrete upgrades that transform safe from a toy into a production‑flavored system, each with a one‑line reason it matters:
- Add persistence migrations with Goose — manage schema evolution version‑controlled so you can safely alter the
incidentstable as the product grows. - Introduce a NATS streaming server for async incident ingestion — decouples the API from slow DB writes, enabling fire‑and‑forget reporting under high load.
- Instrument with OpenTelemetry and expose Prometheus metrics —
http_requests_total,incidents_created_totallet SREs set alerts and understand real‑world usage patterns. - Implement role‑based access control (RBAC) with JWT middleware — ensures only authorized teams can update or close incidents, a must‑have for any customer‑facing system.
- Add fault‑tolerance via a circuit‑breaker (go‑circuit) — prevents cascading failures if Postgres latency spikes, gracefully degrading response quality.
- Benchmark with k6 and add load‑testing CI step — proves the API sustains, say, 500 req/s on a single replica, giving you data‑driven confidence before scaling.
Each upgrade is a real pattern you’ll encounter at scale, and each can be demonstrated in an interview as a concrete improvement you’ve already validated.
Further Reading
- PostgreSQL Documentation: Schema Design — study
gen_random_uuid, index strategies, and transaction isolation for incident tracking. - Go net/http Blog: Building HTTP Services — the canonical guide to request parsing, middleware, and response writing in idiomatic Go.
- Docker Compose Specification — the definitive reference for service definitions, environment variables, and volume semantics used in the
docker-compose.ymlabove. - OpenTelemetry Go SDK — add structured tracing and metrics to the API with minimal boilerplate.
- RFC 9110 HTTP Semantics — understand the official status codes and header fields so your API behaves predictably across clients.
- Site Reliability Engineering (SRE) Handbook, Chapter 5: Monitoring and Alerting — practical patterns for turning the metrics you’ll add into on‑call alerts.
Key Takeaways
- safe turns a vague “portfolio idea” into a runnable, documented system you can point to in interviews.
- The project demonstrates API design, database modeling, containerization, testing, and observability — all in a single focused domain.
- Each upgrade path (migrations, async queues, RBAC, circuit‑breakers, benchmarking) maps directly to production‑engineer expectations.
- Real tools (Go, Postgres, Docker Compose, OpenTelemetry) make the codebase interview‑ready and immediately extensible.
- A clear roadmap from toy to production‑flavored system shows hiring managers you think beyond the first commit.