Building Scalable APIs with Go: Lessons from the Trenches
There’s something deeply satisfying about writing Go code. The simplicity, the explicit error handling, the blazing-fast compilation times — it all adds up to a language that gets out of your way and lets you focus on solving real problems.
After building dozens of APIs across different stacks, I’ve settled on Go as my primary language for backend services. Here’s what I’ve learned along the way.
Why Go for APIs?
The short answer: performance, simplicity, and developer experience.
Go compiles to a single binary. No runtime dependencies, no complex deployment pipelines — just a binary that you can drop onto any server or container. This alone is a massive win for operational simplicity.
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", handleHealth)
mux.HandleFunc("GET /api/users/{id}", handleGetUser)
mux.HandleFunc("POST /api/users", handleCreateUser)
log.Println("Server starting on :8080")
http.ListenAndServe(":8080", mux)
}
With Go 1.22+, the standard library’s http.ServeMux now supports method-based routing and path parameters. For many APIs, you don’t even need a third-party router anymore.
Patterns That Scale
1. Dependency Injection Without a Framework
Go doesn’t need a DI container. Struct embedding and interface satisfaction give you everything you need:
type UserService struct {
repo UserRepository
cache Cache
logger *slog.Logger
}
func NewUserService(repo UserRepository, cache Cache, logger *slog.Logger) *UserService {
return &UserService{repo: repo, cache: cache, logger: logger}
}
This pattern makes testing trivial — just pass in mock implementations.
2. Graceful Shutdown
One of the most overlooked aspects of a production API is graceful shutdown. Active requests should complete before the server stops:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
<-ctx.Done()
log.Println("Shutting down gracefully...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
3. Structured Logging from Day One
Don’t start with fmt.Println and plan to “add proper logging later.” Start with slog from the beginning:
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
logger.Info("request processed",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", time.Since(start).Milliseconds(),
"status", statusCode,
)
The Middleware Pattern
Go’s middleware pattern is elegant in its simplicity. A middleware is just a function that wraps an http.Handler:
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"duration", time.Since(start),
)
})
}
Stack them up: withLogging(withAuth(withCORS(handler))). No magic, no annotations, just function composition.
What I’d Tell My Past Self
- Start with the standard library. Reach for frameworks only when you genuinely need them.
- Invest in error handling early. Custom error types with context save hours of debugging.
- Write table-driven tests. Go’s testing patterns are powerful — use them.
- Profile before optimizing.
pprofis built-in and incredibly useful. - Keep your interfaces small. One or two methods is usually enough.
Go isn’t perfect — generics are still evolving, and the ecosystem can feel sparse compared to Node.js or Python. But for building reliable, performant APIs that you can reason about, it’s hard to beat.
What’s your experience with Go? I’d love to hear about your patterns and lessons learned. Drop me a message or find me on GitHub.