Cuando alguien empieza con Go y quiere montar una API, lo primero que hace es buscar un framework. Gin, Echo, Fiber... hay un buen puñado. Pero Go lleva años enviando a producción servidores HTTP sin una sola dependencia externa. Kubernetes lo hace. Docker también. El paquete net/http de la librería estándar no es un prototipo, es código que aguanta tráfico real.
Hasta Go 1.21 había un motivo legítimo para tirar de gorilla/mux o chi: el ServeMux nativo no aceptaba parámetros de ruta ni filtraba por método HTTP. Tenías que parsear la URL a mano o usar una librería. Con Go 1.22, publicado en febrero de 2024, eso cambió. El mux estándar ya admite patrones como GET /users/{id} y obtener el valor del parámetro en el handler es trivial.
¿Cuándo tiene sentido usar un framework? Cuando necesitas binding automático de formularios, validación declarativa, o un conjunto de middlewares ya escritos y mantenidos por otros. Si tu API hace JSON puro y tus rutas son razonablemente simples, net/http te va a sobrar.
El nuevo ServeMux de Go 1.22
Crear un mux es una línea:
mux := http.NewServeMux()
A partir de ahí registras rutas con la sintaxis nueva, que incluye el método HTTP delante del patrón:
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
Para leer el parámetro {id} dentro del handler usas r.PathValue("id"):
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// id es un string con el valor capturado
}
También puedes capturar el resto de la ruta con un wildcard de segmento múltiple:
mux.HandleFunc("GET /files/{path...}", serveFile)
Cuando dos patrones pueden coincidir con la misma ruta, gana el más específico. No hay magia: si registras /users/me y /users/{id}, una petición a /users/me aterriza en el primero.
Montar el servidor
Crear el servidor directamente con http.ListenAndServe funciona, pero te deja sin control sobre los timeouts. Mejor usar http.Server explícito:
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(server.ListenAndServe())
ReadTimeout cuenta desde que llega la conexión hasta que termina de leer el body de la request. WriteTimeout cubre el tiempo que tardas en escribir la respuesta. Sin estos valores, una conexión lenta o maliciosa puede quedarse abierta indefinidamente.
Graceful shutdown
En producción quieres que el servidor deje de aceptar conexiones nuevas al recibir SIGTERM pero termine de servir las requests que ya están en curso. El patrón estándar es este:
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatal("Error en shutdown:", err)
}
}()
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
server.Shutdown(ctx) cierra el listener, espera a que terminen las requests activas y devuelve el control cuando todas han acabado o cuando expira el contexto.
Handlers: la interfaz http.Handler
En Go cualquier tipo que implemente ServeHTTP(w http.ResponseWriter, r *http.Request) es un handler válido. Para funciones sueltas existe el adaptador http.HandlerFunc, que convierte una función con la firma correcta en un http.Handler:
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hola")
})
Cuando usas mux.HandleFunc, Go hace esa conversión por ti internamente.
Responder con JSON
El orden importa: primero pones los headers, luego el status code, luego el body. Si escribes algo en el body antes de llamar a WriteHeader, Go manda un 200 automático y ya no puedes cambiar el status.
func respondJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, msg string) {
respondJSON(w, status, map[string]string{"error": msg})
}
Define estas dos funciones una vez y úsalas en todos tus handlers. Así la estructura de tus respuestas es consistente sin esfuerzo.
Middleware: el patrón de envoltura
Un middleware en net/http es una función que recibe un http.Handler y devuelve otro. Dentro ejecuta lo que necesita antes y después de llamar al handler original:
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
respondError(w, http.StatusUnauthorized, "token requerido")
return
}
next.ServeHTTP(w, r)
})
}
Para aplicarlos a una ruta concreta los encadenas:
mux.Handle("GET /users/{id}", LoggingMiddleware(AuthMiddleware(http.HandlerFunc(getUser))))
El anidamiento se vuelve incómodo cuando tienes cuatro o cinco middlewares. Para esos casos la librería justinas/alice ofrece una sintaxis más limpia sin añadir magia al asunto:
chain := alice.New(LoggingMiddleware, AuthMiddleware)
mux.Handle("GET /users/{id}", chain.ThenFunc(getUser))
Leer y parsear el body
Para requests con body JSON el patrón es este:
func createUser(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
respondError(w, http.StatusUnsupportedMediaType, "se esperaba application/json")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
defer r.Body.Close()
var input struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
respondError(w, http.StatusBadRequest, "JSON inválido: "+err.Error())
return
}
// procesar input...
respondJSON(w, http.StatusCreated, input)
}
http.MaxBytesReader es el detalle que se suele olvidar. Sin él, alguien puede mandarte un body de varios gigas y tu servidor intentará leerlo entero. Con el límite puesto, json.Decode devuelve error si el body supera el máximo.
Respuestas de error consistentes
Usa siempre las constantes del paquete, no los números:
http.StatusOK // 200
http.StatusCreated // 201
http.StatusBadRequest // 400
http.StatusUnauthorized // 401
http.StatusForbidden // 403
http.StatusNotFound // 404
http.StatusInternalServerError // 500
Para estructurar los errores, define un tipo propio al principio del proyecto:
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func newError(w http.ResponseWriter, status int, msg string) {
respondJSON(w, status, APIError{Code: status, Message: msg})
}
Si cambias de opinión sobre el formato de error más adelante, solo tocas este sitio.
Testing de handlers sin levantar un servidor
Una de las ventajas de net/http es que el paquete net/http/httptest te permite probar handlers de forma directa, sin red:
func TestGetUser(t *testing.T) {
req := httptest.NewRequest("GET", "/users/42", nil)
rec := httptest.NewRecorder()
// Montar el mux para que PathValue funcione
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("esperaba 200, got %d", rec.Code)
}
var body map[string]any
json.NewDecoder(rec.Body).Decode(&body)
// verificar body...
}
httptest.NewRecorder() implementa http.ResponseWriter y graba todo lo que escribes. rec.Code tiene el status, rec.Body tiene el cuerpo. No hace falta mock ni librería de testing extra.
Una nota sobre PathValue en tests: si llamas al handler directamente en lugar de pasar por el mux, r.PathValue("id") devolverá cadena vacía porque nadie ha registrado el patrón. Monta el mux en el test, como en el ejemplo, y todo funciona igual que en producción.
¿Vale la pena prescindir del framework?
Para una API con diez o veinte rutas, sí. El resultado es un binario sin dependencias externas, más fácil de auditar y de actualizar. Cuando tu equipo crece o necesitas features que la stdlib no da (binding automático, validación por struct tags, generación de OpenAPI), los frameworks tienen su sitio. Pero empezar con net/http te enseña exactamente lo que pasa en cada capa, y eso vale más que la comodidad inicial de un generador de scaffolding.
Si estás pensando en cuándo Go encaja y cuándo no en tu stack, puede ayudarte este comparativo: Go vs Rust para servicios web. Y si quieres ver qué más ha cambiado en el lenguaje en los últimos años, echa un ojo a Go en microservicios y APIs en 2025.
Imagen: Pexels / Pixabay
