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.
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
La Realidad de Mantener APIs en Producción
🎯 Introducción: El Problema Real
Existe un momento en la vida de toda API que marca un punto de no retorno: el primer cambio que rompe compatibilidad hacia atrás.
Ese momento puede verse así:
Escenario 1: La sorpresa desagradable
Tu equipo: "Cambiamos el campo 'email' a 'user_email' en el response."
Clientes 1, 2, 3... N: "¿¿¿QUÉ????"
Logs de error: 💥 Exception: KeyError 'email'
Escenario 2: La muerte silenciosa
Tu API cambia el tipo de un campo de string a integer.
Algunos clientes no se dan cuenta hasta 3 meses después.
Para entonces, 50,000 usuarios tienen datos corruptos.
Escenario 3: El caos de la coexistencia
Tu API soporta v1, v2, v3, v4 y v5 simultáneamente.
Cada versión tiene su propia BD, su propia lógica de negocio.
Tu servidor es un museo arqueológico de decisiones pasadas.
¿Reconoces alguno de estos? Casi todos los equipos los viven.
Esta guía existe porque versionado de APIs no es un tema técnico, es un problema arquitectónico y organizacional.
¿Qué Aprenderás Aquí?
✅ URL Versioning vs Header Versioning - No es preferencia, es trade-off arquitectónico ✅ Cómo deprecar endpoints - El arte de la comunicación técnica ✅ Migration patterns - Cómo mover clientes de una versión a otra ✅ Backwards compatibility - Diseño que aguanta cambios ✅ Code examples reales - Go puro, sin frameworks mágicos ✅ Errores que rompen equipos - Anti-patrones documentados
🏗️ Parte 1: El Panorama Completo
Los Tres Enfoques Principales
Existen básicamente tres formas de versionar APIs:
1. URL Versioning (Más común)
GET /api/v1/users/123
GET /api/v2/users/123
Pros:
- Claro visualmente
- Fácil de debuggear (ves la versión en la URL)
- Cacheables por proxies/CDNs normalmente
Contras:
- Duplicación de código
- URLs más largas
- Proliferación de versiones en tu codebase
2. Header Versioning (Elegante)
GET /api/users/123
Accept: application/vnd.api+json;version=2
Pros:
- URLs limpias
- RESTful (según algunos)
- Menos duplicación de código potencial
Contras:
- Difícil de debuggear en browser
- Harder para CDNs
- Requiere disciplina en headers
3. Query Parameter Versioning (Raramente usado)
GET /api/users/123?v=2
Pros:
- Funciona en cualquier lado
Contras:
- No-RESTful
- Fácil de olvidar
- Los proxies lo ignoran
🔴 Parte 2: URL Versioning - La Realidad
URL versioning es la más común porque es la más simple (aunque no la mejor).
Estructura Básica
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
// Versión 1: Legacy
mux.HandleFunc("GET /api/v1/users", listUsersV1)
mux.HandleFunc("GET /api/v1/users/{id}", getUserV1)
mux.HandleFunc("POST /api/v1/users", createUserV1)
// Versión 2: Nueva
mux.HandleFunc("GET /api/v2/users", listUsersV2)
mux.HandleFunc("GET /api/v2/users/{id}", getUserV2)
mux.HandleFunc("POST /api/v2/users", createUserV2)
http.ListenAndServe(":8080", mux)
}
🎓 Explicación:
- Cada versión tiene su propio handler
- URLs completamente separadas
- Fácil de rutear, pero multiplica código
El Problema: Code Duplication
Esto es lo que generalmente ocurre:
// ❌ ANTI-PATTERN: Duplicación completa
func getUserV1(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
user, err := fetchUserFromDB(userID)
if err != nil {
// JSON response V1
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error": "not found"}`)
return
}
// Response V1 structure
response := map[string]interface{}{
"id": user.ID,
"email": user.Email,
"name": user.Name,
}
json.NewEncoder(w).Encode(response)
}
func getUserV2(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
user, err := fetchUserFromDB(userID)
if err != nil {
// JSON response V2 (estructura diferente)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"success": false, "error": {"type": "NOT_FOUND", "message": "user not found"}}`)
return
}
// Response V2 structure (completamente diferente)
response := map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"full_name": user.Name, // ← Nombre diferente!
},
}
json.NewEncoder(w).Encode(response)
}
💔 El problema es evidente: Duplicación de lógica, mantenimiento pesadilla.
La Solución: Adapter Pattern
package main
import (
"encoding/json"
"net/http"
)
// ✅ PATRÓN: Lógica compartida, adapters separados
// Lógica de negocio: Agnóstica de versión
type UserService struct{}
func (s *UserService) GetUser(id string) (*User, error) {
// Lógica compartida, una sola vez
return fetchUserFromDB(id)
}
// Usuario interno (una sola estructura de verdad)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// Response V1 - Adaptador para versión 1
type UserResponseV1 struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// Response V2 - Adaptador para versión 2
type UserResponseV2 struct {
Success bool `json:"success"`
Data struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
} `json:"data"`
}
// Handler V1 - Solo convierte/adapta
func getUserV1(service *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
user, err := service.GetUser(userID)
if err != nil {
http.Error(w, `{"error": "not found"}`, http.StatusNotFound)
return
}
// Adaptamos a V1
response := UserResponseV1{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// Handler V2 - Solo convierte/adapta
func getUserV2(service *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
user, err := service.GetUser(userID)
if err != nil {
http.Error(w, `{"success": false, "error": {"type": "NOT_FOUND"}}`, http.StatusNotFound)
return
}
// Adaptamos a V2
response := UserResponseV2{
Success: true,
}
response.Data.ID = user.ID
response.Data.Email = user.Email
response.Data.FullName = user.Name
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
🎓 Explicación:
UserServicees la lógica compartida (escrita una sola vez)- Handlers V1 y V2 solo transforman datos
- Cambios en lógica se hacen en un lugar
- Cada versión es una “vista” diferente de los mismos datos
Router Limpio con Middleware
package main
import (
"net/http"
)
// Middleware que identifica versión
func versionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extraer versión de URL
version := extractVersionFromPath(r.URL.Path)
// Pasar al contexto
r.Header.Set("X-API-Version", version)
next.ServeHTTP(w, r)
})
}
func main() {
service := &UserService{}
mux := http.NewServeMux()
// Rutas V1
v1Mux := http.NewServeMux()
v1Mux.HandleFunc("GET /users/{id}", getUserV1(service))
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", v1Mux))
// Rutas V2
v2Mux := http.NewServeMux()
v2Mux.HandleFunc("GET /users/{id}", getUserV2(service))
mux.Handle("/api/v2/", http.StripPrefix("/api/v2", v2Mux))
http.ListenAndServe(":8080", mux)
}
func extractVersionFromPath(path string) string {
// Simple extraction
if len(path) > 7 && path[:7] == "/api/v" {
return string(path[7])
}
return "1"
}
💡 Tip: El http.StripPrefix() es útil para no repetir /api/v1 en cada ruta.
🔵 Parte 3: Header Versioning - La Elegancia
Header versioning es más elegante pero requiere más disciplina.
Estructura Básica
package main
import (
"net/http"
"strconv"
)
// Middleware que extrae versión del header
func parseAPIVersion(r *http.Request) int {
// Accept: application/vnd.myapi+json;version=2
acceptHeader := r.Header.Get("Accept")
// O usar un header custom: X-API-Version: 2
if versionStr := r.Header.Get("X-API-Version"); versionStr != "" {
version, _ := strconv.Atoi(versionStr)
return version
}
// Default a versión 1
return 1
}
// Handler único que maneja múltiples versiones
func getUser(service *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
version := parseAPIVersion(r)
userID := r.PathValue("id")
user, err := service.GetUser(userID)
if err != nil {
http.Error(w, `{"error": "not found"}`, http.StatusNotFound)
return
}
// Adaptamos el response según versión
switch version {
case 2:
// Response V2
response := map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"full_name": user.Name,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
default: // Versión 1
// Response V1
response := map[string]interface{}{
"id": user.ID,
"email": user.Email,
"name": user.Name,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
}
func main() {
service := &UserService{}
mux := http.NewServeMux()
// Una sola ruta, versión en header
mux.HandleFunc("GET /api/users/{id}", getUser(service))
http.ListenAndServe(":8080", mux)
}
🎓 Explicación:
- Headers van en
AcceptoX-API-Version - Un solo handler, logica condicional
- URLs limpias (
/api/users, no/api/v2/users)
Ventaja: Mejor Mantenibilidad
// ✅ Comparador de esquemas
func adaptUserResponse(user *User, version int) interface{} {
type UserV1 struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
type UserV2 struct {
Success bool `json:"success"`
Data struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
} `json:"data"`
}
type UserV3 struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
switch version {
case 3:
return UserV3{
ID: user.ID,
Email: user.Email,
FullName: user.Name,
CreatedAt: user.CreatedAt.String(),
UpdatedAt: user.UpdatedAt.String(),
}
case 2:
resp := UserV2{Success: true}
resp.Data.ID = user.ID
resp.Data.Email = user.Email
resp.Data.FullName = user.Name
return resp
default:
return UserV1{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}
}
}
Desventaja: Testing + Debugging
// ❌ Más complicado de testear
func TestGetUserV2(t *testing.T) {
// Necesitas configurar el header correctamente
req, _ := http.NewRequest("GET", "/api/users/123", nil)
req.Header.Set("X-API-Version", "2")
// vs con URL versioning:
// req, _ := http.NewRequest("GET", "/api/v2/users/123", nil)
// Mucho más obvio
}
🟡 Parte 4: Comparativa Directa
| Aspecto | URL Versioning | Header Versioning |
|---|---|---|
| Claridad URL | ✅ Evidente | ❌ No visible |
| RESTful | ❌ Algunos dicen no | ✅ Más puro |
| Cacheable por CDN | ✅ Generalmente sí | ❌ Requiere config |
| Debuggeable en browser | ✅ Obvio | ❌ Necesitas DevTools |
| Duplicación código | ❌ Alta riesgo | ✅ Menor |
| Routing simple | ✅ Muy simple | ✅ Simple |
| Testing | ✅ Fácil | ❌ Más pasos |
| Compatibilidad proxies | ✅ Mejor | ⚠️ Depende config |
💡 Mi Recomendación:
- APIs públicas/third-party: URL versioning (transparencia)
- APIs internas/microservicios: Header versioning (flexibilidad)
- APIs móviles: Ambos (mobile tiende a ser compleja)
🟣 Parte 5: Deprecación - El Arte de La Comunicación
Deprecar un endpoint es más que sacar código. Es una orquestación de comunicación.
Fase 1: Anuncio (3 meses antes)
// Middleware que marca endpoints como deprecated
func deprecatedEndpoint(deprecationDate string, alternatives string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Agregar headers informativos
w.Header().Set("Deprecation", "true")
w.Header().Set("Sunset", deprecationDate)
w.Header().Set("Link", fmt.Sprintf(`<%s>; rel="successor-version"`, alternatives))
w.Header().Set("X-Deprecated-Since", "2025-01-08")
// Loguear deprecación
log.Printf("DEPRECATED: %s %s called", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
}
func main() {
mux := http.NewServeMux()
// Endpoint deprecated
oldHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"data": "old"}`)
})
deprecatedHandler := deprecatedEndpoint(
"Sun, 01 Apr 2026 00:00:00 GMT",
"https://api.example.com/api/v2/users",
)(oldHandler)
mux.Handle("GET /api/v1/users", deprecatedHandler)
http.ListenAndServe(":8080", mux)
}
🎓 Explicación de Headers:
Deprecation: true- “Este endpoint se va”Sunset- “Fecha exacta de muerte”Link- “URL alternativa”- Logs - Para monitorear uso
Fase 2: Monitoreo de Uso
package main
import (
"sync"
"time"
)
// Rastreador de endpoints deprecated
type DeprecationTracker struct {
mu sync.RWMutex
clients map[string]*ClientUsage
}
type ClientUsage struct {
IP string
UserAgent string
FirstCall time.Time
LastCall time.Time
CallCount int
LastVersion string
}
func (dt *DeprecationTracker) TrackDeprecatedCall(r *http.Request, endpoint string) {
dt.mu.Lock()
defer dt.mu.Unlock()
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
usage, exists := dt.clients[clientIP]
if !exists {
usage = &ClientUsage{
IP: clientIP,
UserAgent: r.Header.Get("User-Agent"),
FirstCall: time.Now(),
}
dt.clients[clientIP] = usage
}
usage.LastCall = time.Now()
usage.CallCount++
usage.LastVersion = endpoint
}
// Report de qué clientes siguen usando endpoints deprecated
func (dt *DeprecationTracker) GenerateReport() {
dt.mu.RLock()
defer dt.mu.RUnlock()
for ip, usage := range dt.clients {
if usage.CallCount > 10 {
println("⚠️ Cliente pesado en deprecated:", ip)
println(" User-Agent:", usage.UserAgent)
println(" Llamadas:", usage.CallCount)
println(" Última llamada:", usage.LastCall)
}
}
}
💡 Tip: Este tracking te permite identificar a qué clientes contactar directamente.
Fase 3: Comunicación Activa
// Email template automático
const deprecationEmailTemplate = `
Hola,
Hemos detectado que tu aplicación sigue usando nuestro endpoint v1:
GET /api/v1/users
Este endpoint **será descontinuado el 1 de Abril de 2026**.
Por favor, actualiza a:
GET /api/v2/users
Los cambios principales son:
- Estructura de response mejorada
- Mejor manejo de errores
- Performance mejorada (2x más rápido)
Tienes 3 meses para migrar. Aquí está la guía:
https://docs.api.example.com/migration-v1-to-v2
Si tienes problemas, responde a este email.
Saludos,
API Team
`
Fase 4: Reminders Progresivos
// 3 meses antes: Email 1
// 1 mes antes: Email 2 + SMS
// 2 semanas antes: Email 3 + Dashboard warning
// 1 semana antes: Email 4 + Dashboard error
// Día de sunsetting: 503 Service Unavailable
type SunsetSchedule struct {
DeprecationDate time.Time
SunsetDate time.Time
}
func (s *SunsetSchedule) GetCurrentPhase() string {
now := time.Now()
daysRemaining := int(s.SunsetDate.Sub(now).Hours() / 24)
switch {
case daysRemaining > 90:
return "announcement"
case daysRemaining > 30:
return "first_reminder"
case daysRemaining > 14:
return "second_reminder"
case daysRemaining > 7:
return "final_warning"
case daysRemaining >= 0:
return "last_week"
default:
return "sunset"
}
}
🎓 Explicación:
- Anuncio temprano - Sin sorpresas
- Tracking - Saber quién se ve afectado
- Reminders progresivos - Más presión conforme se acerca
- Communication clara - Qué cambio, cuándo, por qué
🟢 Parte 6: Migration Patterns - Cómo Mover Clientes
Patrón 1: Dual Endpoints (Lo más seguro)
// Ambas versiones funcionan simultáneamente
// Pero V1 hablamos en backend a V2
func getUserV1(service *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
// Internamente, siempre usamos V2
user, err := service.GetUserV2(userID)
if err != nil {
handleError(w, err)
return
}
// Adaptamos el response a V1 por compatibilidad
responseV1 := adaptToV1(user)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(responseV1)
}
}
func getUserV2(service *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
user, err := service.GetUserV2(userID)
if err != nil {
handleError(w, err)
return
}
responseV2 := adaptToV2(user)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(responseV2)
}
}
Ventajas:
- ✅ Cero riesgo para clientes
- ✅ Puedes medir migración
- ✅ Rollback fácil
Desventajas:
- ❌ Mantenimiento 2 endpoints
- ❌ Bugs pueden ocurrir en ambas
Patrón 2: Feature Flags (Lo más flexible)
package main
// Feature flags para controlar comportamiento
type APIConfig struct {
EnableV2NewResponse bool
V2ResponseRate float64 // 0.0 a 1.0
V1DeprecationDate time.Time
}
func getUser(service *UserService, config *APIConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
user, err := service.GetUser(userID)
if err != nil {
handleError(w, err)
return
}
// Migración gradual: 10% clientes en V2, luego 50%, luego 100%
shouldUseV2 := shouldUseNewResponse(config, r)
if shouldUseV2 {
response := adaptToV2(user)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else {
response := adaptToV1(user)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
}
func shouldUseNewResponse(config *APIConfig, r *http.Request) bool {
if !config.EnableV2NewResponse {
return false
}
// Migración por cliente específico
clientID := r.Header.Get("X-Client-ID")
if isWhitelistedForV2(clientID) {
return true
}
// Migración gradual por porcentaje
hash := hashClientIP(r.RemoteAddr)
return float64(hash%100) < config.V2ResponseRate*100
}
func hashClientIP(ip string) uint32 {
// Simple hash for migration purposes
hash := uint32(0)
for _, b := range ip {
hash = hash*31 + uint32(b)
}
return hash
}
Ventajas:
- ✅ Migración gradual y segura
- ✅ Puedes revertir al instante
- ✅ Testing A/B posible
Desventajas:
- ❌ Complejidad aumentada
- ❌ Requiere infraestructura de flags
Patrón 3: Client Negotiation (Lo más elegante)
// Cliente negocia qué versión quiere soportar
func getUser(service *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
user, err := service.GetUser(userID)
if err != nil {
handleError(w, err)
return
}
// Preguntamos qué soporta el cliente
supportedVersions := parseClientVersions(r)
// Servimos la versión más nueva que soporta
version := selectBestVersion(supportedVersions)
var response interface{}
switch version {
case 3:
response = adaptToV3(user)
case 2:
response = adaptToV2(user)
default:
response = adaptToV1(user)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-API-Version", fmt.Sprintf("%d", version))
json.NewEncoder(w).Encode(response)
}
}
func parseClientVersions(r *http.Request) []int {
// Accept-Version: 3, 2, 1
versionHeader := r.Header.Get("Accept-Version")
// O: application/vnd.api+json;version=3;version=2
// Parse y retorna lista de versiones soportadas
return []int{3, 2, 1} // ejemplo
}
func selectBestVersion(supported []int) int {
// Toma la versión más nueva disponible
for _, v := range supported {
return v
}
return 1
}
Ventajas:
- ✅ Cliente decide
- ✅ Migración transparente
- ✅ Escalable a muchas versiones
Desventajas:
- ❌ Requiere cliente sofisticado
- ❌ Parsing complejo
🔴 Parte 7: Backwards Compatibility - Cómo No Romper Cosas
Cambios Seguros (Always backwards compatible)
// ✅ SEGURO: Agregar nuevo campo
type UserV1 struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
// ✅ Agregamos sin romper
CreatedAt *time.Time `json:"created_at,omitempty"` // omitempty es clave
}
// ✅ SEGURO: Agregar nuevo endpoint
// GET /api/v1/users (existente)
// GET /api/v1/users/search (nuevo)
// ✅ SEGURO: Hacer campo opcional
type CreateUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
Phone *string `json:"phone,omitempty"` // Ahora es opcional
}
// ✅ SEGURO: Expandir enum
type UserRole string
const (
RoleAdmin UserRole = "admin"
RoleEditor UserRole = "editor"
RoleViewer UserRole = "viewer"
RoleAuditor UserRole = "auditor" // Nuevo role, cliente ignora si no lo sabe
)
Cambios Peligrosos (Breaking changes)
// ❌ PELIGROSO: Cambiar nombre de campo
type UserOld struct {
Email string `json:"email"`
}
type UserNew struct {
UserEmail string `json:"user_email"` // ¡Campo rename!
}
// Cliente que espera "email" va a recibir "user_email"
// Result: KeyError en cliente
// ❌ PELIGROSO: Cambiar tipo de campo
type UserOld struct {
Age int `json:"age"` // Integer
}
type UserNew struct {
Age string `json:"age"` // String!
}
// Cliente que espera int recibe string
// Result: Type mismatch en cliente
// ❌ PELIGROSO: Hacer requerido un campo opcional
type CreateUserOld struct {
Phone *string `json:"phone,omitempty"`
}
type CreateUserNew struct {
Phone string `json:"phone"` // Ahora obligatorio!
}
// Cliente antiguo no envía phone
// Result: Validation error en servidor
// ❌ PELIGROSO: Cambiar URL o método HTTP
// GET /api/users → POST /api/users
// GET /api/users/{id} → GET /api/user/{id}
// ❌ PELIGROSO: Cambiar status codes
// 404 Not Found → 200 OK (con error adentro)
Técnica: Wrapper Compatible
// Cuando necesitas cambiar pero mantener compatibilidad
// Versión antigua
type UserResponseV1 struct {
ID string `json:"id"`
Email string `json:"email"`
}
// Versión nueva (pero envolvemos en estructura compatible)
type UserResponseV2 struct {
ID string `json:"id"`
Email string `json:"email"`
// Campos nuevos
FullName string `json:"full_name,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
// Cuando servimos a un cliente V1, no le mandamos los campos nuevos
func serveUserToClient(user *UserResponseV2, clientVersion int) interface{} {
if clientVersion == 1 {
return UserResponseV1{
ID: user.ID,
Email: user.Email,
}
}
return user
}
// El cliente V1 ve:
// {"id": "123", "email": "john@example.com"}
// El cliente V2 ve:
// {"id": "123", "email": "john@example.com", "full_name": "John Doe", "created_at": "2026-01-08T10:00:00Z"}
🎓 Explicación:
omitemptyes crucial - Si no incluyes el campo, JSON lo omite- Clientes antiguos ignoran campos nuevos - Es seguro agregar, no remover
- El tipo siempre debe ser el mismo - Si era string, sigue siendo string
🟠 Parte 8: Anti-Patrones y Errores Comunes
Anti-Patrón 1: La API “Omnibus”
// ❌ NO HAGAS ESTO
func getUser(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
includeDetails := r.URL.Query().Get("includeDetails") == "true"
includeTransactions := r.URL.Query().Get("includeTransactions") == "true"
format := r.URL.Query().Get("format") // json, xml, csv, protobuf?
deprecated := r.URL.Query().Get("useDeprecatedFormat") == "true"
// 32 combinaciones diferentes de respuesta
response := buildResponse(userID, includeDetails, includeTransactions, deprecated)
// ¿Qué versión es esto?
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
¿Por qué es malo?
- Imposible de debuggear
- Comportamiento no predecible
- Clientes no saben qué esperar
Anti-Patrón 2: Deprecation Silenciosa
// ❌ NO HAGAS ESTO
func getUser(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
// Simplemente removimos el endpoint
// Sin headers Deprecation
// Sin aviso previo
http.Error(w, "Not Found", http.StatusNotFound)
}
Resultado:
- Clientes explotan sin avisar previo
- ¡Sorpresas a las 3am!
Anti-Patrón 3: Versionado Inconsistente
// ❌ NO HAGAS ESTO: Inconsistencia total
// GET /api/users (no tiene versión, qué es?)
// GET /api/v2/users (tiene versión)
// GET /users?version=3 (versionado por query)
// POST /api/users con header Accept-Version: 4
// Cliente confundido: "¿Cuál uso? ¿Qué versión estoy en?"
La regla de oro: Una estrategia de versionado en toda la API.
Anti-Patrón 4: Respuestas Inconsistentes
// ❌ NO HAGAS ESTO: Formatos diferentes para el mismo error
// Endpoint A:
// {"error": "User not found"}
// Endpoint B:
// {"message": "user_not_found"}
// Endpoint C:
// {"errors": [{"code": "USER_NOT_FOUND", "detail": "..."}]}
// Cliente necesita 3 parsers diferentes para errores
Lo correcto: Schema de error consistente en TODA la API.
// ✅ HAZLO ASÍ
type APIError struct {
Code string `json:"code"` // USER_NOT_FOUND
Message string `json:"message"` // Human readable
Status int `json:"status"` // 404
Details map[string]interface{} `json:"details,omitempty"`
}
// SIEMPRE devuelves este formato
🟣 Parte 9: Monitoreo y Observabilidad
Qué Monitorear
package main
import (
"sync"
"sync/atomic"
)
type VersionMetrics struct {
v1Requests int64
v2Requests int64
v3Requests int64
errors int64
mu sync.RWMutex
}
func (vm *VersionMetrics) RecordRequest(version int) {
switch version {
case 3:
atomic.AddInt64(&vm.v3Requests, 1)
case 2:
atomic.AddInt64(&vm.v2Requests, 1)
default:
atomic.AddInt64(&vm.v1Requests, 1)
}
}
func (vm *VersionMetrics) RecordError(version int) {
atomic.AddInt64(&vm.errors, 1)
}
func (vm *VersionMetrics) GetMetrics() map[string]interface{} {
return map[string]interface{}{
"v1_requests": atomic.LoadInt64(&vm.v1Requests),
"v2_requests": atomic.LoadInt64(&vm.v2Requests),
"v3_requests": atomic.LoadInt64(&vm.v3Requests),
"total_errors": atomic.LoadInt64(&vm.errors),
"migration_status": map[string]interface{}{
"still_on_v1": float64(atomic.LoadInt64(&vm.v1Requests)) /
float64(atomic.LoadInt64(&vm.v1Requests) +
atomic.LoadInt64(&vm.v2Requests) +
atomic.LoadInt64(&vm.v3Requests)) * 100,
},
}
}
// Endpoint para monitoreo
func metricsHandler(metrics *VersionMetrics) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metrics.GetMetrics())
}
}
Métricas importantes:
- Requests por versión - ¿Dónde están los clientes?
- Error rates por versión - ¿Versión vieja tiene más bugs?
- Response times - ¿V2 es más rápido?
- Deprecation adoption - ¿Clientes migrando?
💎 Parte 10: Estrategia Completa - Un Ejemplo Real
Vamos a diseñar una API desde cero sabiendo que evolucionará.
package main
import (
"encoding/json"
"log"
"net/http"
"sync/atomic"
)
// ==== INFRAESTRUCTURA DE VERSIONING ====
type VersionHandler struct {
service *UserService
metrics *VersionMetrics
config *APIConfig
}
// ==== SERVICIO DE LÓGICA DE NEGOCIO ====
type UserService struct {
// Lógica agnóstica de versión
}
func (s *UserService) GetUser(id string) (*User, error) {
// Implementación real
return &User{
ID: id,
Email: "user@example.com",
Name: "John Doe",
}, nil
}
// ==== ESTRUCTURA DE DATOS INTERNA ====
type User struct {
ID string
Email string
Name string
}
// ==== ADAPTADORES DE RESPUESTA ====
type UserResponseV1 struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
type UserResponseV2 struct {
Success bool `json:"success"`
Data struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
} `json:"data"`
Timestamp string `json:"timestamp,omitempty"`
}
// ==== HANDLER ====
func (vh *VersionHandler) GetUser(w http.ResponseWriter, r *http.Request) {
version := parseVersion(r)
vh.metrics.RecordRequest(version)
userID := r.PathValue("id")
user, err := vh.service.GetUser(userID)
if err != nil {
vh.metrics.RecordError(version)
http.Error(w, `{"error": "not found"}`, http.StatusNotFound)
return
}
// Marcar como deprecated si es V1
if version == 1 {
w.Header().Set("Deprecation", "true")
w.Header().Set("Sunset", "Sun, 01 Apr 2026 00:00:00 GMT")
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-API-Version", "2") // Siempre responde con versión más nueva posible
// Adaptar respuesta según versión
switch version {
case 2:
resp := UserResponseV2{Success: true}
resp.Data.ID = user.ID
resp.Data.Email = user.Email
resp.Data.Name = user.Name
json.NewEncoder(w).Encode(resp)
default:
json.NewEncoder(w).Encode(UserResponseV1{
ID: user.ID,
Email: user.Email,
Name: user.Name,
})
}
}
// ==== PARSEO DE VERSIÓN ====
func parseVersion(r *http.Request) int {
// 1. Chequear header
if v := r.Header.Get("X-API-Version"); v != "" {
var version int
if _, err := fmt.Sscanf(v, "%d", &version); err == nil {
return version
}
}
// 2. Chequear URL
if len(r.URL.Path) > 7 && r.URL.Path[7:8] >= "1" && r.URL.Path[7:8] <= "9" {
version := int(r.URL.Path[7] - '0')
return version
}
// 3. Default
return 1
}
// ==== MÉTRICAS ====
type VersionMetrics struct {
v1 int64
v2 int64
}
func (m *VersionMetrics) RecordRequest(version int) {
switch version {
case 2:
atomic.AddInt64(&m.v2, 1)
default:
atomic.AddInt64(&m.v1, 1)
}
}
func (m *VersionMetrics) RecordError(version int) {
// Track errors por versión
}
// ==== CONFIG ====
type APIConfig struct {
V1SunsetDate string
}
// ==== MAIN ====
func main() {
service := &UserService{}
metrics := &VersionMetrics{}
config := &APIConfig{
V1SunsetDate: "Sun, 01 Apr 2026 00:00:00 GMT",
}
handler := &VersionHandler{
service: service,
metrics: metrics,
config: config,
}
mux := http.NewServeMux()
// Ruta V1 (deprecated)
mux.HandleFunc("GET /api/v1/users/{id}", handler.GetUser)
// Ruta V2 (current)
mux.HandleFunc("GET /api/v2/users/{id}", handler.GetUser)
// Métricas
mux.HandleFunc("GET /internal/metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"v1_requests": atomic.LoadInt64(&metrics.v1),
"v2_requests": atomic.LoadInt64(&metrics.v2),
})
})
log.Println("Server listening on :8080")
http.ListenAndServe(":8080", mux)
}
📝 Conclusiones: Checklist para tu API
Antes de Versionar
- Define estrategia clara - URL o Header (y mantén consistencia)
- Documenta cambios - Qué cambió, cuándo, por qué
- Estructura interna agnóstica - Lógica no depende de versión
- Planifica deprecación - Antes de hacer cambios breaking
Durante la Evolución
- Mantén compatibilidad atrás - Cambios seguros siempre
- Monitorea uso - Sabe quién usa qué
- Comunica temprano - 3 meses de aviso mínimo
- Testing exhaustivo - Ambas versiones funcionan
En Producción
- Headers correctos - Deprecation, Sunset, Link
- Métricas activas - Dashboard de migración
- Soporte dedicado - Responde preguntas de clientes
- Timeline claro - Cuándo se va cada versión
Al Sunsetting
- No sorpresas - Ya sabían desde hace meses
- Migración completa - Verifica antes de remover
- 404 con mensaje - “Use /api/v2 en su lugar”
- Documenta decision - Por qué se removió esta versión
🎓 Lecciones Finales
La Verdad Incómoda
La mayoría de APIs tienen múltiples versiones porque las rompieron. No porque las planearon bien.
La arquitectura de versioning no es opcional, es inevitable. La pregunta no es “¿Necesito versioning?” sino “¿Cuándo y cómo planear para él?”
El Principio de Menor Sorpresa
Tu cliente debería nunca sorprenderse. Si cambias un endpoint:
- Aviso 3 meses antes
- Headers HTTP claros
- Rutas alternativas disponibles
- Soporte durante transición
URLs vs Headers: Mi Consejo Final
URL Versioning es mejor porque:
- Clientes no olvidan
- URLs en logs son obvias
- CDNs lo cachean bien
- Debugging es trivial
PERO: Si tu equipo es disciplinado y tus clientes son sofisticados (microservicios internos), Header versioning es más elegante.
En duda: Usa URLs. La claridad es más valiosa que la elegancia.
🚀 Recursos Recomendados
- RFC 7231 - HTTP Semantics (qué significan los status codes)
- RFC 7234 - HTTP Caching (cómo cachean versiones)
- Stripe API docs - Ejemplo de versioning bien hecho
- GitHub API v3 vs v4 - Comparativa de transición
¡Tu API merece vivir años en producción sin romper cosas! 🎯
Tags
Artículos relacionados
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
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.