building a go api without a framework
last updated
When you start a new Go project, the reflex is almost automatic: go get github.com/gin-gonic/gin, or reach for Echo, Chi, or Fiber. It's quick, it feels like the industry default, and years of Express (Node.js) and FastAPI (Python) have trained us to assume a bare standard library can't carry a production app.
For my latest project, LEVO, I disagreed. LEVO is a real-time platform for organizing events, the kind of "who's bringing what" coordination that usually dies in a group chat or a shared spreadsheet. I built the entire backend, routing, middleware, authentication, real-time updates, and concurrency, on the Go standard library alone.
The short version of my argument: since the routing changes in Go 1.22, net/http is finally enough for a production API, and reaching for a framework by default is now a habit rather than a requirement. The rest of this post is the proof, with the real code from a shipped app. The backend is open source if you want to read along: github.com/abneribeiro/levo.
Go 1.22 routing: methods and path parameters in net/http
For years, the strongest argument for a framework (or an external router like gorilla/mux or chi) was dynamic routing. The standard http.ServeMux couldn't match on HTTP methods, and pulling an ID out of a URL meant writing regular expressions by hand. That argument is over. Since Go 1.22, the standard mux understands methods and path wildcards natively:
mux := http.NewServeMux()
// Go 1.22 routing matches the method and extracts path parameters.
mux.HandleFunc("GET /api/events/{slug}", func(w http.ResponseWriter, r *http.Request) {
eventSlug := r.PathValue("slug")
fmt.Fprintf(w, "Fetching event: %s", eventSlug)
})That's the whole mechanism. r.PathValue() reads the variable, with no struct tags, no binding package, and no regex. In LEVO this scales cleanly to nested, authenticated resources. Here is a real route, the one that fires when a guest claims an item:
mux.Handle("POST /api/v1/events/{slug}/items/{itemId}/claim",
guestAuth(http.HandlerFunc(h.Claims.HandleClaimItem)))
// inside the handler:
slug := r.PathValue("slug")
itemID := r.PathValue("itemId")My full routing table is about thirty lines like this in a single file. It reads top to bottom like a table of contents, which matters more than it sounds: a router that hides your routes behind decorators is a router you have to reverse-engineer six months from now. Mine fits on one screen.
Middleware in Go is just a function
People talk about middleware as if it were a framework feature. It isn't. A middleware in Go is a function that takes an http.Handler and returns a new one. That's the entire contract, and it's the classic onion pattern: each layer wraps the next, intercepts the request on the way in, and passes control along by calling ServeHTTP.
Because composition is just nesting function calls, the order of your middleware is something you can see with your own eyes instead of learning from a framework's documentation:
// main.go — the outermost wrapper runs first.
handler := middleware.Recovery()( // catch panics from everything inside
middleware.RequestID()( // one id threads through every log line
middleware.Metrics()(
middleware.SecurityHeaders(sec)(
middleware.CORS(origins)(
middleware.CSRFProtect(origins)(
middleware.Idempotency(store)(mux)))))))There's also a request body-size limit in that chain. Recovery sits outermost on purpose, so it catches a panic raised by anything beneath it, and you can tell that at a glance because nothing is hidden. Inside any of these layers you can validate a JWT, inject the user's session into the request Context, and hand off to the next handler, all with plain interfaces and no runtime reflection.
The gotcha a framework would have hidden until it broke
Here is the kind of detail you only learn by owning your own stack. Several of these middlewares wrap http.ResponseWriter to capture the status code for logging and metrics. The moment you wrap the writer, you silently break streaming, because the w.(http.Flusher) type assertion now fails against your wrapper, and Server-Sent Events stop flushing.
The fix is four lines you have to know to write:
// Without this, w.(http.Flusher) fails on the wrapper and SSE never flushes.
func (rw *responseWriter) Flush() {
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}I would rather own that than dig through a framework's issue tracker to find out whether their response wrapper forwards Flush() correctly.
Dependency injection without the magic
Another trap of modern frameworks is "magic" dependency injection: the framework wires up your database pools and loggers with reflection at runtime, and when it fails, it fails deep inside a black box.
In LEVO, dependency injection is deliberately boring. Handlers are methods on a struct, and that struct holds the interfaces to the service layer and the repositories. Everything is wired by hand in main.go when the server boots. If I forget to pass a dependency, the Go compiler stops me at my desk, instead of the app crashing on a nil pointer in production at 2am. Boring and explicit beats clever and implicit every time you're on call.
Real-time updates with Server-Sent Events (SSE) in Go
The hard part of LEVO was never the CRUD layer. It was the real-time sync: when one guest claims "I'll bring the ice," everyone else has to see it instantly, with no refresh. This is the exact case people cite for why you supposedly need a framework, usually with a heavy WebSocket plugin attached.
You don't. A Server-Sent Events stream is just a text/event-stream response that you never close, and the standard library hands you everything you need through the native http.Flusher interface:
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.(http.Flusher).Flush()
client := h.hub.AddClient(event.ID, clientID)
defer h.hub.RemoveClient(event.ID, clientID)
for {
select {
case msg := <-client.Channel:
w.Write(msg)
w.(http.Flusher).Flush()
case <-r.Context().Done():
return // client disconnected; clean up
}
}A goroutine-safe hub fans events out to every connected client over buffered channels. It also handles the thing that actually bites you in production, the slow client: if a client's buffer is full, the hub drops that one client rather than blocking the entire broadcast.
One interface, single-instance or multi-instance
The piece I'm most pleased with is that going multi-instance changed nothing in the handler. The hub talks to a tiny interface:
type Publisher interface {
Publish(eventID string, payload []byte) error
}Run a single instance and the publisher hands payloads straight to the in-memory hub. Set a REDIS_URL and the same hub swaps in a Redis pub/sub publisher, so every server instance sees every event. One interface, two implementations, no framework.
The browser constraint nobody warns you about
SSE also taught me that browsers are weirder than any abstraction admits. The browser EventSource API cannot send an Authorization header. So in LEVO a guest first mints a short-lived token, and it rides in the query string instead. The token has a five-minute TTL on purpose: if it ever leaks into a proxy log, it's already worthless. A WebSocket library would have hidden that constraint from me, and I'd never have understood why it mattered.
Handling race conditions without a framework
Two guests tap "claim" on the same item in the same millisecond. Who wins, and how do you stop both from winning? I assumed this was where I'd finally miss a framework. It wasn't. The answer isn't in the HTTP layer at all, it's one line of SQL. The claim only succeeds if the item is still unclaimed:
UPDATE items SET claimed_by = $2, claimed_at = NOW()
WHERE id = $1 AND claimed_by IS NULL;If someone already claimed it, the WHERE clause matches zero rows, and the handler returns a clean 409 Conflict. The database does the locking; I just have to ask the right question.
Testing HTTP handlers with httptest
The most underrated payoff of staying on net/http is the testing story. The standard library ships net/http/httptest, which spins up a real server on a random port without you managing any networking. With a framework you usually have to mock its specific context object (gin.Context, echo.Context), which is tedious and easy to get wrong. With vanilla Go you pass a normal *http.Request and an httptest.ResponseRecorder straight to your handler.
Because there's no framework context in the way, I could prove the claim race above with a test that fires eight real concurrent requests and asserts that exactly one wins:
for i := range racers { // racers = 8
go func(token string) {
<-start // release all goroutines at once
res, _ := env.client.Do(req)
statuses <- res.StatusCode
}(tokens[i])
}
// assert: exactly 1 got 200, the other 7 got 409This runs against the real server, with the real middleware chain, through httptest.NewServer, talking to a real Postgres. No mocked router, no fake context. You get the full power of httptest precisely because there's no abstraction to fake.
What you give up (and when a framework still wins)
I won't pretend this is free, so here are the honest objections.
You aren't reinventing Gin. I still use libraries for the genuinely hard parts: pgx for Postgres, sqlc to generate type-safe queries, and a JWT library for tokens. I skipped exactly one layer, the router and web framework that Go 1.22 made redundant.
There's no built-in request binding or validation. I decode and validate JSON by hand in every handler. That's more code up front, and it's honestly the real cost of this approach. The trade is zero reflection and code I can read straight down.
And it won't suit every team. A large team that already lives in a framework's conventions may move faster staying there, and I wouldn't fight that. For a solo build or a small team, the standard library was less to learn, not more.
Final thoughts
After shipping LEVO, the upsides held up. Performance is clean because there's no reflection-heavy binding engine in the request path. Longevity is real: when Go 1.25 or 1.26 lands, I adopt new language features immediately instead of waiting on a framework maintainer to catch up. And for hiring and open source, every Go engineer alive already understands net/http, while not everyone knows the quirks of Fiber, Chi, or Echo.
Building without a framework forces you to actually understand the HTTP protocol, how context propagates, how to encode JSON safely, and how to structure a codebase yourself. If you're learning backend engineering, do it the hard way once. You'll be a better engineer for it, even on the day you decide a framework is the right call.
You can try the zero-friction event planning for yourself right now.