Inyección de dependencias en Go: Patrones y buenas prácticas

Domine los patrones DI para código de Go testeable

Índice

Inyección de dependencias (DI) es un patrón de diseño fundamental que promueve código limpio, testable y mantenible en aplicaciones de Go.

Ya sea que estés construyendo APIs REST, implementando patrones de base de datos multiinquilino, o trabajando con bibliotecas ORM, entender la inyección de dependencias mejorará significativamente la calidad de tu código.

go-dependency-injection

¿Qué es la inyección de dependencias?

La inyección de dependencias es un patrón de diseño donde los componentes reciben sus dependencias de fuentes externas en lugar de crearlas internamente. Este enfoque desacopla componentes, haciendo que tu código sea más modular, testable y mantenible.

En Go, la inyección de dependencias es especialmente poderosa debido a la filosofía de diseño basada en interfaces del lenguaje. La satisfacción implícita de interfaces en Go significa que puedes cambiar fácilmente las implementaciones sin modificar el código existente.

¿Por qué usar la inyección de dependencias en Go?

Mejora de la testabilidad: Al inyectar dependencias, puedes reemplazar fácilmente las implementaciones reales con mocks o dobles de prueba. Esto te permite escribir pruebas unitarias que sean rápidas, aisladas y no requieran servicios externos como bases de datos o APIs.

Mejor mantenibilidad: Las dependencias se vuelven explícitas en tu código. Cuando miras una función constructora, inmediatamente ves qué requiere un componente. Esto hace que la base de código sea más fácil de entender y modificar.

Desacoplamiento suave: Los componentes dependen de abstracciones (interfaces) en lugar de implementaciones concretas. Esto significa que puedes cambiar implementaciones sin afectar el código dependiente.

Flexibilidad: Puedes configurar diferentes implementaciones para diferentes entornos (desarrollo, pruebas, producción) sin cambiar tu lógica de negocio.

Inyección por constructor: La forma Go

La manera más común e idiomática de implementar la inyección de dependencias en Go es a través de funciones constructoras. Estas suelen tener nombres como NewXxx y aceptan dependencias como parámetros.

Ejemplo básico

Aquí hay un ejemplo simple que demuestra la inyección por constructor:

// Definir una interfaz para el repositorio
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

// El servicio depende de la interfaz del repositorio
type UserService struct {
    repo UserRepository
}

// El constructor inyecta la dependencia
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// Los métodos usan la dependencia inyectada
func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

Este patrón hace claro que UserService requiere un UserRepository. No puedes crear un UserService sin proporcionar un repositorio, lo que previene errores en tiempo de ejecución por dependencias faltantes.

Múltiples dependencias

Cuando un componente tiene múltiples dependencias, simplemente agrégalas como parámetros del constructor:

type EmailService interface {
    Send(to, subject, body string) error
}

type Logger interface {
    Info(msg string)
    Error(msg string, err error)
}

type OrderService struct {
    repo        OrderRepository
    emailSvc    EmailService
    logger      Logger
    paymentSvc  PaymentService
}

func NewOrderService(
    repo OrderRepository,
    emailSvc EmailService,
    logger Logger,
    paymentSvc PaymentService,
) *OrderService {
    return &OrderService{
        repo:       repo,
        emailSvc:   emailSvc,
        logger:     logger,
        paymentSvc: paymentSvc,
    }
}

Diseño de interfaces para la inyección de dependencias

Uno de los principios clave al implementar la inyección de dependencias es el Principio de Inversión de Dependencias (DIP): los módulos de alto nivel no deben depender de módulos de bajo nivel; ambos deben depender de abstracciones.

En Go, esto significa definir interfaces pequeñas y enfocadas que representen solo lo que tu componente necesita:

// Bueno: interfaz pequeña y enfocada
type PaymentProcessor interface {
    ProcessPayment(amount float64) error
}

// Malo: interfaz grande con métodos innecesarios
type PaymentService interface {
    ProcessPayment(amount float64) error
    RefundPayment(id string) error
    GetPaymentHistory(userID int) ([]Payment, error)
    UpdatePaymentMethod(userID int, method PaymentMethod) error
    // ... muchos más métodos
}

La interfaz más pequeña sigue el Principio de Segregación de Interfaces—los clientes no deben depender de métodos que no usan. Esto hace que tu código sea más flexible y más fácil de probar.

Ejemplo real: abstracción de base de datos

Cuando trabajas con bases de datos en aplicaciones de Go, a menudo necesitas abstraer las operaciones de base de datos. Aquí es cómo la inyección de dependencias ayuda:

// Interfaz de base de datos - abstracción de alto nivel
type DB interface {
    Query(ctx context.Context, query string, args ...interface{}) (Rows, error)
    Exec(ctx context.Context, query string, args ...interface{}) (Result, error)
    BeginTx(ctx context.Context) (Tx, error)
}

// El repositorio depende de la abstracción
type UserRepository struct {
    db DB
}

func NewUserRepository(db DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    rows, err := r.db.Query(ctx, query, id)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    // ... analizar filas
}

Este patrón es especialmente útil al implementar patrones de base de datos multiinquilino, donde podrías necesitar cambiar entre diferentes implementaciones de base de datos o estrategias de conexión.

El patrón de raíz de composición

La Raíz de Composición es donde ensambles todas tus dependencias en el punto de entrada de la aplicación (normalmente main). Esto centraliza la configuración de dependencias y hace explícito el gráfico de dependencias.

func main() {
    // Inicializar dependencias de infraestructura
    db := initDatabase()
    logger := initLogger()
    
    // Inicializar repositorios
    userRepo := NewUserRepository(db)
    orderRepo := NewOrderRepository(db)
    
    // Inicializar servicios con dependencias
    emailSvc := NewEmailService(logger)
    paymentSvc := NewPaymentService(logger)
    userSvc := NewUserService(userRepo, logger)
    orderSvc := NewOrderService(orderRepo, emailSvc, logger, paymentSvc)
    
    // Inicializar controladores HTTP
    userHandler := NewUserHandler(userSvc)
    orderHandler := NewOrderHandler(orderSvc)
    
    // Configurar rutas
    router := setupRouter(userHandler, orderHandler)
    
    // Iniciar servidor
    log.Fatal(http.ListenAndServe(":8080", router))
}

Este enfoque hace claro cómo está estructurada tu aplicación y de dónde provienen las dependencias. Es especialmente valioso al construir APIs REST en Go, donde necesitas coordinar múltiples capas de dependencias.

Marcos de inyección de dependencias

Para aplicaciones más grandes con gráficos de dependencias complejos, gestionar dependencias manualmente puede volverse incómodo. Go tiene varios marcos de DI que pueden ayudarte:

Google Wire (DI en tiempo de compilación)

Wire es una herramienta de inyección de dependencias en tiempo de compilación que genera código. Es tipo seguro y no tiene sobrecarga en tiempo de ejecución.

Instalación:

go install github.com/google/wire/cmd/wire@latest

Ejemplo:

// wire.go
//go:build wireinject
// +build wireinject

package main

import "github.com/google/wire"

func InitializeApp() (*App, error) {
    wire.Build(
        NewDB,
        NewUserRepository,
        NewUserService,
        NewUserHandler,
        NewApp,
    )
    return &App{}, nil
}

Wire genera el código de inyección de dependencias en tiempo de compilación, asegurando la seguridad de tipos y eliminando la sobrecarga de reflexión en tiempo de ejecución.

Uber Dig (DI en tiempo de ejecución)

Dig es un marco de inyección de dependencias en tiempo de ejecución que usa reflexión. Es más flexible pero tiene algunos costos en tiempo de ejecución.

Ejemplo:

import "go.uber.org/dig"

func main() {
    container := dig.New()
    
    // Registrar proveedores
    container.Provide(NewDB)
    container.Provide(NewUserRepository)
    container.Provide(NewUserService)
    container.Provide(NewUserHandler)
    
    // Invocar función que necesita dependencias
    err := container.Invoke(func(handler *UserHandler) {
        // Usar handler
    })
    if err != nil {
        log.Fatal(err)
    }
}

¿Cuándo usar marcos?

Usa un marco cuando:

  • Tu gráfico de dependencias es complejo con muchos componentes interdependientes
  • Tienes múltiples implementaciones de la misma interfaz que necesitan seleccionarse según la configuración
  • Quieres resolución automática de dependencias
  • Estás construyendo una aplicación grande donde la conexión manual se vuelve propensa a errores

Usa DI manual cuando:

  • Tu aplicación es pequeña o mediana
  • El gráfico de dependencias es simple y fácil de seguir
  • Quieres mantener las dependencias mínimas y explícitas
  • Prefieres código explícito sobre código generado

Pruebas con inyección de dependencias

Uno de los principales beneficios de la inyección de dependencias es la mejora de la testabilidad. Aquí es cómo DI hace más fácil las pruebas:

Ejemplo de prueba unitaria

// Implementación de prueba
type mockUserRepository struct {
    users map[int]*User
    err   error
}

func (m *mockUserRepository) FindByID(id int) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.users[id], nil
}

func (m *mockUserRepository) Save(user *User) error {
    if m.err != nil {
        return m.err
    }
    m.users[user.ID] = user
    return nil
}

// Prueba usando el mock
func TestUserService_GetUser(t *testing.T) {
    mockRepo := &mockUserRepository{
        users: map[int]*User{
            1: {ID: 1, Name: "John", Email: "john@example.com"},
        },
    }
    
    service := NewUserService(mockRepo)
    
    user, err := service.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "John", user.Name)
}

Esta prueba se ejecuta rápidamente, no requiere una base de datos y prueba tu lógica de negocio en aislamiento. Cuando trabajas con bibliotecas ORM en Go, puedes inyectar repositorios de prueba para probar la lógica del servicio sin configuración de base de datos.

Patrones comunes y buenas prácticas

1. Usar segregación de interfaces

Mantén las interfaces pequeñas y enfocadas en lo que el cliente realmente necesita:

// Bueno: El cliente solo necesita leer usuarios
type UserReader interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
}

// Interfaz separada para escritura
type UserWriter interface {
    Save(user *User) error
    Delete(id int) error
}

2. Devolver errores desde constructores

Los constructores deben validar dependencias y devolver errores si la inicialización falla:

func NewUserService(repo UserRepository) (*UserService, error) {
    if repo == nil {
        return nil, errors.New("el repositorio de usuarios no puede ser nulo")
    }
    return &UserService{repo: repo}, nil
}

3. Usar contexto para dependencias con ámbito de solicitud

Para dependencias específicas de solicitud (como transacciones de base de datos), pásalas mediante contexto:

type ctxKey string

const dbKey ctxKey = "db"

func WithDB(ctx context.Context, db DB) context.Context {
    return context.WithValue(ctx, dbKey, db)
}

func DBFromContext(ctx context.Context) (DB, bool) {
    db, ok := ctx.Value(dbKey).(DB)
    return db, ok
}

4. Evitar la sobreinyección

No inyectes dependencias que sean verdaderos detalles de implementación interna. Si un componente crea y gestiona sus propios objetos auxiliares, eso está bien:

// Bueno: El helper interno no necesita inyección
type UserService struct {
    repo UserRepository
    // Cache interno - no necesita inyección
    cache map[int]*User
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{
        repo:  repo,
        cache: make(map[int]*User),
    }
}

5. Documentar dependencias

Usa comentarios para documentar por qué se necesitan dependencias y cualquier restricción:

// UserService maneja la lógica de negocio relacionada con usuarios.
// Requiere un UserRepository para el acceso a datos y un Logger para
// el seguimiento de errores. El repositorio debe ser seguro para hilos si se usa
// en contextos concurrentes.
func NewUserService(repo UserRepository, logger Logger) *UserService {
    // ...
}

¿Cuándo NO usar la inyección de dependencias?

La inyección de dependencias es una herramienta poderosa, pero no siempre es necesaria:

Saltar DI para:

  • Objetos simples o estructuras de datos
  • Funciones auxiliares o utilidades internas
  • Scripts de uso único o utilidades pequeñas
  • Cuando la instanciación directa es más clara y simple

Ejemplo de cuando NO usar DI:

// Estructura simple - no se necesita DI
type Point struct {
    X, Y float64
}

func NewPoint(x, y float64) Point {
    return Point{X: x, Y: y}
}

// Utilidad simple - no se necesita DI
func FormatCurrency(amount float64) string {
    return fmt.Sprintf("$%.2f", amount)
}

Integración con el ecosistema de Go

La inyección de dependencias funciona de forma sinérgica con otros patrones y herramientas de Go. Cuando construyes aplicaciones que usan la biblioteca estándar de Go para web scraping o generar informes en PDF, puedes inyectar estos servicios en tu lógica de negocio:

type PDFGenerator interface {
    GenerateReport(data ReportData) ([]byte, error)
}

type ReportService struct {
    pdfGen PDFGenerator
    repo   ReportRepository
}

func NewReportService(pdfGen PDFGenerator, repo ReportRepository) *ReportService {
    return &ReportService{
        pdfGen: pdfGen,
        repo:   repo,
    }
}

Esto te permite cambiar entre implementaciones de generación de PDF o usar mocks durante las pruebas.

Conclusión

La inyección de dependencias es una piedra angular al escribir código Go mantenible y testable. Siguiendo los patrones descritos en este artículo—inyección por constructor, diseño basado en interfaces y el patrón de raíz de composición—creará aplicaciones más fáciles de entender, probar y modificar.

Comienza con la inyección manual por constructor para aplicaciones pequeñas a medianas, y considera marcos como Wire o Dig a medida que crece tu gráfico de dependencias. Recuerda que el objetivo es claridad y testabilidad, no complejidad por sí misma.

Para más recursos de desarrollo en Go, consulta nuestro Hoja de trucos de Go.

Enlaces útiles

Recursos externos