Building Scalable REST APIs in Go
Go has become the language of choice for building high-performance backend services. Its simplicity, fast compile times, and excellent concurrency primitives make it ideal for APIs that need to handle thousands of requests per second. But the language alone doesn't guarantee scale. How you structure your project, handle errors, manage database connections, and observe your system matters far more.
After building and refactoring several production APIs, I've settled on a pattern that balances clarity with room to grow. This isn't about microservices or Kubernetes. It's about the fundamentals that let a single service evolve without becoming a mess.
Project Structure
A flat structure works until it doesn't. Once you have more than a few handlers, you'll want clear boundaries. I use a layered approach inspired by clean architecture, but pragmatically stripped down:
cmd/
api/
main.go
internal/
server/
server.go // HTTP server setup, routes, middleware
middleware.go // Auth, logging, recovery
handler/
user.go // HTTP handlers for user routes
health.go // Health check
service/
user.go // Business logic
store/
user.go // Database operations
store.go // DB connection and interfaces
model/
user.go // Domain types and validation
config/
config.go // Env vars and constants
pkg/
response/ // Standard API response wrappers
validator/ // Request validation helpers
The key rule: dependencies point inward. Handlers depend on services. Services depend on stores. Stores depend on the database. Nothing in the inner layers knows about HTTP. This makes testing and refactoring painless.
Routing and Handler Setup
The standard library's net/http is underrated, but for anything non-trivial I reach for chi. It's lightweight, idiomatic, and gives you middleware composition without magic.
func (s *Server) routes() {
s.router.Route("/api/v1", func(r chi.Router) {
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(s.loggerMiddleware)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Get("/health", handler.HealthCheck)
r.Route("/users", func(r chi.Router) {
r.Get("/", handler.ListUsers)
r.Post("/", handler.CreateUser)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", handler.GetUser)
r.Put("/", handler.UpdateUser)
r.Delete("/", handler.DeleteUser)
})
})
})
}Grouping routes by resource and applying middleware at the right level keeps the setup readable. Auth middleware goes on the sub-router that needs it, not globally.
Request Validation
Don't validate in handlers. Extract, validate, and transform in one place. I define request structs with tags and a Validate method:
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2,max=100"`
Email string `json:"email" validate:"required,email"`
Role string `json:"role" validate:"oneof=admin user guest"`
}
func (r *CreateUserRequest) Validate() error {
// Custom validation logic beyond struct tags
if strings.Contains(r.Name, "\"") {
return fmt.Errorf("name contains invalid characters")
}
return validator.New().Struct(r)
}The handler becomes a thin orchestration layer: decode the request, validate it, call the service, and write the response. That's it.
Error Handling
Consistent error responses are a kindness to your API consumers. I use a small set of domain errors that map cleanly to HTTP status codes:
var (
ErrNotFound = errors.New("resource not found")
ErrConflict = errors.New("resource already exists")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrInvalidInput = errors.New("invalid input")
)
func HTTPStatusFromError(err error) int {
switch {
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
case errors.Is(err, ErrConflict):
return http.StatusConflict
case errors.Is(err, ErrUnauthorized):
return http.StatusUnauthorized
case errors.Is(err, ErrForbidden):
return http.StatusForbidden
case errors.Is(err, ErrInvalidInput):
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}The service layer returns domain errors. The handler maps them to HTTP. This separation means your business logic never imports net/http.
Database Layer
For most projects, I use plain database/sql with lib/pq or pgx. ORMs hide too much. But raw SQL gets tedious, so I generate type-safe code with sqlc. Write SQL queries, get Go functions. No string interpolation, no reflection.
-- name: GetUserByID :one
SELECT id, name, email, role, created_at
FROM users
WHERE id = $1;
-- name: ListUsers :many
SELECT id, name, email, role, created_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;For connection management, create the pool at startup and pass it down:
db, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatal(err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)The store struct holds *sql.DB or a sqlc-generated interface. Repositories are stateless. The connection pool does the hard work of reuse and backpressure.
Observability
You can't scale what you can't see. At minimum, instrument three things:
- Request logging: method, path, status, duration, and request ID for tracing.
- Structured logs: use
slogorzap. JSON in production, pretty in development. - Metrics: request latency histograms, error rates, and database connection pool stats. Prometheus client libraries work well.
func (s *Server) loggerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration", time.Since(start),
"request_id", middleware.GetReqID(r.Context()),
)
})
}Testing
Go's testing culture is one of its strengths. I write three kinds of tests:
- Unit tests for services with mocked stores.
- Integration tests for stores against a real test database running in Docker.
- Handler tests using
httptest.ResponseRecorderto verify routing, status codes, and response shapes.
The testing/slogtest package and a simple in-memory store implementation make unit tests fast and deterministic. Integration tests run in CI against a Postgres container spun up with Docker Compose.
Graceful Shutdown
Production APIs don't crash. They shut down gracefully, finishing in-flight requests before exiting. The pattern is well-documented but worth repeating:
srv := &http.Server{
Addr: ":8080",
Handler: router,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("server forced to shutdown:", err)
}Closing Thoughts
Scalability isn't about choosing the right framework or deploying to Kubernetes on day one. It's about discipline in the small things: clear boundaries between layers, consistent error handling, observable systems, and tests that give you confidence to refactor.
Go makes the easy things easy and the hard things possible. Your job is to not get in the way. Start simple, instrument everything, and evolve the architecture as the product demands it. The best systems are the ones that are boring to operate.
If you found this useful, feel free to reach out on X or check the code I write on GitHub.