- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A team I spoke with recently had a middleware chain they called "the pipeline." Auth, rate limiting, input validation, logging, tracing, tenant resolution, feature flags. All of it threaded through func(http.Handler) http.Handler wrappers that passed values via context.WithValue. The chain was 200 lines before any handler ran. When they needed to add gRPC, they realized none of that logic was reusable. Every business rule was buried inside HTTP-specific code.
This is the most common architectural mistake in Go web services. Not bad naming. Not missing tests. Middleware that quietly becomes the application.
What Middleware Should Actually Do
Middleware belongs to the transport layer. It sits between the network and your application. Its job is narrow: inspect or modify the HTTP request/response before the handler sees it.
Good middleware concerns:
- CORS headers -- transport-level negotiation.
- Request logging -- when the request arrived, how long it took, what status code went back.
- Distributed tracing -- extract trace IDs from headers, inject them into context.
- Panic recovery -- catch panics and return a 500 instead of crashing the process.
These are cross-cutting concerns that care about HTTP. They do not care about your domain, make business-rule decisions, or validate whether the order total exceeds a threshold.
The 200-Line Middleware That Ate the Domain
Here is a condensed version of what goes wrong. You have probably seen code shaped like this:
func AuthAndValidateMiddleware(
next http.Handler,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
// 1. Extract token
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", 401)
return
}
// 2. Decode and verify JWT
claims, err := verifyJWT(token)
if err != nil {
http.Error(w, "invalid token", 401)
return
}
// 3. Load user from database
user, err := db.FindUserByID(claims.UserID)
if err != nil {
http.Error(w, "user not found", 403)
return
}
So far, arguable. Token extraction and user lookup could live in middleware. But watch what comes next.
// 4. Check subscription tier
if user.Plan == "free" &&
r.URL.Path == "/api/reports" {
http.Error(
w,
"upgrade required",
http.StatusPaymentRequired,
)
return
}
// 5. Rate limit by plan
limit := 100
if user.Plan == "enterprise" {
limit = 10000
}
if !rateLimiter.Allow(user.ID, limit) {
http.Error(
w,
"rate limited",
http.StatusTooManyRequests,
)
return
}
Subscription checks and plan-based rate limiting are business decisions, not transport concerns. It gets worse.
// 6. Parse and validate body
if r.Method == http.MethodPost {
body, _ := io.ReadAll(r.Body)
var input map[string]interface{}
json.Unmarshal(body, &input)
if input["amount"] != nil {
amt := input["amount"].(float64)
if amt > 50000 {
http.Error(
w,
"amount exceeds limit",
400,
)
return
}
}
r.Body = io.NopCloser(
bytes.NewReader(body),
)
}
// 7. Stuff everything into context
ctx := context.WithValue(
r.Context(), "user", user,
)
ctx = context.WithValue(
ctx, "plan", user.Plan,
)
next.ServeHTTP(w, r.WithContext(ctx))
},
)
}
Count the responsibilities. Token extraction. JWT verification. User lookup from a database. Subscription-tier enforcement. Plan-based rate limiting. Request body parsing. Input validation against a business rule (the 50,000 amount cap). Context stuffing.
This is not middleware. This is an application that happens to live inside func(http.Handler) http.Handler.
The problems compound:
- You cannot test the subscription check without constructing an
http.Request. - You cannot reuse the rate-limit logic for a gRPC service.
- The 50,000 amount cap is a business rule buried in transport code. When the product team changes it to 75,000, someone has to edit an HTTP middleware file.
- Every handler downstream does
ctx.Value("user").(User)-- untyped, unchecked, crashes at runtime if the middleware order changes.
The Fix: Thin Middleware, Domain Service, Adapter
The separation follows hexagonal architecture. Three layers, each with a clear job.
Middleware handles transport-only concerns. It extracts identity from the HTTP request and passes a typed struct forward -- nothing more.
The inbound adapter (your HTTP handler) translates the HTTP request into a domain call. It reads the typed identity, parses the request body into a domain input struct, and calls the domain service.
The domain service owns every business rule. Subscription checks, amount limits, rate-limit policies. It knows nothing about HTTP.
Step 1: Auth middleware becomes identity extraction
type Identity struct {
UserID string
Email string
}
type ctxKey string
const identityKey ctxKey = "identity"
The typed context key prevents collisions with any other package that stores values in context. With the types in place, the middleware itself is short.
func AuthMiddleware(
verifier TokenVerifier,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
w http.ResponseWriter,
r *http.Request,
) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(
w,
`{"error":"missing token"}`,
http.StatusUnauthorized,
)
return
}
id, err := verifier.Verify(token)
if err != nil {
http.Error(
w,
`{"error":"invalid token"}`,
http.StatusUnauthorized,
)
return
}
ctx := context.WithValue(
r.Context(),
identityKey,
id,
)
next.ServeHTTP(
w,
r.WithContext(ctx),
)
},
)
}
}
The TokenVerifier is an interface -- the middleware does not know whether the token is a JWT, an opaque session ID, or an API key. A typed helper gives handlers a clean extraction point, replacing the ctx.Value("user").(User) casts scattered across the old codebase.
func IdentityFrom(ctx context.Context) (Identity, bool) {
id, ok := ctx.Value(identityKey).(Identity)
return id, ok
}
The middleware no longer touches the database, checks subscription plans, or parses request bodies. It verifies a token, produces a typed Identity, and moves on.
Step 2: The domain service owns the business rules
type OrderService struct {
users UserRepository
orders OrderRepository
}
func NewOrderService(
users UserRepository,
orders OrderRepository,
) *OrderService {
return &OrderService{
users: users,
orders: orders,
}
}
type PlaceOrderInput struct {
UserID string
Item string
Amount float64
}
The service and its input struct depend on nothing from net/http. The method that does the work enforces every business rule in one place.
func (s *OrderService) PlaceOrder(
ctx context.Context,
input PlaceOrderInput,
) (Order, error) {
user, err := s.users.FindByID(ctx, input.UserID)
if err != nil {
return Order{}, fmt.Errorf(
"lookup user: %w", err,
)
}
if user.Plan == "free" {
return Order{}, ErrUpgradeRequired
}
if input.Amount > 50000 {
return Order{}, ErrAmountExceedsLimit
}
order := Order{
ID: generateID(),
UserID: input.UserID,
Item: input.Item,
Amount: input.Amount,
}
if err := s.orders.Save(ctx, order); err != nil {
return Order{}, fmt.Errorf(
"save order: %w", err,
)
}
return order, nil
}
The subscription check is here. The amount cap is here. These are business decisions. They belong in the domain, not in a middleware file.
The service depends on UserRepository and OrderRepository -- interfaces. It does not import net/http. It does not know the request came from a browser, a CLI tool, or a gRPC client.
Step 3: The HTTP handler is the adapter
type OrderHandler struct {
orders *OrderService
}
func NewOrderHandler(
orders *OrderService,
) *OrderHandler {
return &OrderHandler{orders: orders}
}
The handler struct holds a reference to the domain service and nothing else. The method that handles the route does the translation work.
func (h *OrderHandler) PlaceOrder() http.HandlerFunc {
return func(
w http.ResponseWriter,
r *http.Request,
) {
id, ok := IdentityFrom(r.Context())
if !ok {
http.Error(
w,
`{"error":"no identity"}`,
http.StatusUnauthorized,
)
return
}
var body struct {
Item string `json:"item"`
Amount float64 `json:"amount"`
}
if err := json.NewDecoder(r.Body).Decode(
&body,
); err != nil {
http.Error(
w,
`{"error":"invalid JSON"}`,
http.StatusBadRequest,
)
return
}
That covers the inbound translation -- extract identity, parse the body into a domain struct. Now the handler calls the domain service and maps its errors back to HTTP status codes.
order, err := h.orders.PlaceOrder(
r.Context(),
PlaceOrderInput{
UserID: id.UserID,
Item: body.Item,
Amount: body.Amount,
},
)
switch {
case errors.Is(err, ErrUpgradeRequired):
http.Error(
w,
`{"error":"upgrade required"}`,
http.StatusPaymentRequired,
)
case errors.Is(err, ErrAmountExceedsLimit):
http.Error(
w,
`{"error":"amount exceeds limit"}`,
http.StatusBadRequest,
)
case err != nil:
http.Error(
w,
`{"error":"internal error"}`,
http.StatusInternalServerError,
)
default:
w.Header().Set(
"Content-Type",
"application/json",
)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
}
}
The handler does three things. It extracts the identity that the middleware placed in context. It parses the HTTP-specific body into a domain input. It calls the domain service and translates the domain error back into an HTTP status code. That is the adapter pattern: translate between the outside world and the domain.
Step 4: Wire it in main
func main() {
db := connectDB()
userRepo := postgres.NewUserRepository(db)
orderRepo := postgres.NewOrderRepository(db)
verifier := jwt.NewVerifier(os.Getenv("JWT_SECRET"))
orderSvc := NewOrderService(userRepo, orderRepo)
orderHandler := NewOrderHandler(orderSvc)
mux := http.NewServeMux()
mux.HandleFunc(
"POST /orders",
orderHandler.PlaceOrder(),
)
stack := AuthMiddleware(verifier)(mux)
stack = LoggingMiddleware(stack)
log.Fatal(
http.ListenAndServe(":8080", stack),
)
}
Two middleware layers: logging and auth. Both are transport concerns. The business rules live inside OrderService. If you add gRPC tomorrow, you write a new gRPC handler that calls the same OrderService.PlaceOrder. The subscription check, the amount cap -- they come for free.
Where Rate Limiting Goes
Rate limiting is an interesting edge case. It looks like a transport concern (you want to reject requests before they do work), but the policy is often a business decision (free users get 100 requests/minute, enterprise gets 10,000).
Split it the same way. The transport layer enforces the limit. The domain defines the policy.
type RateLimitPolicy struct {
RequestsPerMinute int
}
func (s *UserService) GetRateLimitPolicy(
ctx context.Context,
userID string,
) (RateLimitPolicy, error) {
user, err := s.users.FindByID(ctx, userID)
if err != nil {
return RateLimitPolicy{}, err
}
switch user.Plan {
case "enterprise":
return RateLimitPolicy{
RequestsPerMinute: 10000,
}, nil
default:
return RateLimitPolicy{
RequestsPerMinute: 100,
}, nil
}
}
The rate-limit middleware calls GetRateLimitPolicy, gets a number, and enforces it. If the product team changes the tiers, the middleware code does not change. Only the domain function does.
What You Gain
Testability. The domain service is a plain struct. Test it with go test, no httptest.NewRequest needed. Pass in-memory repository implementations and assert results.
func TestPlaceOrder_FreePlanRejected(t *testing.T) {
users := &fakeUserRepo{
user: User{
ID: "u1", Plan: "free",
},
}
orders := &fakeOrderRepo{}
svc := NewOrderService(users, orders)
_, err := svc.PlaceOrder(
context.Background(),
PlaceOrderInput{
UserID: "u1",
Item: "widget",
Amount: 100,
},
)
if !errors.Is(err, ErrUpgradeRequired) {
t.Fatalf(
"expected ErrUpgradeRequired, got %v",
err,
)
}
}
No HTTP server. No token. No JSON. The test runs in microseconds and verifies the business rule directly.
Portability. A gRPC handler, a CLI tool, a Kafka consumer -- all call the same service. None of them rewrite the subscription check.
Readability. When a new developer opens the middleware file, they see logging and auth. When they open the service file, they see business rules. The cognitive load drops because each file has one reason to exist.
Changeability. The 50,000 amount cap changes to 75,000. One line in OrderService. Not one line in middleware, one line in the handler, and one line in the gRPC interceptor.
The Heuristic
When you are about to add a line to middleware, ask one question: does this decision change if I switch from HTTP to gRPC?
If the answer is no -- the subscription check still applies, the amount cap still applies, the user lookup still happens -- then it belongs in the domain service, not in middleware.
If the answer is yes -- CORS headers go away, trace header extraction changes, logging format differs -- then it belongs in the transport layer.
That single question will keep your middleware thin and your domain clean.
If this was useful
The before/after in this post is a small slice of what hexagonal architecture looks like in a real Go codebase. Port design, adapter boundaries, error translation across layers, testing at every level, transactions that span adapters -- the book covers all of it across 22 chapters.
If your middleware chain is quietly becoming your application, the architecture section of Hexagonal Architecture in Go walks through exactly how to untangle it.

Top comments (0)