Skip to main content
Go for JVM Engineers

Web Servers from net/http

Ravinder··5 min read
GoJVMJavaHTTPWebServerMiddleware
Share:
Web Servers from net/http

Spring Boot made HTTP server setup a matter of adding an annotation and letting autoconfiguration handle the rest. Go's net/http takes the opposite stance: it gives you a small, composable set of types and expects you to wire them together explicitly. The result is less magic, faster startup, and a binary that does not need an application server sitting beside it.

This post maps Spring MVC concepts to net/http primitives and shows how to build the middleware chain that most production services need.

The Core Types

Go's HTTP server is built on three interfaces and one function:

// The handler contract — the equivalent of @RequestMapping method
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
 
// HandlerFunc adapts a function to satisfy Handler
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }

http.ResponseWriter is a write-once response stream. *http.Request carries the incoming request, body, headers, and URL parameters.

// Spring MVC
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable String id) { ... }
// Go net/http
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // Go 1.22+ path parameters
    user, err := store.Find(r.Context(), id)
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
})

Go 1.22 added method-prefixed patterns (GET /path, POST /path) and {param} path variables to http.ServeMux, making third-party routers optional for most services.

Starting the Server

srv := &http.Server{
    Addr:         ":8080",
    Handler:      mux,
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  120 * time.Second,
}
 
log.Fatal(srv.ListenAndServe())

Always set timeouts. A server without timeouts is a slow-loris attack waiting to happen — Spring Boot's embedded Tomcat has these configured by default; Go's http.ListenAndServe shorthand does not.

Middleware — The Filter Chain

Spring Boot uses OncePerRequestFilter or a HandlerInterceptor. Go uses the decorator pattern: a function that wraps a Handler and returns a new Handler.

// Spring — filter
@Component
public class RequestIdFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req,
            HttpServletResponse res, FilterChain chain) throws ... {
        req.setAttribute("requestId", UUID.randomUUID().toString());
        chain.doFilter(req, res);
    }
}
// Go — middleware
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := uuid.New().String()
        ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Middleware is composed by wrapping:

handler := RequestID(
    Logger(
        Recovery(
            mux,
        ),
    ),
)
 
srv := &http.Server{Addr: ":8080", Handler: handler}
sequenceDiagram participant C as Client participant RI as RequestID MW participant LG as Logger MW participant RC as Recovery MW participant H as Handler C->>RI: HTTP Request RI->>LG: inject request ID LG->>RC: log start RC->>H: catch panics H-->>RC: response RC-->>LG: pass through LG-->>RI: log duration RI-->>C: add X-Request-ID header

A Production-Ready Logging Middleware

type statusRecorder struct {
    http.ResponseWriter
    status int
}
 
func (r *statusRecorder) WriteHeader(code int) {
    r.status = code
    r.ResponseWriter.WriteHeader(code)
}
 
func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(rec, r)
        slog.InfoContext(r.Context(), "http",
            "method", r.Method,
            "path", r.URL.Path,
            "status", rec.status,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    })
}

Dependency Injection Without a Framework

Spring's @Autowired wires beans at startup. Go uses plain constructor functions and closures — no reflection, no annotation scanning.

// Spring
@Service
public class UserService {
    @Autowired UserRepository repo;
}
// Go — explicit dependency injection
type UserHandler struct {
    store UserStore
    log   *slog.Logger
}
 
func NewUserHandler(store UserStore, log *slog.Logger) *UserHandler {
    return &UserHandler{store: store, log: log}
}
 
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    u, err := h.store.Find(r.Context(), id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(u)
}
 
// Wired in main
store := postgres.NewUserStore(db)
handler := NewUserHandler(store, slog.Default())
mux.HandleFunc("GET /users/{id}", handler.Get)

Graceful Shutdown

Spring Boot handles SIGTERM through its embedded container. In Go, hook the OS signal manually:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
 
go func() {
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatalf("server: %v", err)
    }
}()
 
<-ctx.Done()
log.Println("shutting down...")
 
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Fatalf("shutdown: %v", err)
}

When to Reach for a Third-Party Router

http.ServeMux in Go 1.22+ covers most routing needs. Reach for a third-party router only when you need:

  • Route grouping with shared middleware applied to a sub-tree
  • Wildcard/regex path matching beyond {param}
  • Automatic OPTIONS and 405 handling

Popular options: chi (lightweight, stdlib-compatible middleware), httprouter (fastest path matching), echo or gin (batteries-included, opinionated). chi is the most idiomatic choice for teams coming from a Spring background.

Key Takeaways

  • net/http is production-ready without a framework; Go 1.22's enhanced ServeMux adds method-prefix routing and path parameters, removing the main reason to add a third-party router.
  • Middleware in Go is the decorator pattern over http.Handler — wrap, call next, wrap again; the composability matches Spring's filter chain without annotation magic.
  • Always configure ReadTimeout, WriteTimeout, and IdleTimeout on http.Server — the zero values are infinite and leave the server vulnerable.
  • Dependency injection in Go is constructor functions and struct fields, not annotations or a DI container — it is explicit, testable, and refactoring-safe.
  • Capture response status code with a wrapping ResponseWriter so middleware can log it after the handler completes.
  • Implement graceful shutdown with signal.NotifyContext and srv.ShutdownSIGTERM handling is a two-liner, not a framework concern.
Share: