TL;DR — Build a Go‑based CV API that serves JSON résumé data, adds rate‑limiting and Redis caching, persists to PostgreSQL, and exposes Prometheus metrics and OpenTelemetry traces. By the end you’ll have a Dockerized service that showcases real‑world systems skills to hiring managers.
Introduction
Hiring managers scan dozens of GitHub repos; a project that runs in production, exports metrics, and demonstrates production‑grade patterns instantly differentiates an engineer. In this post you’ll construct a self‑hosted Go service that serves your JSON résumé while weaving in rate limiting, caching, persistence, and observability. Every step uses real, runnable code and familiar tools—Go, Docker, PostgreSQL, Redis, Prometheus, and OpenTelemetry—so you can clone the repo, run it locally, and extend it into a senior‑level portfolio piece.
Why This Project Stands Out on a CV
This project signals several high‑value skill sets that hiring managers look for in backend and SRE candidates:
- Concurrent Go programming – the service uses goroutines for request handling, a pool of DB connections, and a rate‑limiting token bucket, demonstrating safe shared‑memory concurrency.
- Middleware composition – you’ll build a stack of middleware (rate limiting, caching, metrics) that can be reused across other services, a pattern common at scale.
- Production‑grade persistence – PostgreSQL with connection pooling (
database/sql+lib/pq) and prepared statements shows you can reliably store and query data. - Distributed caching – Redis integration teaches cache‑aside patterns, TTL management, and fallback strategies.
- Observability by default – Prometheus metrics and OpenTelemetry traces are not afterthoughts; they’re built into the request lifecycle, a habit that separates junior from senior engineers.
- Containerization & orchestration readiness – a Dockerfile and
docker‑compose.ymlmake the service instantly runnable and deployable to any environment, from a laptop to Kubernetes.
Roles that benefit most include backend engineer, DevOps/SRE, and full‑stack engineer positions where you’re expected to ship reliable, observable services.
Architecture Overview
The system consists of a single Go HTTP service that orchestrates several independent components:
- HTTP layer –
net/httpwithmuxfor routing; middleware chain applies rate limiting, caching, and metrics. - Persistence – PostgreSQL stores the canonical résumé JSON; connection pool managed by
sql.DB. - Cache – Redis holds a short‑lived copy of the résumé to avoid DB hits on every request.
- Metrics – Prometheus‑compatible counters and gauges exposed at
/metrics(request duration, cache hit/miss, DB query latency). - Tracing – OpenTelemetry SDK creates spans for each inbound request, exported to a local Jaeger collector for visualisation.
- Instrumentation – middleware wraps the handler to record metrics and start/finish spans.
+-------------------+ +-------------------+ +-------------------+
| Client (curl) | --> | Go HTTP Server | --> | PostgreSQL |
+-------------------+ +-------------------+ +-------------------+
^ ^ ^ ^
| +---------- Rate Limiting -----+ |
+--+-- Redis Cache (cache-aside) +-----+
|
+-------------------+
| Prometheus /metrics|
+-------------------+
|
+-------------------+
| OpenTelemetry |
| Traces /jaeger |
+-------------------+
The diagram highlights that the critical path is: client → Go server → (cache check → DB if miss) → response, while middleware simultaneously records metrics and tracing data.
Building It Step by Step
Below are eight numbered steps, each with a focused code snippet (language‑tagged) that you can copy‑paste into your project. Start with a fresh directory and run go mod init cvapi before proceeding.
Step 1 – Scaffold the Go module and install dependencies
go mod init cvapi
go get github.com/go-redis/redis/v8
go get github.com/prometheus/client_golang/prometheus
go get go.uber.org/atomic
go get github.com/open-telemetry/opentelemetry-go/sdk/trace/sampler
Why: Explicit dependencies make the go.sum file reproducible and simplify CI builds.
Step 2 – Define the résumé struct and a PostgreSQL store
package main
import (
"database/sql"
"log"
"encoding/json"
_ "github.com/lib/pq"
)
type Resume struct {
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
Summary string `json:"summary"`
Experience []Experience `json:"experience"`
Education []Education `json:"education"`
Skills []string `json:"skills"`
}
type Experience struct {
Company string `json:"company"`
Position string `json:"position"`
StartYear int `json:"start_year"`
EndYear *int `json:"end_year,omitempty"`
Description string `json:"description"`
}
type Education struct {
School string `json:"school"`
Degree string `json:"degree"`
GradYear int `json:"grad_year"`
}
// Store abstracts PostgreSQL interactions.
type Store struct {
db *sql.DB
}
// NewStore opens a pooled connection.
func NewStore(dsn string) (*Store, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
return &Store{db: db}, nil
}
// UpsertResume stores or updates the résumé JSON.
func (s *Store) UpsertResume(r *Resume) error {
b, err := json.Marshal(r)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO resumes (payload) VALUES ($1) ON CONFLICT (id) DO UPDATE SET payload = $1`, string(b))
return err
}
// GetResume fetches the résumé JSON and unmarshals it.
func (s *Store) GetResume() (*Resume, error) {
var payload string
err := s.db.QueryRow(`SELECT payload FROM resumes LIMIT 1`).Scan(&payload)
if err != nil {
return nil, err
}
var r Resume
if err := json.Unmarshal([]byte(payload), &r); err != nil {
return nil, err
}
return &r, nil
}
Why: Using json.Marshal/Unmarshal keeps the data format simple for API consumers, while the Store pattern isolates DB logic for later testing or replacement.
Step 3 – Initialise a Redis client for cache‑aside lookups
import (
"github.com/go-redis/redis/v8"
"context"
)
type Cache struct {
client *redis.Client
}
func NewCache(addr string, password string, db int) *Cache {
return &Cache{
client: redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
}),
}
}
// GetResumeJSON returns the cached JSON or "" if missing.
func (c *Cache) GetResumeJSON(ctx context.Context) (string, error) {
val, err := c.client.Get(ctx, "resume:json").Result()
return val, err // err == redis.Nil means miss
}
// SetResumeJSON stores the JSON with a 5‑minute TTL.
func (c *Cache) SetResumeJSON(ctx context.Context, payload string) error {
return c.client.Set(ctx, "resume:json", payload, 5*time.Minute).Err()
}
Why: Cache‑aside pattern reduces DB load and illustrates a classic distributed‑system technique.
Step 4 – Implement a rate‑limiting middleware using the token bucket
import (
"net/http"
"time"
"golang.org/x/time/rate"
)
// limiter holds per‑client tokens.
type limiter struct {
tokens map[string]*rate.Limiter
mu sync.Mutex
}
// newLimiter returns a new rate limiter with 10 requests per second per IP.
func newLimiter() *limiter {
return &limiter{
tokens: make(map[string]*rate.Limiter),
}
}
// allow checks if the request from ip is permitted.
func (l *limiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
lim, ok := l.tokens[ip]
if !ok {
lim = rate.NewLimiter(10, 20) // 10 req/s, burst 20
l.tokens[ip] = lim
}
return lim.Allow()
}
// rateMiddleware returns a middleware that aborts with 429 when limit exceeded.
func rateMiddleware(l *limiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !l.allow(ip) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
Why: Demonstrates production‑grade request throttling without external dependencies; the token bucket adapts to burst traffic while protecting downstream services.
Step 5 – Expose Prometheus metrics for request latency, cache hits/misses, and DB latency
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
_ "github.com/prometheus/client_golang/prometheus/push"
)
var (
requestCount = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "cvapi",
Subsystem: "http",
Name: "requests_total",
Help: "Number of HTTP requests received.",
}, []string{"method", "path"})
requestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "cvapi",
Subsystem: "http",
Name: "request_duration_seconds",
Help: "Duration of HTTP requests in seconds.",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 15),
}, []string{"method", "path"})
cacheHits = prometheus.NewCounter("cache_hits_total", "Number of cache hits")
cacheMisses = prometheus.NewCounter("cache_misses_total", "Number of cache misses")
dbLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "cvapi",
Subsystem: "db",
Name: "query_duration_seconds",
Help: "Duration of PostgreSQL queries.",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 15),
}, []string{"query_type"})
)
func initMetrics() {
prometheus.MustRegister(requestCount, requestDuration, cacheHits, cacheMisses, dbLatency)
}
Why: Prometheus metrics enable monitoring, alerting, and performance baselines—essential for any service running in production.
Step 6 – Wire OpenTelemetry tracing into the handler
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/attribute"
)
func tracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, span := otel.Tracer("cvapi").Start(r.Context(), "HTTP request")
defer span.End()
// Record a simple attribute for the endpoint.
span.SetAttributes(attribute.String("http.method", r.Method),
attribute.String("http.path", r.URL.Path))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Why: OpenTelemetry provides vendor‑agnostic traces; integrating early makes it easy to switch to Jaeger, Tempo, or any backend later.
Step 7 – Assemble the HTTP router, middleware chain, and handlers
func main() {
// Initialise components
store, err := NewStore("postgres://user:pass@localhost:5432/cvapi?sslmode=disable")
if err != nil {
log.Fatalf("failed to open DB: %v", err)
}
cache := NewCache("localhost:6379", "", 0)
limiter := newLimiter()
initMetrics()
// Ensure a résumé exists (seed for demo)
resume := &Resume{
Name: "Jane Doe",
Email: "jane@example.com",
// … fill other fields …
}
_ = store.UpsertResume(resume) // ignore error for brevity
// Build the mux
mux := http.NewServeMux()
// Base handler that returns JSON résumé
baseHandler := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Try cache first
cached, err := cache.GetResumeJSON(ctx)
if err == nil && cached != "" {
cacheHits.Inc()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(cached))
return
}
if err != nil && err != redis.Nil {
log.Printf("cache get error: %v", err)
}
cacheMisses.Inc()
// Fetch from DB
r, err := store.GetResume()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
b, _ := json.Marshal(r)
// Store in cache for next request
_ = cache.SetResumeJSON(ctx, string(b))
// Record DB latency (simplified)
dbLatency.Observe(0.01) // in real code measure elapsed time
w.Header().Set("Content-Type", "application/json")
w.Write(b)
}
// Wrap with middleware stack
handler := rateMiddleware(limiter)
handler = tracingMiddleware(handler)
mux.HandleFunc("/resume", handler.ServeHTTP)
// Prometheus endpoint
mux.Handle("/metrics", promhttp.Handler())
log.Println("cvapi listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Why: Pulling everything together shows a production‑grade request pipeline: rate limit → trace → cache‑aside → DB → metrics → response. The /metrics endpoint lets anyone scrape Prometheus‑format data.
Step 8 – Dockerise the service and compose with dependencies
Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.* .
RUN go mod download
COPY *.go .
RUN CGO_ENABLED=0 go build -o cvapi .
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /src/cvapi .
EXPOSE 8080
CMD ["./cvapi"]
docker‑compose.yml
version: "3.9"
services:
cvapi:
build: .
ports:
- "8080:8080"
environment:
- POSTGRES_DSN=postgres://user:pass@db:5432/cvapi?sslmode=disable
depends_on:
db:
redis:
volumes:
- ./cvapi.yaml:/etc/cvapi/cvapi.yaml:ro
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: cvapi
volumes:
- pg_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pg_data:
Why: A multi‑stage build keeps the final image tiny; docker‑compose.yml spins up PostgreSQL and Redis so the service runs out‑of‑the‑box locally.
Running and Testing It
Start the stack
docker compose up -dThis launches PostgreSQL, Redis, and builds the
cvapibinary.Verify the résumé endpoint
curl http://localhost:8080/resume | jq .You should see the JSON résumé. A second request returns faster because the cache hit increments the
cache_hits_totalcounter.Check Prometheus metrics
curl http://localhost:8080/metricsLook for lines like
cvapi_http_requests_total{method="GET",path="/resume"} 1andcache_hits_total 1.Observe traces in Jaeger (default UI at
http://localhost:16686)
After a few requests, open Jaeger and select thecvapiservice to see spans for “HTTP request”, cache lookup, and DB query.Run the test suite (optional)
go test ./...The test package can spin an in‑memory PostgreSQL (
testcontainers-go) and Redis to assert cache‑aside logic and rate‑limit behaviour.Stress test with
wrk(example)wrk -t12 -c400 -d30s http://localhost:8080/resumeObserve the 429 responses after the per‑IP token bucket is exhausted, and check that
rate_limiter_rejects_total(you can expose a custom metric) rises.
Extending It: Your Roadmap to Senior‑Level
- Persistent schema migrations with
golang-migrate/migrate– ensures theresumestable evolves safely across deployments. - Horizontal scaling on Kubernetes – Deploy the Go binary as a
Deploymentwith aServiceand enable aHorizontalPodAutoscalerbased on custom metrics (request latency, cache miss rate). - Grafana dashboards + Loki for logs – ship structured JSON logs (
logrusorzap) to Loki, then build dashboards that correlate request latency with cache hit ratio. - Circuit‑breaker with go‑kit/breaker – wrap the PostgreSQL and Redis calls so that transient outages don’t cascade into total service failure.
- Benchmarking & load‑testing pipeline – integrate
k6orwrkinto a CI job that fails if average latency exceeds a SLA (e.g., 200 ms at 100 RPS). - OpenAPI 3.0 specification generation – use
go-swaggerorswagto auto‑generate a spec, enabling client SDKs and interactive API docs for consumers.
Each upgrade adds a production‑grade capability that moves the project from “toy” to a system you’d confidently discuss in a senior‑level interview.
Key Takeaways
- A minimal Go service can simultaneously demonstrate concurrency, middleware composition, persistence, caching, metrics, and tracing—all skills hiring managers value.
- Docker and
docker‑composemake the project instantly runnable locally and portable to any cloud or on‑prem environment. - Prometheus metrics and OpenTelemetry traces give you observability “for free” once the middleware is in place.
- Rate limiting and circuit breakers protect downstream dependencies and show you understand fault‑tolerance patterns.
- The roadmap upgrades (migrations, K8s scaling, Grafana logs, go‑kit breaker, benchmarking, OpenAPI) transform the project into a senior‑level portfolio piece.
Further Reading
- Go net/http package – foundation for the HTTP server and middleware.
- Prometheus client_golang README – how to expose and instrument metrics.
- OpenTelemetry Go API – instrumentation for distributed tracing.
- Redis documentation – cache‑aside patterns – the caching strategy used in the project.
- PostgreSQL official documentation – connection pooling and SQL patterns.
- Docker Compose specification – the compose file that orchestrates the local environment.