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.ResponseWriteris where the response is written: headers, status code, and body.*http.Requestis the request. It carries the method, URL, headers, body, andContext.- There is no return value. Unlike a Minimal API that returns
Results.Ok(...), you write directly tow. http.HandlerFuncis an adapter type that lets a function with the signaturefunc(http.ResponseWriter, *http.Request)be used as aHandler.mux.HandleFuncuses it internally.
ServeMux routing in Go 1.22+
Since Go 1.22, http.ServeMux patterns can include a method and wildcards.
| Pattern | Meaning |
|---|---|
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
muxand pass the result as theHandlerofhttp.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.
| Option | Characteristics |
|---|---|
Standard net/http (ServeMux) | No dependencies. Stays in the standard http.Handler shape and combines easily with other libraries |
| chi | A lightweight router that stays http.Handler compatible while adding route grouping (Route / Group), sub-routers, and a set of middleware |
| gin / echo | Frameworks 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.Handlercompatible also keeps the cost of swapping it later low. - gin and echo depend on their own
Context, so handlers and middleware are not interchangeable withnet/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.
- Decode the request with
json.NewDecoder(r.Body).Decode(&req). - Validate the input (use a validation library or write your own
Validatemethod). - 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 API | net/http + ServeMux |
| Middleware pipeline | Wrapping http.Handler |
| Controller / Model Binding | Manual decoding / validation inside the handler |
| Kestrel | http.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.RequestAborted | r.Context() |
builder.Services.AddScoped<T>() | Pass dependencies through struct fields and constructors (no DI container) |
ILogger<T> | *slog.Logger from log/slog |
UseExceptionHandler | Middleware that uses recover |
IHostApplicationLifetime / StopAsync | signal.NotifyContext and Server.Shutdown |
WebApplicationFactory / TestServer | net/http/httptest |
| Kestrel timeout settings | ReadHeaderTimeout / 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 ass.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
Chainis the order from outermost to innermost. slogis the structured logging package added to the standard library in Go 1.21.NewJSONHandlerproduces JSON output andNewTextHandlerproduces text.- With chi, you can register middleware of the same shape using
r.Use(...). Because it ishttp.Handlercompatible, 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
Shutdownis called,ListenAndServereturnshttp.ErrServerClosedand exits. This is the sign of a normal shutdown, not a failure. Shutdownstops accepting new connections and waits for in-flight requests to complete. If the deadline of thectxyou 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.HandlerwithServeHTTP(w, r). It has no return value and writes directly tow. - Since Go 1.22,
ServeMuxprovides method-aware patterns such asGET /users/{id}andr.PathValueout 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
MaxBytesReaderandDisallowUnknownFields. - In production, set
http.Servertimeouts and implement graceful shutdown withsignal.NotifyContextandShutdown. - When you need things like route grouping, consider chi, which is
http.Handlercompatible. Choose gin or echo knowing that they depend on their ownContext. - With
httptest, you can test handlers and routers without using the network.