Implementación de Feature Flags en Go para despliegues seguros

Los feature flags (o feature toggles) son un mecanismo de control en tiempo de ejecución que permite desacoplar el despliegue de código (hacer un push a producción) del lanzamiento de la funcionalidad (activar la feature para los usuarios). En lugar de depender de un nuevo despliegue para activar una lógica compleja, consultamos un estado externo que nos indica si esa ruta de ejecución debe ser seguida. Esta técnica es vital para implementar canary releases (despliegues graduales), realizar A/B testing o, en casos críticos, actuar como un kill switch para desactivar una funcionalidad que está provocando errores en producción sin necesidad de un rollback completo.

Para que esta estrategia sea sostenible en sistemas distribuidos, la evaluación de la flag no debe ser un simple if os.Getenv(...) == "true". Una implementación profesional requiere una interfaz que permita la segmentación (activar la feature solo para el usuario X o el 5% de la audiencia) y la testabilidad (poder forzar estados en las pruebas unitarias). Si implementas esto de forma global o mediante variables estáticas, rompes la capacidad de testear escenarios de error y conviertes tu lógica de negocio en un caos de condicionales difíciles de seguir.

package main

import (
	"context"
	"fmt"
)

// ContextKey es un tipo personalizado para evitar colisiones en el context.Context.
type contextKey string

const userIDKey contextKey = "userID"

// FeatureGate define la abstracción necesaria para consultar flags.
// El uso de context.Context es fundamental para permitir la segmentación
// basada en el usuario, la región o cualquier metadato del request.
type FeatureGate interface {
	IsEnabled(ctx context.Context, featureKey string) bool
}

// --- Implementaciones ---

// ConfigGate es la implementación de producción. En un entorno real,
// esto podría consultar un caché local sincronizado con Redis, 
// LaunchDarkly, Unleash o un servicio de configuración dinámica.
type ConfigGate struct {
	// En un caso real, esto sería un mapa protegido por un RWMutex 
	// o un cliente de un servicio externo.
	flags map[string]bool
}

func (c *ConfigGate) IsEnabled(ctx context.Context, featureKey string) bool {
	// Simulamos lógica de segmentación: la feature "new-payment-gateway"
	// solo está activa si el flag es true Y el usuario es el "VIP_USER".
	enabled := c.flags[featureKey]
	
	if featureKey == "new-payment-gateway" {
		userID, ok := ctx.Value(userIDKey).(string)
		return enabled && ok && userID == "VIP_USER"
	}
	
	return enabled
}

// MockGate es la implementación para tests unitarios.
// Permite predecir el comportamiento de la lógica de negocio sin efectos secundarios.
type MockGate struct {
	MockedFlags map[string]bool
}

func (m *MockGate) IsEnabled(ctx context.Context, featureKey string) bool {
	return m.MockedFlags[featureKey]
}

// --- Lógica de Negocio ---

// PaymentService es nuestro componente de negocio. Nota que no sabe
// de dónde vienen los flags, solo sabe que puede consultarlos.
type PaymentService struct {
	gate FeatureGate
}

func (s *PaymentService) ProcessOrder(ctx context.Context, orderID string, amount float64) error {
	fmt.Printf("Procesando orden %s por $%.2f...\n", orderID, amount)

	// Decisión basada en Feature Flag
	if s.gate.IsEnabled(ctx, "new-payment-gateway") {
		return s.processWithCrypto(orderID, amount)
	}

	return s.processWithLegacy(orderID, amount)
}

func (s *PaymentService) processWithLegacy(id string, amt float64) error {
	fmt.Println("-> Usando pasarela de pagos LEGACY (Credit Card)")
	return nil
}

func (s *PaymentService) processWithCrypto(id string, amt float64) error {
	fmt.Println("-> Usando pasarela de pagos NUEVA (Crypto)")
	return nil
}

func main() {
	// Escenario 1: Producción con el flag desactivado
	prodGate := &ConfigGate{flags: map[string]bool{"new-payment-gateway": true}}
	service := &PaymentService{gate: prodGate}

	fmt.Println("--- Escenario Producción: Usuario Normal ---")
	ctxNormal := context.WithValue(context.Background(), userIDKey, "user_123")
	_ = service.ProcessOrder(ctxNormal, "ord_001", 100.0)

	fmt.Println("\n--- Escenario Producción: Usuario VIP ---")
	ctxVIP := context.WithValue(context.Background(), userIDKey, "VIP_USER")
	_ = service.ProcessOrder(ctxVIP, "ord_002", 500.0)

	// Escenario 2: Test Unitario
	fmt.Println("\n--- Escenario Test: Forzando feature OFF ---")
	mockGate := &MockGate{MockedFlags: map[string]bool{"new-payment-gateway": false}}
	testService := &PaymentService{gate: mockGate}
	_ = testService.ProcessOrder(context.Background(), "test_999", 10.0)
}

Desglose de la implementación

En el código anterior, la clave del diseño es la inyección de dependencias de la interfaz FeatureGate en PaymentService. Esto permite que el servicio de pagos sea totalmente agnóstico de la infraestructura de configuración.

En ConfigGate, implementamos la lógica de segmentación aprovechando el context.Context. Al extraer userIDKey dentro del método IsEnabled, permitimos que la decisión de si una feature está activa no dependa solo de un booleano global, sino de la identidad del usuario que realiza la petición. Esto es lo que permite un canary rollout: el flag puede ser true para el sistema, pero la lógica interna de la implementación decide que solo se aplique a un subconjunto de usuarios.

El MockGate es el aliado del ingeniero en la fase de testing. Al usar una implementación que simplemente devuelve un valor de un mapa predefinido, eliminamos la necesidad de configurar variables de entorno o servicios externos en nuestros tests unitarios, haciendo que las pruebas sean deterministas y rápidas.

El error frecuente

Un error crítico al trabajar con feature flags en sistemas concurrentes es la inconsistencia de estado durante una transacción.

Si evalúas una flag al inicio de una función larga y la vuelves a evaluar en medio de la misma, podrías terminar con un estado inconsistente si el valor cambia en el proveedor externo.

// ERROR: Inconsistencia de estado
func (s *PaymentService) RiskyOperation(ctx context.Context) {
    if s.gate.IsEnabled(ctx, "new-feature") {
        // Imagina que aquí la flag cambia en la DB/Config externa
        // debido a un cambio en caliente (hot-reload).
        
        doStepOne()
        
        // Si vuelves a consultar, podrías entrar en el bloque 'else' 
        // de una lógica que asume que la feature sigue activa.
        if s.gate.IsEnabled(ctx, "new-feature") { 
            doStepTwo()
        } else {
            return fmt.Errorf("estado inconsistente")
        }
    }
}

La solución es capturar el estado de la flag en una variable local al inicio del flujo de ejecución o, mejor aún, pasar esa decisión como un parámetro a las funciones internas para garantizar que un mismo flujo de ejecución sea atómico respecto a la configuración.

Además, no olvides el cleanup. Los feature flags son deuda técnica con patas; cada flag que dejas en el código es un condicional que deberá ser eliminado una vez que la feature sea estable. Si no defines un proceso de limpieza, tu base de código se llenará de “ramas muertas” que nadie se atreverá a borrar.

253

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio