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}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/httpis production-ready without a framework; Go 1.22's enhancedServeMuxadds 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, andIdleTimeoutonhttp.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
ResponseWriterso middleware can log it after the handler completes. - Implement graceful shutdown with
signal.NotifyContextandsrv.Shutdown—SIGTERMhandling is a two-liner, not a framework concern.