Building a REST API in Go 1.22 Without Frameworks
last updated
Building a REST API in Go 1.22 without frameworks (using only the standard library) was one of the most revealing exercises I've done in months. No Gin, no Echo: just net/http, database/sql, and whatever the language ships with by default.
The motivation wasn't purism. It was the opposite: it's easy to end up with a shallow understanding of HTTP, context, and concurrency when a framework handles everything under the hood. Removing that layer forces you to understand what actually happens between the request coming in and the response going out. The result, in the end, is a small, fast API with no dependency I can't explain line by line.
This post is a practical summary of what came out of that build: architecture, native routing in Go 1.22, hand-written middleware, migrations embedded in the binary, and an error format any HTTP client can predict.
Architecture and project structure
To keep the code organized and testable, I adopted a package structure inspired by the Standard Go Project Layout, with one simple rule guiding the boundaries: no global variables.
At the infrastructure level, configuration loading follows a fail-fast pattern: if an essential variable like DATABASE_URL isn't set, the application returns an error immediately and doesn't even try to start the server. Failing in seconds at boot is always cheaper than failing in production.
The biggest practical lesson, though, was using dependency injection without a container and without magic: a single Deps struct in the server package holds the database, logger, and configuration, and gets passed explicitly to the handlers. That made unit tests trivial: just inject an in-memory fake repository, without touching the real database.
Native routing with Go 1.22's http.ServeMux
Until recently, building an API without an external router (chi, gorilla/mux) was uncomfortable: no HTTP methods in the route pattern and no path parameters, so everything turned into if r.Method == "GET" scattered across handlers. Go 1.22 changed that: the standard library's http.ServeMux now supports HTTP verbs and path wildcards natively.
func (h Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/users", h.create)
mux.HandleFunc("GET /v1/users", h.list)
mux.HandleFunc("GET /v1/users/{id}", h.get)
mux.HandleFunc("PATCH /v1/users/{id}", h.update)
mux.HandleFunc("DELETE /v1/users/{id}", h.delete)
}Inside the handler, capturing the {id} from the URL takes one line: r.PathValue("id"). Simple, with no extra allocations and no third-party dependency. For most CRUD APIs, this replaces 100% of what an external router offered.
Hand-written middleware
In the Go ecosystem, a middleware is just a function that takes an http.Handler and returns another http.Handler; there's no magic to reproduce, only composition. I chained four essential middleware for a production-ready API:
requestID: generates a unique identifier per request and injects it intocontext.Contextand the response headers, making end-to-end tracing easier.requestLogger: wraps thehttp.ResponseWriterto capture the status code and latency, logging everything with the nativeslogpackage.recoverPanic: keeps an isolatedpanicfrom taking down the whole process, converting it into a clean500for the client.maxBytes: a defensive layer that caps the request body size (1 MB, for example) usinghttp.MaxBytesReader, protecting the server against abusive payloads.
Database and migrations with go:embed
Instead of relying on an external tool like golang-migrate via CLI, I embedded the SQL migrations directly into the compiled binary using the //go:embed directive:
//go:embed migrations/*.sql
var migrationsFS embed.FSAt boot, the application reads the .sql files straight from memory, checks the schema_migrations table for which ones already ran, and applies the new ones automatically. To stay safe when running multiple instances (containers in parallel), the process is guarded by a PostgreSQL advisory lock (pg_advisory_lock) before applying any migration.
For talking to the database, I chose the pgx/v5 driver, optimized and recommended by the Go community for direct use without database/sql. Another turning point was delegating constraints to the database itself: instead of running a SELECT before an INSERT to check whether an email already exists (which opens a race condition window), the code just attempts the INSERT directly. If the email is a duplicate, the driver returns Postgres error code 23505 (unique_violation); I catch that code and translate it into a 409 Conflict. It's a simpler approach, and far more robust against concurrency, than the "check before insert" alternative.
Standardized errors with RFC 7807
So that any client (frontend, mobile, another service) can consume the API predictably, errors follow RFC 7807 (Problem Details for HTTP APIs). Every error response is JSON in the same shape:
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "invalid email format"
}The detail that makes this work is having a single decision point: a fail() method in the handler is the only bridge that translates domain errors (ErrNotFound, ErrDuplicate, ErrInvalid) into the matching HTTP status. No handler decides the status code on its own, and that's what keeps the API consistent as it grows.
Graceful shutdown and a distroless Docker image
To run well in production (Kubernetes, Docker, any orchestrator that sends SIGTERM before killing the process), the application implements graceful shutdown: main.go listens for OS signals and, once it receives one, immediately stops accepting new requests and waits, with a safety timeout, for in-flight requests to finish before closing the database connections.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server failed", "error", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)Finally, the application is containerized with a multi-stage build: the binary is compiled statically (CGO_ENABLED=0) and the final image uses gcr.io/distroless/static:nonroot, with no shell, no package manager, nothing beyond the binary. The result is an image that's just a few megabytes with a minimal attack surface.
When it makes sense to skip a framework
Worth being direct about the trade-off: this approach pays off when the API is small enough, when the team is senior enough to write middleware correctly, and when the stated goal is to deeply understand what's running. It doesn't replace a framework on large projects, with many teams and many endpoints, where the shared convention of a Gin or Echo reduces cognitive load across people. In that scenario, rewriting routing and middleware from scratch is cost, not learning.
Final thoughts
Building this CRUD relying only on the standard library was a revealing experience. Without the abstraction layer frameworks impose, I'm forced to truly understand how the HTTP protocol works under the hood, how Go's context.Context manages time and cancellation, and how to design code that's built for testing and easy to maintain.
Modern Go's standard library is rich. Solid tools like slog, http.ServeMux with native path parameters (Go 1.22 release notes), and net/mail (used here for safe email validation without a homegrown regex) prove we already have practically everything "in the box." The end result is a high-performance API that's easy to read and, honestly, a lot more fun to build than I expected.