DEV Community

Cover image for 7 Lessons I Learned Building Production APIs in Go
Parsa Aftabi
Parsa Aftabi

Posted on

7 Lessons I Learned Building Production APIs in Go

TL;DR: After shipping several production systems in Go — coming from PHP/Laravel — these are the seven lessons that actually mattered: boring project structure, context.Context everywhere, fixing the database before adding cache, deliberate Redis caching, request-ID logging, graceful shutdown, and thin handlers. Code included.


When I first picked up Go, I came from PHP/Laravel land — where the framework holds your hand through everything. Go handed me a toolbox, pointed at the horizon, and said "good luck."

A few production systems later, here are the seven lessons that actually stuck. Not theory — the stuff I wish someone had told me on day one.


1. Boring project structure beats clever frameworks

My first Go API had packages named utils, helpers, and common — three junk drawers with the same junk. Don't do that.

What works: organize by domain, keep main.go thin, and resist importing a web framework until net/http genuinely hurts.

/cmd/api/main.go
/internal/user/handler.go
/internal/user/service.go
/internal/user/repository.go
/internal/platform/db/
/internal/platform/cache/
Enter fullscreen mode Exit fullscreen mode

Boring is a feature. The next person reading your code (probably future you at 2 AM) will thank you.

2. context.Context is not optional

Every handler, every DB call, every outbound request should take a context. The day a slow downstream service hangs your entire API is the day you learn this the hard way. Learn it the easy way:

ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

user, err := repo.GetByID(ctx, id)
Enter fullscreen mode Exit fullscreen mode

Three lines. Timeouts, cancellation, and request-scoped values — all from threading ctx through your call chain.

3. Your database is the bottleneck, not Go

Go can handle tens of thousands of concurrent requests. Your Postgres connection pool cannot — at least not with default settings.

Use pgx with a properly sized pool, and size it deliberately:

pool, err := pgxpool.New(ctx,
    "postgres://user:pass@localhost:5432/app?pool_max_conns=20&pool_min_conns=5")
Enter fullscreen mode Exit fullscreen mode

Rule of thumb I follow: max_connections ≈ (2 × CPU cores) + a small buffer per instance. And add the missing index before you add Redis. Most "slow API" problems I've debugged were a missing index, not a missing cache.

4. Cache deliberately with Redis — don't sprinkle it everywhere

Caching everything is how you get stale data bugs that only appear on Tuesdays. I cache with two rules:

  1. Cache-aside only — the app reads from cache, falls back to DB, and writes back. No magic.
  2. Every cached key gets a TTL and an invalidation story. If you can't explain when the key expires or gets invalidated, don't cache it.

Cache-aside pattern: App → Redis → (on miss) Postgres → write back

// cache-aside in ~10 lines
if val, err := rdb.Get(ctx, key).Result(); err == nil {
    return val, nil // cache hit
}
val, err := db.Query(ctx, ...) // cache miss: go to source
if err == nil {
    rdb.Set(ctx, key, val, 5*time.Minute)
}
return val, err
Enter fullscreen mode Exit fullscreen mode

5. Structured logging with request IDs will save you

log.Println("something happened") is a confession, not logging. In production you need to trace one request across handlers, DB calls, and cache lookups. The cheapest way: a request-ID middleware plus structured logs.

One request ID traveling through handler, service, and database layers

func requestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := uuid.NewString()
        w.Header().Set("X-Request-ID", id)
        ctx := context.WithValue(r.Context(), "requestID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
Enter fullscreen mode Exit fullscreen mode

Then log with fields, not sentences: logger.Info("order.created", "request_id", id, "order_id", orderID). When something breaks at 3 AM, you'll grep one ID instead of reading 50,000 lines.

6. Handle graceful shutdown from day one

It's ten lines of code, and it prevents dropped requests on every deploy:

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit // wait for shutdown signal

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    log.Fatalf("forced shutdown: %v", err)
}
Enter fullscreen mode Exit fullscreen mode

In-flight requests finish, DB connections close cleanly, and your deploys stop causing mysterious 502s.

7. Keep handlers thin — they're translators, not business logic

A handler should do three things: parse input, call a service, write output. The moment you see SQL queries or business rules inside a handler, extract them.

Layered architecture: Handler → Service → Repository

// handler: translation layer only
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var in CreateUserInput
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        writeError(w, http.StatusBadRequest, err)
        return
    }
    user, err := h.service.CreateUser(r.Context(), in)
    if err != nil {
        writeError(w, http.StatusInternalServerError, err)
        return
    }
    writeJSON(w, http.StatusCreated, user)
}
Enter fullscreen mode Exit fullscreen mode

All the real logic lives in service. Handlers stay dumb, services stay testable.


The thread connecting all seven

None of these are about Go-specific tricks. They're about respecting production: timeouts, observability, clean boundaries, deliberate caching. Go just happens to make the right thing easy — if you let it.

Which lesson hit home for you? And what's the one you wish someone told you on day one? I'd genuinely like to steal it for the next post. 👇


I write about backend engineering, Go, and the unglamorous work of keeping systems alive. Follow for more.

Top comments (0)