Ditching frameworks: Building a custom middleware stack in Go 1.22+
When I first started my Go journey, I kept looking for the "Express" or "NestJS" of Go. I looked at Chi, Gorilla, and Gin, but the more I used them, the more I realized that after Go 1.22, the standard library is actually incredible on its own.
I wanted to stop relying on heavy frameworks just to handle basic things like Auth, CORS, or Logging. So, I decided to build my own middleware suite. It’s less than 100 lines of core logic, and it gives me total control over how my API behaves.
The Core Pattern
I used a simple pattern I found on a Dreams of Code video and tweaked it to fit my project. The secret sauce is just a function that takes an http.Handler and returns an http.Handler.
type MiddlewareFunc func(http.Handler) http.Handler
// CreateStack allows you to chain multiple middleware functions together
func (m *Middleware) CreateStack(xs ...MiddlewareFunc) MiddlewareFunc {
return func(next http.Handler) http.Handler {
for i := len(xs) - 1; i >= 0; i-- {
x := xs[i]
next = x(next)
}
return next
}
}
This CreateStack helper is what lets me write my routes in a clean, readable way without nesting ten functions deep.
The Logging Struggle (and SSE)
One thing I learned the hard way was that a simple logging middleware can break things like Server-Sent Events (SSE) or WebSockets if you aren't careful. If you wrap the ResponseWriter to capture the status code, you might lose the ability to "Flush" or "Hijack" the connection.
I had to build a wrappedWriter that implements those interfaces so my streams wouldn't buffer:
type wrappedWriter struct {
http.ResponseWriter
statusCode int
}
func (w *wrappedWriter) WriteHeader(statusCode int) {
w.ResponseWriter.WriteHeader(statusCode)
w.statusCode = statusCode
}
func (w *wrappedWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, http.ErrNotSupported
}
return h.Hijack()
}
func (w *wrappedWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (m *Middleware) Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &wrappedWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
next.ServeHTTP(wrapped, r)
slog.Info("http request",
"status", wrapped.statusCode,
"method", r.Method,
"path", r.URL.Path,
"duration", time.Since(start),
)
})
}
Adding Rate Limiting
Because I’m building this myself, adding something like rate limiting was surprisingly easy using golang.org/x/time/rate. I wanted it to be smart: if a user is logged in, limit them by their UserID; if they are anonymous, limit them by their IP address.
func (m *Middleware) RateLimit() MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var key string
uid := m.session.GetString(r.Context(), string(UserIDKey))
if uid != "" {
key = uid
} else {
key = GetClientIP(r)
}
m.mu.Lock()
// limter here is map[string]*client
// where the client is a custom struct
// type client struct {
// limiter *rate.Limiter //<- from golang.org/x/time/rate
// lastSeen time.Time
// }
v, exists := m.limiters[key]
if !exists {
v = &client{
limiter: rate.NewLimiter(m.rateLimiConfig.Rate, m.rateLimiConfig.Burst),
}
m.limiters[key] = v
}
v.lastSeen = time.Now()
m.mu.Unlock()
if !v.limiter.Allow() {
WriteJSON(w, ResponseMessage{Message: "Too Many Requests"}, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
Authorization and RBAC
I also wanted a clean way to handle permissions. I built an IsAuthorized check that verifies the session and injects the UserID into the context, followed by a HasPermission gatekeeper for more granular control.
func (m *Middleware) IsAuthorized(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uidString := m.session.GetString(r.Context(), string(UserIDKey))
if uidString == "" {
WriteJSON(w, ResponseMessage{Message: "Unauthorized"}, http.StatusUnauthorized)
return
}
uid, err := uuid.Parse(uidString)
if err != nil {
WriteJSON(w, ResponseMessage{Message: "Invalid session data"}, http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, uid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *Middleware) HasPermission(requiredPermission ...user.UserPermission) MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := GetUserID(r.Context())
if !ok {
WriteJSON(w, ResponseMessage{Message: "Unauthorized Access"}, http.StatusUnauthorized)
return
}
u, err := m.userService.GetUser(r.Context(), uid)
if err != nil {
WriteJSON(w, ResponseMessage{Message: "User not found"}, http.StatusForbidden)
return
}
hasPerm, err := m.userService.HasPermissions(r.Context(), u.Role, requiredPermission...)
if err != nil || !hasPerm {
WriteJSON(w, ResponseMessage{Message: "Forbidden: Missing Permission"}, http.StatusForbidden)
return
}
ctx := context.WithValue(r.Context(), UserKey, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
The cool thing about this is how readable it makes the routes. I can wrap a whole mux or just a single endpoint like this:
mux.Handle("GET /chat", mw.HasPermissions(h.AskAIAssistant, user.PermAITutorAccess))
The Timeout "Gotcha"
The standard http.TimeoutHandler is great, but it buffers responses, which completely breaks Server-Sent Events (SSE). I had to write a custom timeout middleware that detects the Accept: text/event-stream header and handles it differently.
func (m *Middleware) Timeout(d time.Duration) MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "text/event-stream" {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
return
}
h := http.TimeoutHandler(next, d, `{"message": "Service Timeout"}`)
w.Header().Set("Content-Type", "application/json")
h.ServeHTTP(w, r)
})
}
}
Putting it all together
Now, my routing tree looks like a real pipeline. I separate public routes from secured ones and apply the "Global Stack" to everything.
func (s *Server) routes() http.Handler {
pubMux := http.NewServeMux()
s.authHandler.RouteRegister(pubMux, s.middleware)
securedMux := http.NewServeMux()
authStack := s.middleware.CreateStack(s.middleware.IsAuthorized)
// Wrap the secured mux with auth
pubMux.Handle("/", authStack(securedMux))
// Global Pipeline
globalStack := s.middleware.CreateStack(
s.middleware.Timeout(s.config.Timeout),
s.middleware.Logging,
s.middleware.CORS(),
s.sessionManager.LoadAndSave,
s.middleware.RateLimit(),
)
return globalStack(pubMux)
}
Building this myself taught me more about the HTTP lifecycle than any framework ever could. I’m not fighting against a library’s opinion—I’m just using the language the way it was designed. It’s fast, it’s readable, and I know exactly what every line of code is doing.