![]()
Routing con Chi en Go: estructura limpia, middlewares y rutas escalables
Cuando una API comienza con tres o cuatro endpoints, cualquier implementación basada en net/http parece suficiente. El problema aparece meses después, cuando el proyecto tiene autenticación, versionado, decenas de rutas y varios equipos trabajando al mismo tiempo.
En ese punto, mantener un archivo lleno de llamadas a http.HandleFunc() se vuelve incómodo. Las rutas empiezan a mezclarse, los middlewares terminan duplicados y cada cambio requiere revisar demasiados lugares.
Ahí es donde Chi destaca. No intenta reemplazar la librería estándar, sino extenderla con un router flexible, rápido y muy sencillo de organizar.
Una de sus mayores ventajas es que sigue utilizando las interfaces estándar de Go, por lo que migrar desde net/http resulta bastante natural.
¿Por qué elegir Chi?
Chi está construido sobre net/http, lo que significa que cualquier middleware o herramienta compatible con la librería estándar seguirá funcionando.
Entre sus características destacan:
- Router extremadamente rápido.
- Soporte para parámetros dinámicos.
- Agrupación de rutas.
- Middlewares por grupo.
- Versionado sencillo de APIs.
- Compatibilidad total con
context.Context. - Código muy legible.
La organización del proyecto mejora considerablemente cuando la cantidad de endpoints empieza a crecer.
Instalación
Crear un proyecto nuevo:
mkdir chi-api
cd chi-api
go mod init github.com/usuario/chi-api
Instalar Chi:
go get github.com/go-chi/chi/v5
También resulta útil instalar los middlewares oficiales.
go get github.com/go-chi/chi/v5/middleware
Primer servidor
Un servidor mínimo puede verse así:
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hola desde Chi"))
})
http.ListenAndServe(":8080", r)
}
Ejecutar:
go run .
Probar:
GET http://localhost:8080/
Respuesta:
Hola desde Chi
La diferencia frente a http.ServeMux todavía parece pequeña, pero cambia bastante cuando aparecen más rutas.
Agregando middlewares
Uno de los errores más frecuentes consiste en escribir el mismo código de logging, recuperación de errores o validación en cada controlador.
Chi permite aplicar middlewares de forma centralizada.
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Servidor activo"))
})
http.ListenAndServe(":8080", r)
}
Con únicamente esas dos líneas ya se obtiene:
- Registro automático de solicitudes.
- Recuperación de pánicos sin detener el servidor.
En producción es habitual agregar también:
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Timeout(30 * time.Second))
Esto facilita el rastreo de errores y evita solicitudes que permanecen abiertas indefinidamente.
Parámetros en la URL
Supongamos una API de usuarios.
GET /users/10
Con Chi basta definir:
r.Get("/users/{id}", getUser)
El controlador:
func getUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
w.Write([]byte("Usuario: " + id))
}
Solicitud:
GET /users/15
Respuesta:
Usuario: 15
No hace falta manipular expresiones regulares ni dividir manualmente la ruta.
Múltiples métodos HTTP
Chi permite declarar cada método de forma explícita.
r.Get("/products", listProducts)
r.Post("/products", createProduct)
r.Put("/products/{id}", updateProduct)
r.Delete("/products/{id}", deleteProduct)
El código resulta mucho más legible que realizar validaciones sobre r.Method.
Agrupando rutas
Cuando una API crece, agrupar endpoints evita repetir prefijos.
r.Route("/users", func(r chi.Router) {
r.Get("/", listUsers)
r.Post("/", createUser)
r.Get("/{id}", getUser)
r.Put("/{id}", updateUser)
r.Delete("/{id}", deleteUser)
})
Las rutas generadas serán:
GET /users
POST /users
GET /users/{id}
PUT /users/{id}
DELETE /users/{id}
Toda la lógica relacionada permanece en un único bloque.
Versionando una API
Una práctica muy común consiste en versionar los endpoints.
r.Route("/api", func(r chi.Router) {
r.Route("/v1", func(r chi.Router) {
r.Get("/users", listUsers)
r.Post("/users", createUser)
})
})
Después será sencillo agregar una nueva versión.
/api/v2/users
Sin romper clientes existentes.
Middlewares por grupo
No todas las rutas requieren autenticación.
Un error bastante habitual consiste en proteger absolutamente todo el router.
Con Chi se puede aplicar un middleware únicamente donde hace falta.
r.Route("/admin", func(r chi.Router) {
r.Use(AuthMiddleware)
r.Get("/dashboard", dashboard)
r.Get("/users", adminUsers)
})
Mientras tanto, las rutas públicas permanecen accesibles.
GET /
GET /login
GET /products
Este enfoque reduce la complejidad y evita comprobaciones innecesarias.
Middleware personalizado
Supongamos que todas las rutas administrativas requieren un token.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
Aplicarlo es sencillo.
r.Use(AuthMiddleware)
O únicamente sobre un grupo específico.
r.Route("/admin", func(r chi.Router) {
r.Use(AuthMiddleware)
})
En proyectos reales este middleware normalmente valida un JWT o consulta un servicio de autenticación.
Enviando respuestas JSON
La mayoría de APIs devuelven JSON.
package main
import (
"encoding/json"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func getUser(w http.ResponseWriter, r *http.Request) {
user := User{
ID: 1,
Name: "Carlos",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
Respuesta:
{
"id": 1,
"name": "Carlos"
}
Usar json.NewEncoder() evita crear cadenas manualmente y reduce errores de formato.
Organización recomendada del proyecto
Una estructura sencilla facilita el mantenimiento.
project/
├── cmd/
│ └── api/
│ └── main.go
│
├── internal/
│ ├── handlers/
│ ├── middleware/
│ ├── routes/
│ ├── models/
│ └── services/
│
├── go.mod
└── go.sum
El archivo main.go debería limitarse a iniciar el servidor.
Toda la definición de rutas puede vivir en:
package routes
import (
"github.com/go-chi/chi/v5"
)
func Register(r chi.Router) {
r.Get("/", Home)
r.Route("/users", func(r chi.Router) {
r.Get("/", ListUsers)
})
}
Y desde main.go únicamente:
r := chi.NewRouter()
routes.Register(r)
Esto evita archivos de cientos de líneas difíciles de navegar.
Ejemplo completo
package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func listUsers(w http.ResponseWriter, r *http.Request) {
users := []User{
{1, "Ana"},
{2, "Luis"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
func getUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
user := User{
ID: 1,
Name: "Usuario " + id,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Route("/api/v1", func(r chi.Router) {
r.Get("/users", listUsers)
r.Get("/users/{id}", getUser)
})
http.ListenAndServe(":8080", r)
}
Solicitudes disponibles:
GET /api/v1/users
GET /api/v1/users/25
Este tipo de organización sigue siendo fácil de mantener incluso cuando el proyecto supera los cien endpoints.
Flujo de una petición
Cliente
│
▼
HTTP Request
│
▼
Chi Router
│
├──────────────┐
▼ │
Middlewares │
│ │
▼ │
Handler │
│ │
▼ │
Respuesta JSON ◄──┘
El router decide qué controlador ejecutar, los middlewares realizan tareas transversales (autenticación, logging, recuperación de errores, límites de tiempo, etc.) y finalmente el handler genera la respuesta.
Cuándo usar Mount
En aplicaciones grandes es habitual separar módulos completos.
users := chi.NewRouter()
users.Get("/", listUsers)
users.Post("/", createUser)
r.Mount("/users", users)
Esto permite que cada módulo tenga:
- Sus propios middlewares.
- Sus propias rutas.
- Sus propios controladores.
Es una estrategia muy útil cuando varios equipos trabajan sobre la misma API.
Buenas prácticas
- Mantén los handlers pequeños; la lógica de negocio debería vivir en servicios o casos de uso, no en el router.
- Usa
context.Contextpara propagar información como identificadores de solicitud, usuarios autenticados o tiempos límite. - Evita registrar rutas duplicadas o demasiado genéricas que puedan ocultar otras más específicas.
- Aplica los middlewares únicamente donde aporten valor; autenticar rutas públicas incrementa la complejidad sin beneficios.
- Devuelve códigos HTTP coherentes (
200,201,204,400,401,404,500) junto con respuestas JSON consistentes. - No ignores los errores de
json.NewEncoder().Encode()ni dehttp.ListenAndServe(). En entornos reales, registrar esos errores facilita el diagnóstico de problemas. - Organiza las rutas por dominio (
users,orders,products) en lugar de acumularlas en un único archivo. A medida que la API crece, esta separación reduce conflictos y hace más sencilla la incorporación de nuevos desarrolladores.