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/
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)
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")
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:
- Cache-aside only — the app reads from cache, falls back to DB, and writes back. No magic.
- 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 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
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.
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))
})
}
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)
}
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.
// 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)
}
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)