API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes

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.

Por Omar Flores

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:

  • UserService es 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 Accept o X-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

AspectoURL VersioningHeader 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:

  • omitempty es 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:

  1. Requests por versión - ¿Dónde están los clientes?
  2. Error rates por versión - ¿Versión vieja tiene más bugs?
  3. Response times - ¿V2 es más rápido?
  4. 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

#golang #api-design #versioning #backend #rest #architecture #best-practices