100 days of Go and production APIs: the recap explained
We've spent the last 100 days building, testing, and shipping production APIs in Go—and the lessons are clearer now than they were on day one. Go's design philosophy rewards discipline over cleverness, and after a hundred days in the trenches, that philosophy stops being a nice idea and becomes survival instinct. This recap captures what actually mattered.
Simplicity is the feature
The most productive Go codebases we've worked on weren't the ones with the most packages, interfaces, or abstraction layers. They were the ones that made decisions visible and reasoning straightforward. In production, your team needs to understand why a function exists, what it does, and when it fails—within seconds of reading it.
Small interfaces are the cornerstone here. Not interfaces everywhere, but interfaces where they solve a real problem. A 10-method interface is usually a sign that you've bundled unrelated concerns. We learned this the hard way: a large e-commerce platform we worked with had an OrderService interface with 23 methods. When testing handlers, we ended up mocking all 23, even though we only needed 2. We split it into OrderReader and OrderWriter, each with 4-5 methods. Tests became faster to write and easier to understand.
Clear package organization follows naturally from this. A package should have a single reason to change. If your user package handles authentication, validation, storage, and email notifications, you've built a time bomb. Your teammates will be afraid to touch it, and bugs will hide in unexpected places.
Explicit wiring—dependency injection without the framework—keeps things honest. In Go, we often pass dependencies as constructor arguments:
type OrderHandler struct {
repo OrderRepository
logger *slog.Logger
cache Cache
}
func NewOrderHandler(repo OrderRepository, logger *slog.Logger, cache Cache) *OrderHandler {
return &OrderHandler{repo, logger, cache}
}
This is verbose, sure, but it's explicit. You can see exactly what OrderHandler needs. No magic, no reflection, no container frameworks guessing at types. In a 50-handler API, that explicitness compounds into real savings when you're debugging or onboarding someone new.
Concurrency needs discipline
Go makes concurrency easy—too easy. We've seen goroutines spawn in production code with no synchronization, no timeout, and no way to shut them down cleanly. A ride-hailing platform we supported had goroutines leaking in their matching engine because goroutines were started but the channel they waited on could be closed at any time. Under load, the number of leaked goroutines climbed until the service memory-profiled at 2GB.
Contexts are your first defense. Every goroutine should respect context.Context. It's not optional:
func (h *OrderHandler) processOrder(ctx context.Context, orderID string) error {
// Check context before expensive operations
if err := ctx.Err(); err != nil {
return err
}
result, err := h.repo.GetOrder(ctx, orderID)
if err != nil {
return err
}
// More work...
return nil
}
When a request context is cancelled—because the client disconnected or the request timeout fired—your goroutines need to stop work immediately. In HTTP handlers, the router propagates the context to your handler. In background jobs, you create a context with context.WithTimeout or context.WithCancel. No exceptions.
Bounded parallelism prevents cascading failures. If you spawn one goroutine per incoming request without limits, a traffic spike will exhaust memory and your service will crash. Use a semaphore or worker pool:
type WorkerPool struct {
sem chan struct{}
}
func NewWorkerPool(maxWorkers int) *WorkerPool {
return &WorkerPool{sem: make(chan struct{}, maxWorkers)}
}
func (p *WorkerPool) Do(ctx context.Context, fn func() error) error {
select {
case p.sem <- struct{}{}:
defer func() { <-p.sem }()
return fn()
case <-ctx.Done():
return ctx.Err()
}
}
This is straightforward: you're limiting how many goroutines run concurrently. The channel acts as a gate. When the semaphore is full, new work waits. If context is cancelled while waiting, the goroutine exits cleanly.
No goroutine leaks means: every goroutine you start, you must be able to stop. Test this. Write a test that starts your component, cancels the context, and verifies that all goroutines exit within a reasonable time. A CRM platform we worked with added this test as a regression detector, and it caught a leak in the notification service that had been festering for weeks.
The standard library goes far
Go's standard library is not comprehensive, but it is deep. net/http, encoding/json, database/sql, testing, and log/slog cover the core of what you need to build a production API. Using them well beats reaching for a framework nine times out of ten.
net/http is robust enough for APIs at significant scale. You don't need a router abstraction right away. The standard http.ServeMux is limited, sure, but libraries like chi or gorilla/mux are thin wrappers that still feel like Go. A trading platform we helped scale started with ServeMux, upgraded to chi when regex routing became necessary, and never needed to change request handling logic.
slog, added in 1.21, gives you structured logging without pulling in five dependencies. Write JSON logs to stdout, and you can aggregate them in your observability platform:
slog.Info("order processed",
slog.String("order_id", orderID),
slog.Int("items_count", len(items)),
slog.Duration("processing_time", elapsed))
The testing package—table-driven tests, subtests, t.Run—is sufficient for unit testing. For integration tests, httptest.NewServer eliminates the need for mock frameworks. When you need fixtures or factories, write them as helper functions in your test files. Keep it simple.
The trade-off is that you'll write more boilerplate than a framework gives you. But boilerplate is clarity. You control the flow. The day someone needs to understand how your API works, they'll read the code, not the framework docs.
Production habits
Shipping code is different from shipping APIs. Production APIs handle unexpected load, network failures, and degraded dependencies. Four habits separate services that wake you up at 3am from ones that quietly handle problems:
Timeouts everywhere. Set a deadline on every external call—database queries, HTTP requests, cache lookups. No timeouts means your service can hang indefinitely waiting for a slow upstream. A travel platform we worked with had a booking handler that called an external payment processor without a timeout. When the processor's API latency spiked from 200ms to 30 seconds, the booking handler hung, connection limits were exhausted, and the service became unresponsive. Five lines of code would have prevented it:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
Graceful shutdown. When you deploy a new version, existing requests should finish before the process exits. Use http.Server.Shutdown:
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
srv.ListenAndServe()
This allows in-flight requests to complete while new requests are rejected immediately. Orchestrators like Kubernetes read the exit code and treat the shutdown gracefully. Zero-downtime deployments become possible.
Profiling and observability. Enable the pprof handler in production (on a separate, internal port, behind authentication). CPU profiles, heap profiles, and goroutine dumps have saved us countless hours. Export metrics—request latency, error rates, database connection counts—to Prometheus or your metrics backend. When things go wrong, you'll have data.
Structured error handling. Don't panic your way through errors. Handle them explicitly, log them with context, and return meaningful HTTP status codes:
if err != nil {
slog.Error("order fetch failed",
slog.String("order_id", orderID),
slog.String("error", err.Error()))
if errors.Is(err, ErrNotFound) {
w.WriteHeader(http.StatusNotFound)
} else if errors.Is(err, ErrBadInput) {
w.WriteHeader(http.StatusBadRequest)
} else {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
Common mistakes
After 100 days, patterns emerge. The mistakes we see repeated:
- Ignoring context cancellation. A goroutine that doesn't check
ctx.Done()will run to completion regardless of whether the request was cancelled. This leaks resources and cascades into timeouts elsewhere. - Holding database connections in goroutines. Each database connection is a scarce resource. A query that launches a goroutine and holds a connection until the goroutine finishes can exhaust the connection pool under moderate load.
- Over-engineering for extensibility. We've seen codebases with 15-method interfaces, reflection-based dependency injection, and plugin architectures, all built "for flexibility." Flexibility you don't need yet is technical debt you do have now.
- Defaulting to channels for everything. Channels are for synchronization and work distribution, not general-purpose concurrency primitives. For guarding shared state, use
sync.Mutex. It's clearer and often faster. - Missing error context. "Database error" tells you nothing. "Database error: connection pool exhausted" tells you where to look. Use
fmt.Errorfand the%wverb to wrap errors with context.
Key takeaways
- Small interfaces, clear packages, and explicit wiring aren't restrictions—they're the shape of maintainable code at scale.
- Concurrency isn't free. Contexts, bounded parallelism, and deliberate goroutine lifecycle management are non-negotiable in production.
- The standard library covers the 80% of what you need. Learn it deeply before reaching for third-party packages.
- Production APIs require timeouts on everything, graceful shutdown, profiling enabled, and structured error handling from day one.
- Simplicity compounds. A slightly verbose handler that's easy to reason about saves weeks of debugging later.






Comments
Loading comments…