Skip to main content

Building Web APIs with net/http

For developers with .NET (C#) experience, this guide explains net/http, ServeMux, middleware, choosing between chi/gin/echo, and how it all maps to ASP.NET Core. In Go, the standard library's net/http alone is enough to write a production-quality HTTP server. Since Go 1.22 introduced a ServeMux that understands methods and path parameters, routing that feels close to ASP.NET Core Minimal APIs is available without any dependency. On the other hand, there is no DI container, model binding, or filter pipeline, so you assemble more of the pieces yourself.

1. What This Document Covers​

  • http.Handler and HandlerFunc
  • ServeMux routing in Go 1.22+
  • Writing middleware
  • When to use an external router (chi, etc.)
  • JSON I/O and validation

http.Handler and HandlerFunc​

An HTTP handler is, at its core, an interface with a single method.

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
  • http.ResponseWriter is where the response is written: headers, status code, and body.
  • *http.Request is the request. It carries the method, URL, headers, body, and Context.
  • There is no return value. Unlike a Minimal API that returns Results.Ok(...), you write directly to w.
  • http.HandlerFunc is an adapter type that lets a function with the signature func(http.ResponseWriter, *http.Request) be used as a Handler. mux.HandleFunc uses it internally.

ServeMux routing in Go 1.22+​

Since Go 1.22, http.ServeMux patterns can include a method and wildcards.

PatternMeaning
GET /users/users for GET only (GET also matches HEAD)
GET /users/{id}Captures one path segment as id
GET /files/{path...}Captures the rest of the path as path
GET /{$}Matches the root / exactly (a trailing {$} means exact match)
/static/A pattern ending in / matches everything under that prefix
example.com/api/Patterns can also include a host name
  • Read values with r.PathValue("id"). They are strings; conversion and error handling are up to you.
  • When several patterns match, the more specific one wins. Registering conflicting patterns where neither is more specific panics at registration time.
  • A path that exists but not for the requested method automatically gets 405 Method Not Allowed.

Writing middleware​

Go middleware is a function that takes an http.Handler and returns an http.Handler. The pipeline that app.Use(...) builds in ASP.NET Core is expressed in Go by wrapping handlers.

func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// before
next.ServeHTTP(w, r)
// after
})
}
  • If you don't call next.ServeHTTP, the rest of the chain does not run (for example, to short-circuit on an authentication failure).
  • The outermost middleware runs first. The order is determined by the order in which you wrap.
  • To apply middleware to a single route, wrap just that handler. To apply it to everything, wrap the mux and pass the result as the Handler of http.Server.

When to use an external router (chi, etc.)​

Since Go 1.22, the standard ServeMux is enough for many APIs. Consider an external router or framework when you have requirements like the following.

OptionCharacteristics
Standard net/http (ServeMux)No dependencies. Stays in the standard http.Handler shape and combines easily with other libraries
chiA lightweight router that stays http.Handler compatible while adding route grouping (Route / Group), sub-routers, and a set of middleware
gin / echoFrameworks with their own Context type. Binding and validation are built in, which reduces code but moves you away from the standard http.Handler shape
  • If you do a lot of route grouping or per-route middleware, chi is a good candidate.
  • If the standard library is enough, not adding a dependency is the default choice. Picking a router that is http.Handler compatible also keeps the cost of swapping it later low.
  • gin and echo depend on their own Context, so handlers and middleware are not interchangeable with net/http. Share that trade-off within the team before adopting them.

JSON I/O and validation​

There is nothing in the standard library equivalent to ASP.NET Core model binding or the automatic 400 response from [ApiController]. You write these steps explicitly in the handler.

  1. Decode the request with json.NewDecoder(r.Body).Decode(&req).
  2. Validate the input (use a validation library or write your own Validate method).
  3. Write the response with the proper status code using json.NewEncoder(w).Encode(v).

Headers set after calling w.WriteHeader have no effect. You must write the headers, then the status code, then the body.

2. .NET Mapping​

.NET (C#)Go
ASP.NET Core Minimal APInet/http + ServeMux
Middleware pipelineWrapping http.Handler
Controller / Model BindingManual decoding / validation inside the handler
Kestrelhttp.Server

Additional mappings that complement the table above:

.NET (C#)Go
app.MapGet("/users/{id}", ...)mux.HandleFunc("GET /users/{id}", ...)
Route parameter (int id)r.PathValue("id") (a string; you convert it yourself)
app.Use(async (ctx, next) => ...)func(next http.Handler) http.Handler
Results.Ok(obj) / Results.NotFound()json.NewEncoder(w).Encode(obj) / http.NotFound(w, r) / http.Error(w, msg, code)
HttpContext.RequestAbortedr.Context()
builder.Services.AddScoped<T>()Pass dependencies through struct fields and constructors (no DI container)
ILogger<T>*slog.Logger from log/slog
UseExceptionHandlerMiddleware that uses recover
IHostApplicationLifetime / StopAsyncsignal.NotifyContext and Server.Shutdown
WebApplicationFactory / TestServernet/http/httptest
Kestrel timeout settingsReadHeaderTimeout / ReadTimeout / WriteTimeout / IdleTimeout on http.Server

3. Common Pitfalls for C# Developers​

Handlers have no return value; they write directly to w​

In C#, returning an IResult lets the framework serialize the body and set the status code. In Go, the handler writes the response itself. If you forget return after writing an error, the code that follows still runs and the response is written twice.

if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return // if you forget this, execution continues
}

The write order: headers, status, body​

Once you call w.Write or w.WriteHeader, the headers have been sent, and later w.Header().Set calls have no effect. If you call Write without WriteHeader, the status is implicitly 200 OK. Calling WriteHeader a second time is ignored.

Path parameters are strings; conversion and error handling are yours​

The id in /users/{id} is always a string. You cannot constrain it by type with a route constraint like ASP.NET Core's {id:int}. Convert it with strconv.Atoi or similar, and return 400 or 404 when it fails.

No built-in model binding or validation​

There are no attribute-based facilities like [Required] or [FromBody]. Decoding with encoding/json fails only when types don't match and does not check whether values are valid. Use an external library such as go-playground/validator, which validates through struct tags, or write your own Validate method.

Decode request bodies defensively​

A client can send a body of any size. Cap it with http.MaxBytesReader, and reject unknown fields with DisallowUnknownFields when appropriate.

Middleware can't read the response status​

http.ResponseWriter has no method for getting the status code. To record it for logging, create a struct that wraps the ResponseWriter and override WriteHeader. However, wrapping hides optional interfaces such as http.Flusher, so implement an Unwrap() http.ResponseWriter method so that http.ResponseController (Go 1.20 and later) can reach the underlying writer.

There is no DI container: put dependencies on the handler's struct​

There is no equivalent of builder.Services.AddScoped<...>(). Give a struct fields for dependencies such as the database and logger, and make the handlers methods on that struct. When registering routes, pass the method value (s.getUser). See the DI document for details.

Don't use http.ListenAndServe as is in production​

The server created by http.ListenAndServe has no timeouts. Slow clients can exhaust connections, so in production create an http.Server explicitly and set ReadHeaderTimeout and the others. Kestrel has timeouts by default; Go's standard server does not.

Beware of r.Context() in goroutines started by a handler​

r.Context() is cancelled when the handler returns. If you pass r.Context() to asynchronous work that continues after the handler finishes, it is cancelled right away. When you intentionally want to detach, use context.WithoutCancel(r.Context()).

A panic is caught per handler​

net/http recovers a panic in a handler per connection, logs it, closes that connection, and keeps the process alive. A panic in a separate goroutine started by the handler is not recovered, though, and takes down the whole process.

4. Code Examples​

Example 1: Routing with ServeMux​

C# (Minimal API):

var app = WebApplication.Create(args);

app.MapGet("/healthz", () => "ok");
app.MapGet("/users/{id}", (int id) => $"user {id}");

app.Run("http://localhost:8080");

Go:

package main

import (
"fmt"
"log"
"net/http"
)

func main() {
mux := http.NewServeMux()

// A pattern with a method
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})

// A path parameter
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "user %s\n", id)
})

// Capture the rest of the path
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "file: %s\n", r.PathValue("path"))
})

// Match only the root "/" exactly
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "home")
})

log.Fatal(http.ListenAndServe(":8080", mux))
}

A GET to /users/42 returns user 42, and a POST to the same path returns 405 Method Not Allowed.

Example 2: JSON I/O and validation​

C# (model binding with a controller):

public record CreateUserRequest([Required] string Name, [EmailAddress] string Email);

[ApiController]
[Route("users")]
public class UsersController : ControllerBase
{
[HttpPost]
public ActionResult<User> Create(CreateUserRequest req)
{
// [ApiController] performs binding and validation and returns 400 on failure
var user = _store.Add(req.Name, req.Email);
return CreatedAtAction(nameof(Get), new { id = user.Id }, user);
}
}

In Go, you write decoding, validation, and response output explicitly in the handler. The dependency (here, the store) lives on a struct.

package main

import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
)

type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}

type createUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}

// Validate returns an error message per field. It is empty when there are no problems.
func (r createUserRequest) Validate() map[string]string {
problems := map[string]string{}
if strings.TrimSpace(r.Name) == "" {
problems["name"] = "required"
}
if !strings.Contains(r.Email, "@") {
problems["email"] = "must be an email address"
}
return problems
}

type server struct {
mu sync.Mutex
nextID int
users map[int]User
}

func newServer() *server {
return &server{nextID: 1, users: map[int]User{}}
}

func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write response", "err", err)
}
}

func (s *server) createUser(w http.ResponseWriter, r *http.Request) {
// Limit the body size (1 MiB)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // reject unknown fields

var req createUserRequest
if err := dec.Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if problems := req.Validate(); len(problems) > 0 {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"errors": problems})
return
}

s.mu.Lock()
u := User{ID: s.nextID, Name: req.Name, Email: req.Email}
s.users[u.ID] = u
s.nextID++
s.mu.Unlock()

w.Header().Set("Location", fmt.Sprintf("/users/%d", u.ID))
writeJSON(w, http.StatusCreated, u)
}

func (s *server) getUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be an integer"})
return
}

s.mu.Lock()
u, ok := s.users[id]
s.mu.Unlock()
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
return
}
writeJSON(w, http.StatusOK, u)
}

func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /users", s.createUser)
mux.HandleFunc("GET /users/{id}", s.getUser)
return mux
}

func main() {
s := newServer()
if err := http.ListenAndServe(":8080", s.routes()); err != nil {
slog.Error("server stopped", "err", err)
}
}
  • The handlers are methods on *server, registered as method values such as s.createUser. This takes the place of DI.
  • Status codes are specified with constants such as http.StatusCreated.
  • In production, you can also declare validation with something like go-playground/validator.

Example 3: Middleware (logging and panic recovery)​

C#:

app.Use(async (context, next) =>
{
var sw = Stopwatch.StartNew();
await next();
logger.LogInformation("{Method} {Path} -> {Status} in {Elapsed}ms",
context.Request.Method, context.Request.Path,
context.Response.StatusCode, sw.ElapsedMilliseconds);
});

Go:

package main

import (
"log/slog"
"net/http"
"os"
"time"
)

type Middleware func(http.Handler) http.Handler

// Chain composes middleware so that the first one is the outermost.
func Chain(h http.Handler, ms ...Middleware) http.Handler {
for i := len(ms) - 1; i >= 0; i-- {
h = ms[i](h)
}
return h
}

// statusRecorder records the status code that was written.
type statusRecorder struct {
http.ResponseWriter
status int
}

func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}

// Unwrap lets http.ResponseController reach the underlying writer.
func (s *statusRecorder) Unwrap() http.ResponseWriter {
return s.ResponseWriter
}

func logging(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()

next.ServeHTTP(rec, r)

logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
)
})
}
}

func recoverer(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
logger.Error("panic", "value", v, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

mux := http.NewServeMux()
mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) {
panic("something went wrong")
})

// recoverer is outermost, logging is innermost
handler := Chain(mux, recoverer(logger), logging(logger))

http.ListenAndServe(":8080", handler)
}
  • The order of the arguments to Chain is the order from outermost to innermost.
  • slog is the structured logging package added to the standard library in Go 1.21. NewJSONHandler produces JSON output and NewTextHandler produces text.
  • With chi, you can register middleware of the same shape using r.Use(...). Because it is http.Handler compatible, the middleware written here can be reused as is.
// chi example (github.com/go-chi/chi/v5)
r := chi.NewRouter()
r.Use(recoverer(logger), logging(logger))
r.Route("/users", func(r chi.Router) {
r.Get("/{id}", getUser)
})
// Read path parameters with chi.URLParam(req, "id")

Example 4: Timeouts and graceful shutdown​

In C#, app.Run() shuts down automatically on Ctrl+C. In Go, you assemble it yourself.

package main

import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os/signal"
"syscall"
"time"
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(3 * time.Second):
fmt.Fprintln(w, "done")
case <-r.Context().Done(): // signalled on client disconnect or Shutdown
return
}
})

srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()

select {
case err := <-errCh:
// Startup failed (for example, the port is in use)
slog.Error("server failed", "err", err)
return
case <-ctx.Done():
slog.Info("shutting down")
}

// Wait up to 10 seconds for in-flight requests to finish
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown", "err", err)
}
if err := <-errCh; !errors.Is(err, http.ErrServerClosed) {
slog.Error("server", "err", err)
}
}
  • When Shutdown is called, ListenAndServe returns http.ErrServerClosed and exits. This is the sign of a normal shutdown, not a failure.
  • Shutdown stops accepting new connections and waits for in-flight requests to complete. If the deadline of the ctx you pass expires, it gives up waiting and returns an error.
  • See the context package document for more on signals and context.

Example 5: Testing handlers with httptest​

This is a network-free test, comparable to WebApplicationFactory in C#.

package api

import (
"net/http"
"net/http/httptest"
"testing"
)

func newMux() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("user " + r.PathValue("id")))
})
return mux
}

func TestGetUser(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
rec := httptest.NewRecorder()

newMux().ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got, want := rec.Body.String(), "user 42"; got != want {
t.Errorf("body = %q, want %q", got, want)
}
}

func TestMethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/users/42", nil)
rec := httptest.NewRecorder()

newMux().ServeHTTP(rec, req)

if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
}

httptest.NewRecorder records the response in memory, so you can test the whole router without opening a port. If you want to go through real HTTP, httptest.NewServer(handler) starts a test server.

5. Summary​

  • A Go HTTP handler is an http.Handler with ServeHTTP(w, r). It has no return value and writes directly to w.
  • Since Go 1.22, ServeMux provides method-aware patterns such as GET /users/{id} and r.PathValue out of the box. Many APIs can be written with the standard library alone.
  • Middleware has the shape func(http.Handler) http.Handler, and the pipeline is built by wrapping handlers.
  • Pass dependencies as fields of the struct that owns the handlers. There is no DI container or model binding in the standard library.
  • JSON decoding, validation, error responses, and status codes are written explicitly. Decode defensively with MaxBytesReader and DisallowUnknownFields.
  • In production, set http.Server timeouts and implement graceful shutdown with signal.NotifyContext and Shutdown.
  • When you need things like route grouping, consider chi, which is http.Handler compatible. Choose gin or echo knowing that they depend on their own Context.
  • With httptest, you can test handlers and routers without using the network.

References​