API-DD

Guía práctica

El singleton que convierte una dependencia en estado global

Un recorder global parece cómodo hasta que dos tests necesitan configuraciones distintas: el singleton oculta la API requerida y comparte estado entre consumidores.

Por Lautaro Mei

Un paquete de auditoría ofrece una instancia global:

go
package audit

var defaultRecorder = NewRecorder(os.Stdout)

func Record(event Event) error {
    return defaultRecorder.Record(event)
}

Cualquier módulo puede registrar un evento sin recibir dependencias:

go
func (s *Service) Cancel(ctx context.Context, id BookingID) error {
    if err := s.bookings.Cancel(ctx, id); err != nil {
        return err
    }
    return audit.Record(BookingCancelled{ID: id})
}

Hay una sola instancia y una llamada corta. El coste aparece en lo que la firma de Service ya no cuenta: cancelar necesita auditoría, puede fallar por ella y comparte el mismo recorder con todos los consumidores del proceso.

La dependencia existe aunque el constructor no la muestre#

El singleton no elimina la relación. Crea una ruta alternativa para obtenerla.

go
service := NewService(bookings)

Esta construcción parece completa, pero el comportamiento también depende de cuándo se inicializó audit.defaultRecorder, quién lo configuró y qué otros módulos lo usan.

Para comprender Cancel hay que buscar nombres globales fuera del módulo. La conversación deja de estar contenida por su API.

El test necesita modificar el mundo#

Para observar auditoría, suele aparecer un setter global:

go
func SetDefault(recorder Recorder) {
    defaultRecorder = recorder
}

El test sustituye la instancia y promete restaurarla:

go
func TestCancelRecordsEvent(t *testing.T) {
    fake := &fakeRecorder{}
    audit.SetDefault(fake)
    t.Cleanup(func() {
        audit.SetDefault(audit.NewRecorder(os.Stdout))
    })

    service := NewService(fakeBookings{})
    _ = service.Cancel(context.Background(), "booking-42")

    if fake.events != 1 {
        t.Fatalf("expected one event, got %d", fake.events)
    }
}

El caso ya no controla solo su sujeto. Cambia estado compartido por todo el proceso.

Dos tests paralelos pueden sobrescribirse. Un test que olvida restaurar contamina al siguiente. El resultado puede depender del orden, de una suite completa o de si otro paquete ejecutó init.

La fragilidad no procede del fake. Procede de que sustituir una dependencia local exige mutar un singleton global.

Una instancia única no necesita acceso global#

La aplicación puede crear un solo recorder en su punto de composición y entregarlo explícitamente:

go
func main() {
    recorder := audit.NewRecorder(os.Stdout)
    bookings := postgres.NewBookings(db)
    service := cancellation.NewService(bookings, recorder)

    startHTTP(service)
}

El módulo declara la capacidad que necesita:

go
package cancellation

type Audit interface {
    Record(audit.Event) error
}

type Service struct {
    bookings Bookings
    audit    Audit
}

func NewService(bookings Bookings, audit Audit) *Service {
    return &Service{bookings: bookings, audit: audit}
}

Puede seguir existiendo una sola instancia en producción. La diferencia es que su ciclo de vida tiene propietario y la dependencia cruza una API visible.

El test recupera autonomía#

Cada test construye su propio grafo:

go
func TestCancelRecordsEvent(t *testing.T) {
    fake := &fakeRecorder{}
    service := NewService(fakeBookings{}, fake)

    err := service.Cancel(context.Background(), "booking-42")

    if err != nil {
        t.Fatalf("cancel: %v", err)
    }
    if fake.events != 1 {
        t.Fatalf("expected one event, got %d", fake.events)
    }
}

No hay restauración. Dos casos pueden ejecutarse en paralelo con recorders distintos. Una segunda instancia de Service puede utilizar otra política sin reconfigurar la primera.

El constructor ahora cuenta la verdad: el servicio no está completo sin reservas y auditoría.

El singleton mezcla tres decisiones#

Bajo la palabra singleton suelen esconderse decisiones diferentes:

Las dos primeras pueden ser válidas sin la tercera. Una pool de conexiones, un registry de métricas o una configuración pueden tener una instancia compartida gobernada por la composición. No necesitan exponer un Default() a todos los paquetes.

Separar las decisiones permite cambiar la cantidad en tests, ejecutar dos aplicaciones en el mismo proceso o migrar una dependencia sin editar consumidores ocultos.

sync.Once resuelve inicialización, no diseño#

Esta variante evita crear dos veces la instancia:

go
var (
    once     sync.Once
    recorder *Recorder
)

func Default() *Recorder {
    once.Do(func() {
        recorder = NewRecorder(os.Stdout)
    })
    return recorder
}

sync.Once puede hacer segura la inicialización concurrente. No vuelve visible la dependencia, no separa estado entre tests y no permite dos configuraciones legítimas. Resuelve una propiedad del mecanismo, no el contrato entre módulos.

Cuándo un global es inocuo#

Una constante o un valor inmutable sin identidad compartida no introduce el mismo riesgo:

go
var ErrNotFound = errors.New("not found")

Nadie lo reconfigura y observarlo no modifica a otros consumidores. El problema aparece con servicios, caches, clientes o registries cuyo estado y configuración cambian el comportamiento.

La pregunta útil no es «¿hay una variable de paquete?», sino «¿puede un consumidor cambiar algo que afecte silenciosamente a los demás?».

Compartir una instancia puede ser una decisión de composición. Compartir una puerta global para alcanzarla convierte esa decisión en acoplamiento para todos.