Building Scalable APIs with Go: What I Learned from Production
Over the past year, I've built and shipped several Go APIs for real estate data pipelines, deal management systems, and lead generation tools. Here's what actually matters when you move past tutorial-level code.
The Middleware Stack That Works
Every API I ship follows the same middleware pattern:
Request → Logger → Recoverer → CORS → Auth → Rate Limit → Handler
This isn't creative — it's battle-tested. The key insight is ordering: auth comes after CORS (so preflight requests don't fail auth), rate limiting comes after auth (so you can rate-limit by user ID), and recovery wraps everything so one panic doesn't take down the process.
Chi Router Conventions
I use go-chi/chi exclusively. It's stdlib-compatible, composable, and doesn't impose an opinion on how you structure handlers. The pattern:
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Route("/api/v1", func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/deals", listDeals)
r.Post("/deals", createDeal)
r.Put("/deals/{id}", updateDeal)
})
Grouping by route prefix keeps the file structure clean. Each resource gets its own file with Register*routes().
Error Handling: Don't Abstract Too Early
The biggest mistake I see in Go APIs is over-engineering error handling on day one. A custom error type with status codes, error codes, and structured JSON is useful — but only after you've written 20+ handlers and see the pattern emerge.
Start simple:
func respondError(w http.ResponseWriter, status int, msg string) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
Refactor to structured errors when you're tired of writing the same if err != nil + respondError block for the tenth time.
Database: SQLC Over ORMs
I avoid GORM and similar ORMs in Go. The abstraction layer is thin enough that you still write SQL, but thick enough that you debug generated queries. Instead:
- sqlc: generates type-safe Go code from SQL
- sqlx: thin wrapper over
database/sqlfor scanning into structs - pgx: PostgreSQL driver with connection pooling built in
sqlc is the sweet spot. You write raw SQL in .sql files, it generates Go functions with proper types. No runtime reflection, no magic, no N+1 queries hiding in ORM lazy loading.
What I've Stopped Doing
- Global dependency injection containers — just pass structs to constructors
- Reusing HTTP clients — create per-service clients with their own timeouts
- One-size-fits-all handler signatures — every handler gets exactly what it needs
- Gorilla mux / gin — chi does everything they do without the framework lock-in
The Bottom Line
Go APIs perform well from day one. The challenge isn't throughput — it's maintainability. Clear middleware chains, explicit error handling, and generated SQL produce services that six-month-from-now you can still modify confidently.