6. Inversión de dependencias y principio SOLID aplicado

La arquitectura hexagonal abraza el Principio de Inversión de Dependencias (DIP) del acrónimo SOLID para garantizar que las decisiones del dominio sean independientes de la tecnología. Seguir este principio significa que los módulos de alto nivel (casos de uso, reglas de negocio) no dependen de detalles de bajo nivel (frameworks, bases de datos, controladores) sino de abstracciones. De esa manera la aplicación puede evolucionar sin reescribir su corazón cada vez que aparece una nueva herramienta.

Este tema detalla cómo se aplica el DIP en el diseño hexagonal, cómo los puertos juegan el rol de abstracciones compartidas, por qué el dominio queda protegido de frameworks y cuáles son los beneficios concretos para las pruebas y el mantenimiento continuo.

6.1 Aplicación del Principio de Inversión de Dependencias (D)

El DIP establece dos reglas: los módulos de alto nivel no deben depender de módulos de bajo nivel y ambos deben depender de abstracciones; además, las abstracciones no deben depender de detalles, sino que los detalles deben depender de las abstracciones. En una aplicación hexagonal esto se traduce en que:

  • Los casos de uso dependen de interfaces que representan servicios necesarios (guardar datos, emitir eventos, consultar catálogos) en vez de depender de clases concretas.
  • Los adaptadores concretos implementan esas interfaces, de modo que los detalles técnicos se subordinan a las necesidades del negocio.
  • El contenedor de inyección o la configuración enlaza ambos mundos sin violar la dirección de dependencias.

Al invertir la dependencia, cambia el sentido tradicional: en lugar de que el dominio se adapte al framework de turno, son los detalles quienes se alinean al modelo de negocio.

6.2 Cómo los puertos abstraen la infraestructura

Los puertos son la materialización de esas abstracciones. Definen contratos claros que la infraestructura debe cumplir para colaborar con la lógica de negocio. Al ubicarlos en el dominio o en la capa de aplicación según su tipo, obligan a que cualquier adaptador se ajuste al lenguaje del negocio.

Esta abstracción trae consigo varias consecuencias positivas:

  • El dominio habla en términos propios (por ejemplo, Pedido, Factura, Notificación) en lugar de términos técnicos (DTO, EntityManager, HttpClient).
  • Los puertos pueden versionarse y evolucionar sin atar la aplicación a bibliotecas específicas.
  • La infraestructura se vuelve intercambiable: basta con cumplir el contrato definido por el puerto.

Cuando se descubre que un adaptador necesita un dato adicional, se negocia a nivel del puerto; así se evita que cada detalle técnico modifique libremente el dominio.

6.3 Ejemplo: el dominio no depende de frameworks ni bases de datos

Consideremos un contexto de gestión de inventario. El caso de uso ReponerStock debe registrar un movimiento y avisar cuando el stock queda por debajo de un umbral. El dominio define interfaces y clases puras, sin anotaciones ni referencias a frameworks.

package com.example.inventario.dominio;

import java.time.LocalDateTime;

public class MovimientoStock {

    private final String sku;
    private final int cantidad;
    private final LocalDateTime fecha;

    public MovimientoStock(String sku, int cantidad, LocalDateTime fecha) {
        if (sku == null || sku.isBlank()) {
            throw new IllegalArgumentException("El SKU es obligatorio");
        }
        if (cantidad == 0) {
            throw new IllegalArgumentException("La cantidad no puede ser cero");
        }
        this.sku = sku;
        this.cantidad = cantidad;
        this.fecha = fecha != null ? fecha : LocalDateTime.now();
    }

    public String sku() {
        return sku;
    }

    public int cantidad() {
        return cantidad;
    }

    public LocalDateTime fecha() {
        return fecha;
    }
}

package com.example.inventario.dominio;

public interface PuertoInventario {
    void registrarMovimiento(MovimientoStock movimiento);
    int stockActual(String sku);
}

package com.example.inventario.dominio;

public interface PuertoAlertas {
    void notificarBajoStock(String sku, int stockDisponible);
}

package com.example.inventario.aplicacion;

import com.example.inventario.dominio.MovimientoStock;
import com.example.inventario.dominio.PuertoAlertas;
import com.example.inventario.dominio.PuertoInventario;

public class ReponerStock {

    private final PuertoInventario puertoInventario;
    private final PuertoAlertas puertoAlertas;

    public ReponerStock(PuertoInventario puertoInventario, PuertoAlertas puertoAlertas) {
        this.puertoInventario = puertoInventario;
        this.puertoAlertas = puertoAlertas;
    }

    public void ejecutar(String sku, int cantidad) {
        MovimientoStock movimiento = new MovimientoStock(sku, cantidad, java.time.LocalDateTime.now());
        puertoInventario.registrarMovimiento(movimiento);
        int disponible = puertoInventario.stockActual(sku);
        if (disponible <= 5) {
            puertoAlertas.notificarBajoStock(sku, disponible);
        }
    }
}

La clase ReponerStock solo conoce las abstracciones PuertoInventario y PuertoAlertas. No importa si el stock se persiste en una base relacional, en una hoja de cálculo o en un servicio externo; la infraestructura debe adaptarse al contrato. Tampoco hace referencia a anotaciones de inyección, sesiones de Hibernate ni repositorios específicos.

6.3.1 Adaptadores que dependen del dominio

Los adaptadores concretos se implementan en la infraestructura y dependen del dominio para conocer los puertos. A modo de ejemplo, un adaptador basado en Spring Data JPA y otro que envía alertas por correo podrían lucir de la siguiente manera:

package com.example.inventario.infraestructura.salida.jpa;

import org.springframework.stereotype.Repository;
import com.example.inventario.dominio.MovimientoStock;
import com.example.inventario.dominio.PuertoInventario;

@Repository
class InventarioJpaAdapter implements PuertoInventario {

    private final MovimientoJpaRepository repository;

    InventarioJpaAdapter(MovimientoJpaRepository repository) {
        this.repository = repository;
    }

    @Override
    public void registrarMovimiento(MovimientoStock movimiento) {
        repository.save(MovimientoEntity.desdeDominio(movimiento));
    }

    @Override
    public int stockActual(String sku) {
        return repository.sumarCantidadPorSku(sku);
    }
}

package com.example.inventario.infraestructura.salida.notificaciones;

import com.example.inventario.dominio.PuertoAlertas;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.stereotype.Component;

@Component
class AlertaCorreoAdapter implements PuertoAlertas {

    private final JavaMailSender mailSender;

    AlertaCorreoAdapter(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }

    @Override
    public void notificarBajoStock(String sku, int stockDisponible) {
        SimpleMailMessage mensaje = new SimpleMailMessage();
        mensaje.setSubject("Stock crítico para " + sku);
        mensaje.setText("Disponible: " + stockDisponible + " unidades.");
        mensaje.setTo("compras@example.com");
        mailSender.send(mensaje);
    }
}

Cada adaptador depende del dominio porque implementa interfaces declaradas allí. El dominio, en cambio, nunca depende de JavaMailSender, interfaces JPA ni de anotaciones de Spring. Esto evidencia cómo la inversión de dependencias se respeta en la práctica.

6.4 Beneficios en testabilidad y mantenimiento

Separar el dominio de los detalles técnicos trae ventajas tangibles:

  • Pruebas unitarias más simples: Los casos de uso se testean con dobles de prueba que simulan los puertos de salida, sin necesidad de inicializar bases de datos ni servidores.
  • Pruebas de contrato: Cada adaptador puede validarse independientemente para garantizar que cumple el puerto. Esto evita sorpresas en la integración.
  • Menor costo de cambio: Cuando aparece un nuevo proveedor de correo o una tecnología de persistencia más conveniente, se reemplaza solo el adaptador. El dominio mantiene sus invariantes.
  • Documentación más clara: Los puertos actúan como catálogos de servicios disponibles, lo que acelera la incorporación de nuevos integrantes al equipo.

Al combinar el DIP con la estructura hexagonal, los sistemas evolucionan de forma sostenible: el dominio sigue siendo el punto de verdad, mientras que la infraestructura puede renovarse tantas veces como sea necesario sin afectar la experiencia del negocio ni los ciclos de prueba.