Versionado de APIs en Go: URL, Headers y Content Negotiation - Estrategias Profesionales
Una guía exhaustiva sobre versionado de APIs: qué es, por qué es crítico, comparación de estrategias (URL, Header, Accept), implementación paso a paso en Go, ventajas/desventajas, y casos reales. Código práctico y verificable.
Una de las decisiones más importantes que harás en tu carrera de backend es cómo versionar tus APIs. No es una pregunta técnica menor. Es una decisión arquitectónica que afecta cómo tu negocio puede evolucionar, cómo tus clientes pueden integrar con tu sistema, y cómo tu equipo puede mantener múltiples versiones en producción.
He visto equipos que eligieron mal, y años después estaban atrapados: no podían cambiar su API sin romper a millones de clientes, pero tampoco podían mantener un código base monolítico de múltiples versiones. Estaban paralizado.
He visto otros equipos que eligieron bien desde el inicio, y pudieron evolucionar su API de forma limpia, mantener múltiples versiones con bajo costo, y crecer sin dolor.
La diferencia no es suerte. Es entender las opciones, sus tradeoffs, y elegir la que funciona para tu situación específica.
Este artículo es una guía exhaustiva y práctica sobre versionado de APIs en Go. Explicaré qué es versionado, por qué importa, compararé las tres estrategias principales (URL, Header, Accept), mostraré cómo implementar cada una, y te daré el conocimiento para elegir sabiamente para tu proyecto.
Parte 1: Fundamentos del Versionado
¿Qué es versionado de API?
Versionado es la práctica de mantener múltiples versiones de tu API en producción simultáneamente, permitiendo que clientes antiguos sigan funcionando mientras nuevos clientes adoptan versiones más recientes.
Parece simple. La realidad es compleja.
Por qué es crítico
Imagina que tienes una API que devuelve datos de usuarios:
// API v1
{
"id": 1,
"name": "John",
"email": "john@example.com"
}
Un día, tu equipo de producto dice: “Necesitamos devolver la fecha de creación también”. Fácil, ¿verdad?
// ¿Vamos a cambiar a esto?
{
"id": 1,
"name": "John",
"email": "john@example.com",
"created_at": "2025-01-15T10:30:00Z"
}
El problema: tienes 10,000 clientes usando tu API. Si cambias el response, algunos de ellos se rompen. No todos esperaban ese nuevo campo. Sus parsers pueden fallar. Sus tests fallan.
Ahora necesitas una forma de:
- Servir el response antiguo a clientes que lo esperan
- Servir el response nuevo a clientes que lo quieren
- Hacer transición de clientes antiguos al nuevo formato
- Mantener ambas versiones en producción
Esto es versionado.
El Costo Real del Versionado
Aquí viene la verdad incómoda: versionado de API cuesta dinero. No es gratis.
- Mantienes más código
- Tests más complejos
- Más bugs potenciales
- Complejidad operacional
El verdadero question es: ¿es el costo de versionado menor que el costo de romper a tus clientes?
Para la mayoría de APIs públicas: sí, definitivamente. El costo de romper a 10,000 clientes es astronómico.
Para APIs internas: depende. Si tienes control de todos los clientes y puedes actualizar el código, quizás no necesites versionado.
Parte 2: Comparación de Estrategias
Hay tres formas principales de versionar una API. Cada una tiene ventajas y desventajas profundas.
Estrategia 1: Versionado en URL
Concepto: La versión está en la ruta:
GET /v1/users/123
GET /v2/users/123
Ventajas:
✅ Clarísimo: Miras la URL, ves la versión inmediatamente
✅ Fácil de cachear: CDNs y proxies entienden naturalmente /v1/ vs /v2/
✅ Fácil de documentar: Cada versión tiene su propia página
✅ Fácil de monitorear: Puedes ver qué versión está siendo usada
✅ Fácil de deprecar: Anuncias “v1 se elimina en 6 meses” y es obvio qué endpoint cambiar
Desventajas:
❌ Duplicación de código: Necesitas routings separados para v1 y v2
❌ Difícil de evolucionar dentro de versión: Si necesitas cambio menor en v1, ¿usas v1.1? Complejidad
❌ Ugly URLs: Algunos puristas dicen que /v1/ es “ugliness”
Cuándo usarla:
- APIs públicas que necesiten soportar muchos clientes
- Cuando el cambio es grande (cambios de estructura, no solo campos)
- Cuando necesites hacer deprecación ordenada y clara
Estrategia 2: Versionado en Header
Concepto: La versión está en un header HTTP:
GET /users/123
Header: API-Version: 1
vs
GET /users/123
Header: API-Version: 2
Ventajas:
✅ URLs limpias: El endpoint es siempre /users/123
✅ Menos duplicación: Rutas compartidas, lógica diferente dentro del handler
✅ Flexible: Puedes cambiar versión sin actualizar URLs en clientes
✅ Estándar HTTP: Los headers son exactamente para esto
Desventajas:
❌ No es evidente: Tienes que leer headers para ver qué versión se está usando
❌ Difícil de cachear: CDNs no entienden bien headers custom (aunque puedes configurar)
❌ Menos visible: En logs, si no miras headers, no sabes qué versión se usó
❌ Testing complicado: Necesitas recordar agregar el header en cada test
Cuándo usarla:
- APIs internas o privadas
- Cuando los cambios son menores
- Cuando necesitas máxima flexibilidad
Estrategia 3: Content Negotiation (Accept Header)
Concepto: Usas el header Accept estándar:
GET /users/123
Accept: application/vnd.company.v1+json
vs
GET /users/123
Accept: application/vnd.company.v2+json
Ventajas:
✅ Estándar REST puro: Esto es lo que sugieren los expertos
✅ URLs limpas: Como Strategy 2
✅ Profesional: Demuestra que entiendes HTTP
✅ Flexible: Puedes versionear por media type, no solo versión
Desventajas:
❌ Complejo de implementar: Parsear content negotiation no es trivial
❌ Difícil de testear: Tienes que entender MIME types
❌ Poco común en web: Muchos desarrolladores no lo entienden
❌ Sobrequinería: Para muchas APIs, es overengineering
Cuándo usarla:
- APIs que necesiten soportar múltiples formatos (JSON, XML, etc)
- Cuando quieres ser puro REST
- Cuando documentación y estándares son críticos
Parte 3: Implementación en Go - Estrategia 1 (URL)
Vamos a implementar versionado en URL. Es la más común y la que verás en la mayoría de APIs.
3.1 Setup Inicial
mkdir api-versioning
cd api-versioning
go mod init github.com/tuusuario/api-versioning
mkdir -p {cmd/api,internal/{domain,usecase,adapter/http}}
3.2 Definir el Dominio
Crea internal/domain/user.go:
package domain
import "time"
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
Role string `json:"role,omitempty"`
}
// GetUserV1 es lo que devolvemos en v1 (sin timestamps ni rol)
type UserV1 struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// GetUserV2 es lo que devolvemos en v2 (con timestamps)
type UserV2 struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GetUserV3 es lo que devolvemos en v3 (con rol también)
type UserV3 struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Role string `json:"role"`
}
// ToV1 convierte User a UserV1
func (u *User) ToV1() UserV1 {
return UserV1{
ID: u.ID,
Name: u.Name,
Email: u.Email,
}
}
// ToV2 convierte User a UserV2
func (u *User) ToV2() UserV2 {
return UserV2{
ID: u.ID,
Name: u.Name,
Email: u.Email,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// ToV3 convierte User a UserV3
func (u *User) ToV3() UserV3 {
return UserV3{
ID: u.ID,
Name: u.Name,
Email: u.Email,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
Role: u.Role,
}
}
¿Qué está pasando?
- Tenemos una entidad interna
Usercon TODOS los campos - Para cada versión, creamos un DTO (Data Transfer Object) específico
- Métodos de conversión transforman la entidad interna al formato que cada versión espera
Esto es una práctica estándar en versionado: la base de datos y la lógica interna usan la “versión más nueva”, pero devolvemos lo que cada cliente espera.
3.3 Crear el Repository
Crea internal/adapter/http/repository.go:
package http
import (
"sync"
"time"
"github.com/tuusuario/api-versioning/internal/domain"
)
type UserRepository struct {
mu sync.RWMutex
users map[int]*domain.User
}
func NewUserRepository() *UserRepository {
repo := &UserRepository{
users: make(map[int]*domain.User),
}
// Seed con datos de ejemplo
repo.users[1] = &domain.User{
ID: 1,
Name: "Alice Johnson",
Email: "alice@example.com",
CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC),
UpdatedAt: time.Date(2025, 1, 10, 14, 20, 0, 0, time.UTC),
Role: "admin",
}
repo.users[2] = &domain.User{
ID: 2,
Name: "Bob Smith",
Email: "bob@example.com",
CreatedAt: time.Date(2024, 3, 20, 9, 15, 0, 0, time.UTC),
UpdatedAt: time.Date(2025, 1, 5, 11, 45, 0, 0, time.UTC),
Role: "user",
}
repo.users[3] = &domain.User{
ID: 3,
Name: "Carol White",
Email: "carol@example.com",
CreatedAt: time.Date(2024, 6, 10, 13, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2025, 1, 8, 16, 30, 0, 0, time.UTC),
Role: "moderator",
}
return repo
}
func (r *UserRepository) GetByID(id int) (*domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, exists := r.users[id]
if !exists {
return nil, ErrUserNotFound
}
return user, nil
}
func (r *UserRepository) GetAll() []*domain.User {
r.mu.RLock()
defer r.mu.RUnlock()
users := make([]*domain.User, 0, len(r.users))
for _, u := range r.users {
users = append(users, u)
}
return users
}
var ErrUserNotFound = error(nil) // Reemplazar con error real
3.4 Handlers con Versionado en URL
Crea internal/adapter/http/handlers.go:
package http
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/tuusuario/api-versioning/internal/domain"
)
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Version string `json:"version"`
}
type Handler struct {
userRepo *UserRepository
}
func NewHandler(userRepo *UserRepository) *Handler {
return &Handler{
userRepo: userRepo,
}
}
// GetUser maneja GET /v{version}/users/{id}
// El router extrae la versión y el ID del path
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request, version string, id int) {
user, err := h.userRepo.GetByID(id)
if err != nil {
h.sendError(w, http.StatusNotFound, "User not found", version)
return
}
// Aquí es donde el versionado importa:
// Devolvemos diferentes respuestas según la versión solicitada
switch version {
case "v1":
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: user.ToV1(),
Version: "v1",
})
case "v2":
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: user.ToV2(),
Version: "v2",
})
case "v3":
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: user.ToV3(),
Version: "v3",
})
default:
h.sendError(w, http.StatusBadRequest, fmt.Sprintf("Version %s not supported", version), version)
}
}
// GetUsers maneja GET /v{version}/users
func (h *Handler) GetUsers(w http.ResponseWriter, r *http.Request, version string) {
users := h.userRepo.GetAll()
switch version {
case "v1":
v1Users := make([]domain.UserV1, len(users))
for i, u := range users {
v1Users[i] = u.ToV1()
}
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: v1Users,
Version: "v1",
})
case "v2":
v2Users := make([]domain.UserV2, len(users))
for i, u := range users {
v2Users[i] = u.ToV2()
}
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: v2Users,
Version: "v2",
})
case "v3":
v3Users := make([]domain.UserV3, len(users))
for i, u := range users {
v3Users[i] = u.ToV3()
}
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: v3Users,
Version: "v3",
})
default:
h.sendError(w, http.StatusBadRequest, fmt.Sprintf("Version %s not supported", version), version)
}
}
// Helpers
func (h *Handler) sendJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func (h *Handler) sendError(w http.ResponseWriter, status int, message, version string) {
h.sendJSON(w, status, APIResponse{
Success: false,
Error: message,
Version: version,
})
}
3.5 Router Personalizado para Versiones
Crea internal/adapter/http/router.go:
package http
import (
"net/http"
"strconv"
"strings"
)
type Router struct {
handler *Handler
mux *http.ServeMux
}
func NewRouter(handler *Handler) *Router {
r := &Router{
handler: handler,
mux: http.NewServeMux(),
}
// Registra rutas con versiones
r.mux.HandleFunc("GET /v{version}/users/{id}", r.handleGetUser)
r.mux.HandleFunc("GET /v{version}/users", r.handleGetUsers)
return r
}
// handleGetUser extrae versión e ID del path y llama al handler
func (r *Router) handleGetUser(w http.ResponseWriter, req *http.Request) {
// Extrae versión del path: /v1/users/123 → "v1"
version := req.PathValue("version")
if version == "" {
http.Error(w, "Version not provided", http.StatusBadRequest)
return
}
// Extrae ID del path
idStr := req.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
r.handler.GetUser(w, req, "v"+version, id)
}
// handleGetUsers extrae versión del path
func (r *Router) handleGetUsers(w http.ResponseWriter, req *http.Request) {
version := req.PathValue("version")
if version == "" {
http.Error(w, "Version not provided", http.StatusBadRequest)
return
}
r.handler.GetUsers(w, req, "v"+version)
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mux.ServeHTTP(w, req)
}
3.6 Main: Ponerlo Todo Junto
Crea cmd/api/main.go:
package main
import (
"fmt"
"log"
"net/http"
"github.com/tuusuario/api-versioning/internal/adapter/http"
)
func main() {
// Inicializa el repositorio con datos de ejemplo
userRepo := http.NewUserRepository()
// Crea el handler
handler := http.NewHandler(userRepo)
// Crea el router con versionado
router := http.NewRouter(handler)
// Inicia el servidor
port := ":8080"
fmt.Printf("🚀 API versioning iniciada en http://localhost%s\n", port)
fmt.Println("\nEjemplos de rutas:")
fmt.Println(" v1: curl http://localhost:8080/v1/users/1")
fmt.Println(" v2: curl http://localhost:8080/v2/users/1")
fmt.Println(" v3: curl http://localhost:8080/v3/users/1")
fmt.Println(" list v1: curl http://localhost:8080/v1/users")
fmt.Println(" list v2: curl http://localhost:8080/v2/users")
fmt.Println()
if err := http.ListenAndServe(port, router); err != nil {
log.Fatal(err)
}
}
3.7 Prueba el Versionado
go run ./cmd/api
En otra terminal:
# v1: Sin timestamps ni rol
curl http://localhost:8080/v1/users/1 | jq .
# Deberías ver:
# {
# "success": true,
# "data": {
# "id": 1,
# "name": "Alice Johnson",
# "email": "alice@example.com"
# },
# "version": "v1"
# }
# v2: Con timestamps
curl http://localhost:8080/v2/users/1 | jq .
# Deberías ver:
# {
# "success": true,
# "data": {
# "id": 1,
# "name": "Alice Johnson",
# "email": "alice@example.com",
# "created_at": "2024-01-15T10:30:00Z",
# "updated_at": "2025-01-10T14:20:00Z"
# },
# "version": "v2"
# }
# v3: Con timestamps Y rol
curl http://localhost:8080/v3/users/1 | jq .
# Deberías ver:
# {
# "success": true,
# "data": {
# "id": 1,
# "name": "Alice Johnson",
# "email": "alice@example.com",
# "created_at": "2024-01-15T10:30:00Z",
# "updated_at": "2025-01-10T14:20:00Z",
# "role": "admin"
# },
# "version": "v3"
# }
✅ El versionado en URL funciona.
Parte 4: Implementación - Estrategia 2 (Header)
Ahora vamos a mostrar cómo implementar el mismo sistema usando headers en lugar de URLs.
4.1 Middleware para Extracción de Versión
Crea internal/adapter/http/version_middleware.go:
package http
import (
"context"
"net/http"
)
// contextKeyVersion es una clave para almacenar versión en el contexto
type contextKey string
const contextKeyVersion contextKey = "api-version"
// VersionMiddleware extrae la versión del header API-Version
// y la almacena en el contexto de la request
func VersionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Lee el header API-Version
version := r.Header.Get("API-Version")
if version == "" {
// Default a v1 si no se especifica
version = "v1"
}
// Valida que sea una versión soportada
switch version {
case "v1", "v2", "v3":
// OK
default:
http.Error(w, "Unsupported API version: "+version, http.StatusBadRequest)
return
}
// Almacena la versión en el contexto
ctx := context.WithValue(r.Context(), contextKeyVersion, version)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetVersionFromContext extrae la versión del contexto
func GetVersionFromContext(r *http.Request) string {
version, ok := r.Context().Value(contextKeyVersion).(string)
if !ok {
return "v1"
}
return version
}
4.2 Handlers Actualizados
Crea internal/adapter/http/handlers_header.go:
package http
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/tuusuario/api-versioning/internal/domain"
)
type HandlerHeader struct {
userRepo *UserRepository
}
func NewHandlerHeader(userRepo *UserRepository) *HandlerHeader {
return &HandlerHeader{
userRepo: userRepo,
}
}
// GetUser maneja GET /users/{id}
// La versión viene del header API-Version
func (h *HandlerHeader) GetUser(w http.ResponseWriter, r *http.Request) {
// Extrae versión del contexto (puesto ahí por el middleware)
version := GetVersionFromContext(r)
// Extrae ID del path
idStr := r.PathValue("id")
if idStr == "" {
http.Error(w, "ID not provided", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
user, err := h.userRepo.GetByID(id)
if err != nil {
h.sendError(w, http.StatusNotFound, "User not found", version)
return
}
// Devuelve según la versión
switch version {
case "v1":
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: user.ToV1(),
Version: "v1",
})
case "v2":
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: user.ToV2(),
Version: "v2",
})
case "v3":
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: user.ToV3(),
Version: "v3",
})
}
}
// GetUsers maneja GET /users
func (h *HandlerHeader) GetUsers(w http.ResponseWriter, r *http.Request) {
version := GetVersionFromContext(r)
users := h.userRepo.GetAll()
switch version {
case "v1":
v1Users := make([]domain.UserV1, len(users))
for i, u := range users {
v1Users[i] = u.ToV1()
}
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: v1Users,
Version: "v1",
})
case "v2":
v2Users := make([]domain.UserV2, len(users))
for i, u := range users {
v2Users[i] = u.ToV2()
}
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: v2Users,
Version: "v2",
})
case "v3":
v3Users := make([]domain.UserV3, len(users))
for i, u := range users {
v3Users[i] = u.ToV3()
}
h.sendJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: v3Users,
Version: "v3",
})
}
}
func (h *HandlerHeader) sendJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func (h *HandlerHeader) sendError(w http.ResponseWriter, status int, message, version string) {
h.sendJSON(w, status, APIResponse{
Success: false,
Error: message,
Version: version,
})
}
4.3 Router con Middleware
Crea internal/adapter/http/router_header.go:
package http
import "net/http"
type RouterHeader struct {
handler *HandlerHeader
mux *http.ServeMux
}
func NewRouterHeader(handler *HandlerHeader) *RouterHeader {
r := &RouterHeader{
handler: handler,
mux: http.NewServeMux(),
}
// Rutas simples sin versión en la URL
r.mux.HandleFunc("GET /users/{id}", r.handler.GetUser)
r.mux.HandleFunc("GET /users", r.handler.GetUsers)
return r
}
func (r *RouterHeader) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Aplica el middleware de versión a TODAS las requests
middleware := VersionMiddleware(r.mux)
middleware.ServeHTTP(w, req)
}
4.4 Main Alternativo
Para probar con headers, crea cmd/api-header/main.go:
package main
import (
"fmt"
"log"
"net/http"
"github.com/tuusuario/api-versioning/internal/adapter/http"
)
func main() {
userRepo := http.NewUserRepository()
handler := http.NewHandlerHeader(userRepo)
router := http.NewRouterHeader(handler)
port := ":8081"
fmt.Printf("🚀 API con Header Versioning en http://localhost%s\n", port)
fmt.Println("\nEjemplos:")
fmt.Println(" v1: curl http://localhost:8081/users/1 -H 'API-Version: v1'")
fmt.Println(" v2: curl http://localhost:8081/users/1 -H 'API-Version: v2'")
fmt.Println(" v3: curl http://localhost:8081/users/1 -H 'API-Version: v3'")
fmt.Println(" default (v1): curl http://localhost:8081/users/1")
fmt.Println()
if err := http.ListenAndServe(port, router); err != nil {
log.Fatal(err)
}
}
4.5 Prueba el Header Versioning
go run ./cmd/api-header/main.go
En otra terminal:
# Sin header (default a v1)
curl http://localhost:8081/users/1 | jq .
# v1 explícito
curl http://localhost:8081/users/1 -H "API-Version: v1" | jq .
# v2
curl http://localhost:8081/users/1 -H "API-Version: v2" | jq .
# v3
curl http://localhost:8081/users/1 -H "API-Version: v3" | jq .
# Versión no soportada
curl http://localhost:8081/users/1 -H "API-Version: v99"
Parte 5: Implementación - Estrategia 3 (Accept Header / Content Negotiation)
Finalmente, mostremos la forma “pura” de REST usando Content Negotiation.
5.1 Middleware de Content Negotiation
Crea internal/adapter/http/content_negotiation.go:
package http
import (
"context"
"net/http"
"strings"
)
const contextKeyAPIVersion contextKey = "api-version"
// ContentNegotiationMiddleware parsea el header Accept
// para extraer la versión de la API
//
// Soporta formatos como:
// - application/vnd.myapi.v1+json
// - application/vnd.myapi.v2+json
// - application/json (default a v1)
func ContentNegotiationMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
version := parseAPIVersion(r.Header.Get("Accept"))
// Valida versión
switch version {
case "v1", "v2", "v3":
// OK
default:
http.Error(w, "Unsupported API version: "+version, http.StatusNotAcceptable)
return
}
ctx := context.WithValue(r.Context(), contextKeyAPIVersion, version)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// parseAPIVersion extrae la versión del header Accept
func parseAPIVersion(acceptHeader string) string {
if acceptHeader == "" {
return "v1"
}
// Parsea el header Accept (puede tener múltiples valores)
parts := strings.Split(acceptHeader, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
// Busca el patrón vnd.myapi.v{numero}+json
if strings.Contains(part, "vnd.myapi") {
// Extrae la versión
if strings.Contains(part, "v1") {
return "v1"
}
if strings.Contains(part, "v2") {
return "v2"
}
if strings.Contains(part, "v3") {
return "v3"
}
}
// Si solo es application/json, default a v1
if strings.HasPrefix(part, "application/json") {
return "v1"
}
}
return "v1"
}
// GetAPIVersionFromContext extrae la versión del contexto
func GetAPIVersionFromContext(r *http.Request) string {
version, ok := r.Context().Value(contextKeyAPIVersion).(string)
if !ok {
return "v1"
}
return version
}
5.2 Handlers para Content Negotiation
Crea internal/adapter/http/handlers_content_neg.go:
package http
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/tuusuario/api-versioning/internal/domain"
)
type HandlerContentNeg struct {
userRepo *UserRepository
}
func NewHandlerContentNeg(userRepo *UserRepository) *HandlerContentNeg {
return &HandlerContentNeg{
userRepo: userRepo,
}
}
// GetUser maneja GET /users/{id}
// La versión viene del header Accept
func (h *HandlerContentNeg) GetUser(w http.ResponseWriter, r *http.Request) {
version := GetAPIVersionFromContext(r)
idStr := r.PathValue("id")
if idStr == "" {
h.sendError(w, http.StatusBadRequest, "ID not provided", version)
return
}
id, err := strconv.Atoi(idStr)
if err != nil {
h.sendError(w, http.StatusBadRequest, "Invalid user ID", version)
return
}
user, err := h.userRepo.GetByID(id)
if err != nil {
h.sendError(w, http.StatusNotFound, "User not found", version)
return
}
// Aquí usamos el header Accept para determinar qué devolver
switch version {
case "v1":
h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
Success: true,
Data: user.ToV1(),
Version: "v1",
}, "application/vnd.myapi.v1+json")
case "v2":
h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
Success: true,
Data: user.ToV2(),
Version: "v2",
}, "application/vnd.myapi.v2+json")
case "v3":
h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
Success: true,
Data: user.ToV3(),
Version: "v3",
}, "application/vnd.myapi.v3+json")
}
}
// GetUsers maneja GET /users
func (h *HandlerContentNeg) GetUsers(w http.ResponseWriter, r *http.Request) {
version := GetAPIVersionFromContext(r)
users := h.userRepo.GetAll()
switch version {
case "v1":
v1Users := make([]domain.UserV1, len(users))
for i, u := range users {
v1Users[i] = u.ToV1()
}
h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
Success: true,
Data: v1Users,
Version: "v1",
}, "application/vnd.myapi.v1+json")
case "v2":
v2Users := make([]domain.UserV2, len(users))
for i, u := range users {
v2Users[i] = u.ToV2()
}
h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
Success: true,
Data: v2Users,
Version: "v2",
}, "application/vnd.myapi.v2+json")
case "v3":
v3Users := make([]domain.UserV3, len(users))
for i, u := range users {
v3Users[i] = u.ToV3()
}
h.sendJSONWithContentType(w, http.StatusOK, APIResponse{
Success: true,
Data: v3Users,
Version: "v3",
}, "application/vnd.myapi.v3+json")
}
}
func (h *HandlerContentNeg) sendJSONWithContentType(w http.ResponseWriter, status int, data interface{}, contentType string) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func (h *HandlerContentNeg) sendError(w http.ResponseWriter, status int, message, version string) {
contentType := fmt.Sprintf("application/vnd.myapi.%s+json", version)
h.sendJSONWithContentType(w, status, APIResponse{
Success: false,
Error: message,
Version: version,
}, contentType)
}
5.3 Router y Main
Crea cmd/api-content-neg/main.go:
package main
import (
"fmt"
"log"
"net/http"
"github.com/tuusuario/api-versioning/internal/adapter/http"
)
func main() {
userRepo := http.NewUserRepository()
handler := http.NewHandlerContentNeg(userRepo)
// Router simple
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", handler.GetUser)
mux.HandleFunc("GET /users", handler.GetUsers)
// Envuelve con content negotiation middleware
server := http.ContentNegotiationMiddleware(mux)
port := ":8082"
fmt.Printf("🚀 API con Content Negotiation en http://localhost%s\n", port)
fmt.Println("\nEjemplos:")
fmt.Println(" v1: curl http://localhost:8082/users/1 -H 'Accept: application/vnd.myapi.v1+json'")
fmt.Println(" v2: curl http://localhost:8082/users/1 -H 'Accept: application/vnd.myapi.v2+json'")
fmt.Println(" v3: curl http://localhost:8082/users/1 -H 'Accept: application/vnd.myapi.v3+json'")
fmt.Println(" default: curl http://localhost:8082/users/1 -H 'Accept: application/json'")
fmt.Println()
if err := http.ListenAndServe(port, server); err != nil {
log.Fatal(err)
}
}
5.4 Prueba
go run ./cmd/api-content-neg/main.go
En otra terminal:
# v1 con content negotiation
curl http://localhost:8082/users/1 \
-H "Accept: application/vnd.myapi.v1+json" | jq .
# v2
curl http://localhost:8082/users/1 \
-H "Accept: application/vnd.myapi.v2+json" | jq .
# v3
curl http://localhost:8082/users/1 \
-H "Accept: application/vnd.myapi.v3+json" | jq .
# Default (v1) con application/json
curl http://localhost:8082/users/1 \
-H "Accept: application/json" | jq .
Parte 6: Comparación en Acción
Ahora que hemos implementado las tres estrategias, comparemos:
6.1 Claridad de Versión
URL Versioning:
curl http://localhost:8080/v2/users/1
# Obvio: estoy usando v2
Header Versioning:
curl http://localhost:8081/users/1 -H "API-Version: v2"
# Tengo que leer el header para saber que es v2
Content Negotiation:
curl http://localhost:8082/users/1 -H "Accept: application/vnd.myapi.v2+json"
# Tengo que entender MIME types
Ganador: URL Versioning - Es inmediatamente evidente.
6.2 Caching y CDN
URL Versioning:
GET /v1/users/1 → CDN cachea como /v1/users/1
GET /v2/users/1 → CDN cachea como /v2/users/1
CDNs entienden automáticamente que son recursos diferentes.
Header Versioning:
GET /users/1 + Header: API-Version: v1
GET /users/1 + Header: API-Version: v2
El mismo URL con diferentes headers. CDNs no entienden esto por defecto (necesitas configurar Vary headers).
Content Negotiation:
GET /users/1 + Accept: application/vnd.myapi.v1+json
GET /users/1 + Accept: application/vnd.myapi.v2+json
Similar problema al header versioning.
Ganador: URL Versioning - Caching automático.
6.3 Flexibilidad Dentro de Versión
URL Versioning:
Si necesitas un cambio menor en v2 (digamos, agregar un campo optional), ¿qué haces?
- Opción A: Cambias v2 (rompes clientes)
- Opción B: Creas v2.1 (complejidad)
- Opción C: Tienes que pensar bien antes de lanzar v2
Header Versioning:
API-Version: v2.1
API-Version: v2.1.2
Puedes tener subversiones. Flexible.
Content Negotiation:
Similar a header versioning.
Ganador: Header/Content Negotiation - Más flexible para cambios menores.
6.4 Estándares REST
Si sigues estrictamente la especificación REST, el que mejor se adhiere es Content Negotiation, porque usa mechanisms estándar de HTTP (Accept headers y MIME types).
Pero la realidad: la mayoría de la industria usa URL versioning. Es pragmático.
Parte 7: Mejores Prácticas de Versionado
7.1 Deprecation Strategy
Cuando lanzas una versión, necesitas un plan para deprecar la antigua:
Año 1:
- v1 en producción y soportada
- v2 lanzada
Año 1.5:
- v1 deprecated (anunciado)
- v2 en producción y soportada
Año 2:
- v1 removida
- v2 en producción y soportada
- v3 lanzada
Política sugerida:
- Soporta versión anterior + actual
- Anuncia deprecation 6 meses antes de remover
- Proporciona herramientas de migración
- Registra qué cliente usa qué versión (para contactarlos)
7.2 Versionado Semántico
Si usas URL versioning con números: /v1/, /v2/, considera usar semántico:
- v1.0 → cambio incompatible
- v1.1 → cambio compatible (nuevo campo)
- v1.1.2 → bug fix
Go usa esto con módulos (github.com/user/lib v1.2.3).
7.3 Testing Multi-Versión
Tus tests deben cubrir TODAS las versiones soportadas:
func TestGetUserAllVersions(t *testing.T) {
tests := []struct {
version string
expected interface{}
}{
{"v1", UserV1{...}},
{"v2", UserV2{...}},
{"v3", UserV3{...}},
}
for _, tt := range tests {
t.Run(tt.version, func(t *testing.T) {
// Test para esa versión
})
}
}
7.4 Logging de Versión
Siempre registra qué versión se usó:
log.Printf("Request to %s version=%s user_id=%d", r.URL.Path, version, userID)
Así puedes:
- Ver qué versión está siendo usada
- Saber cuándo deprecar (cuando v1 se usa casi nunca)
- Debugging de issues específicos de versión
7.5 Documentación Clara
Para CADA versión, documenta:
## API v1 (DEPRECATED)
Use v2 instead. v1 will be removed on 2026-01-01.
## API v2 (CURRENT)
Current recommended version. Full support.
### Endpoints
- GET /v2/users/{id}
- POST /v2/users
- ...
### Response Format
{
"id": number,
"name": string,
"email": string
}
## API v3 (BETA)
New version. Not recommended for production yet.
Conclusión: Cuál Elegir
| Estrategia | Cuándo Usarla | Ejemplo |
|---|---|---|
| URL | APIs públicas, cambios grandes, caching importante | Stripe, GitHub, Twitter |
| Header | APIs internas, cambios frecuentes | Algunas APIs empresariales |
| Content Negotiation | APIs complejas con múltiples formatos | Algunos services de Google |
Mi recomendación personal: Para el 90% de APIs, URL versioning es la mejor opción. Es clara, cacheable, fácil de entender, y es lo que usa la industria.
Úsalo. Tus usuarios (y tu equipo futuro) lo agradecerán.
Tags
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.
La Capa de Repositorio en Go: Conexiones, Queries y Arquitectura Agnóstica
Una guía exhaustiva sobre cómo construir la capa de repositorio en Go: arquitectura hexagonal con ports, conexiones a MongoDB, PostgreSQL, SQLite, manejo de queries, validación, escalabilidad y cómo cambiar de base de datos sin tocar la lógica de negocio.