La tienda ofrece envío gratuito desde 50 euros. Para comprobar el límite de la promoción hay que arrancar un servidor, configurar una credencial y esperar al transportista. Son bastantes preparativos para descubrir si cincuenta es mayor o igual que cincuenta.

La arquitectura hexagonal, también llamada de puertos y adaptadores, permite ejecutar la lógica de la aplicación con distintas conexiones exteriores. Su idea central es distinguir lo que la aplicación hace de la tecnología que utilizamos para hablar con ella y con sus colaboradores.

Dentro y fuera, antes de dibujar seis lados

Dentro colocamos el caso de uso y su regla: si el subtotal alcanza 5.000 céntimos, el envío es gratuito; si no, necesitamos una tarifa. Fuera quedan la entrada web, la integración con el transportista y el código que conecta los objetos.

Un puerto expresa una conversación que la aplicación ofrece o necesita. Un adaptador conecta esa conversación con una tecnología concreta. En este ejemplo, QuoteShipping será el puerto de entrada; ShippingRates, el de salida.

Un puerto concreta las ideas del artículo de dependencias y contratos. Su firma importa, pero también sus garantías: aquí una tarifa se expresa en céntimos de euro y no puede ser negativa. Una interfaz que devuelve un número sin aclarar unidades deja abierta una conversación bastante peligrosa para una tienda.

Entrada y salida describen quién inicia la colaboración. La web inicia una operación de la aplicación; la aplicación pide una tarifa al exterior. No describen la dirección de cada dato: un puerto de salida también devuelve resultados.

El artículo original de Alistair Cockburn propone poder ejecutar y probar la aplicación mediante distintos adaptadores. El hexágono sirve para representar varias conversaciones alrededor del núcleo; no exige seis puertos ni seis capas.

El núcleo decide y expresa lo que necesita

Estos tipos son completos y compatibles con Java 17 o posterior. Los reuniremos con los adaptadores en HexagonalExample.java. Los importes son céntimos de euro; solo representamos un destino textual y una tarifa, sin impuestos, paquetes múltiples ni redondeo.

interface QuoteShipping {
    long quote(long subtotalInCents, String destination);
}

interface ShippingRates {
    long rateFor(String destination);
}

record ShippingQuoteService(ShippingRates rates) implements QuoteShipping {
    public long quote(long subtotalInCents, String destination) {
        if (subtotalInCents < 0 || destination == null || destination.isBlank()) {
            throw new IllegalArgumentException("Invalid shipping request.");
        }
        if (subtotalInCents >= 5_000) {
            return 0;
        }
        long rateInCents = rates.rateFor(destination);
        if (rateInCents < 0) {
            throw new IllegalStateException("Invalid shipping rate.");
        }
        return rateInCents;
    }
}

ShippingQuoteService sabe pedir una tarifa mediante ShippingRates. No conoce HTTP, nombres de proveedores ni formatos JSON. La regla se puede leer en orden: validar, comprobar gratuidad y, solo si hace falta, consultar la tarifa.

Con un subtotal de 4.999 céntimos necesitamos al transportista; con 5.000 devolvemos cero sin consultarlo. Una tarifa negativa sería una respuesta inválida, no un descuento. En una integración completa definiríamos también cómo comunicar indisponibilidad y qué restricciones tiene el destino.

Ambas interfaces pertenecen al vocabulario de la aplicación. La interfaz de salida no es una copia de toda la API del proveedor: expresa la capacidad pequeña que este caso de uso necesita.

Podríamos llamar al servicio directamente desde una prueba, sin declarar un puerto de entrada separado. Lo hacemos explícito para distinguir lo que la aplicación ofrece de la clase que lo implementa. Un puerto es ante todo un contrato de colaboración; no hay una obligación de envolver cada método en una interfaz Java.

Los adaptadores traducen las conversaciones

La web utilizaría el puerto de entrada. Aquí la representamos con una entrada que devuelve texto:

record ShippingController(QuoteShipping shipping) {
    String quote(long subtotalInCents, String destination) {
        return "shippingInCents=" + shipping.quote(subtotalInCents, destination);
    }
}

final class FixedShippingRates implements ShippingRates {
    public long rateFor(String destination) {
        return 490;
    }
}

ShippingController adapta una entrada al caso de uso. No contiene la promoción. FixedShippingRates es un adaptador de salida deliberadamente trivial: devuelve 490 céntimos para cualquier destino válido del ejemplo. Sustituye al transportista para que la frontera se vea sin escribir un cliente HTTP.

Un adaptador real convertiría el destino al formato del proveedor, realizaría la petición y traduciría la respuesta a céntimos. También distinguiría una tarifa ausente de un fallo de comunicación. Esos detalles tienen sus propias pruebas de integración; que la regla funcione con una tarifa fija no demuestra que entendamos la API del transportista.

El punto donde se conectan las piezas

El ensamblaje elige las implementaciones. A veces se llama raíz de composición al lugar desde el que se construye ese grafo de objetos:

final class HexagonalExample {
    public static void main(String[] args) {
        ShippingRates rates = new FixedShippingRates();
        QuoteShipping shipping = new ShippingQuoteService(rates);
        var controller = new ShippingController(shipping);

        System.out.println(controller.quote(4_999, "28001"));
        System.out.println(controller.quote(5_000, "28001"));
    }
}

Las salidas son shippingInCents=490 y shippingInCents=0. Se pueden obtener con javac --release 17 HexagonalExample.java y java HexagonalExample, reuniendo los tres bloques. No hace falta un contenedor de inyección de dependencias: los constructores ya muestran qué necesita cada objeto.

Para comprobar el límite sin transportista, basta sustituir ShippingRates por una función controlada. Este fragmento necesita los tipos anteriores y un método contenedor:

ShippingRates unavailable = destination -> {
    throw new IllegalStateException("Carrier unavailable.");
};
QuoteShipping shipping = new ShippingQuoteService(unavailable);
long freeShipping = shipping.quote(5_000, "28001");

freeShipping vale cero. Si se consultase el adaptador, la excepción haría fallar el caso. Con 4.999, en cambio, ese fallo se propaga: hemos elegido no inventar una tarifa cuando falta la información. Un producto podría decidir otra política, pero tendría que explicitarla.

Esta separación concreta la testabilidad del primer módulo. La prueba comprueba una decisión y una colaboración relevante: al alcanzar el umbral, el proveedor no participa. Después hacen falta pruebas del adaptador real para verificar formatos, unidades y errores de la integración.

Dos direcciones que conviene separar

Dependencias de código: ShippingController utiliza QuoteShipping; ShippingQuoteService implementa QuoteShipping y utiliza ShippingRates; FixedShippingRates implementa ShippingRates.

Las flechas continuas indican uso de tipos; las discontinuas, implementación de interfaces. En ejecución, el servicio llama al objeto FixedShippingRates. En el código, el servicio solo nombra ShippingRates, cuyo contrato vive en el núcleo. El adaptador conoce y cumple ese contrato: esa es la inversión de la dependencia.

Es el principio de inversión de dependencias de SOLID aplicado a una frontera de la aplicación. La inyección por constructor proporciona el colaborador concreto; la inversión consiste en que el contrato que conoce el servicio no depende del proveedor. Podemos usar una sin conseguir necesariamente la otra.

Si ShippingRates devolviera un objeto del SDK del transportista, el detalle externo se filtraría hacia dentro. Tener una interfaz seguiría sin proteger el límite. También lo rompería una regla de gratuidad dentro de ShippingController, porque otras entradas podrían saltársela.

La arquitectura puede coexistir con capas y cortes verticales. Podemos agrupar por envíos y distinguir responsabilidades dentro de ese módulo. Tampoco decide si desplegamos un monolito o varios servicios; describe relaciones de código y conexiones exteriores.

La propiedad de los contratos permite compararla con Clean Architecture: el caso de uso conoce una necesidad del interior y el adaptador externo la cumple. Cambiar el dibujo de un hexágono a círculos no sustituye la revisión de esas relaciones.

Cuándo merece la pena

Los puertos aportan valor cuando aíslan una colaboración con significado: pagos, persistencia, reloj o transportista. No hay que crear una interfaz para cada suma ni envolver todo objeto en otro idéntico. Cada frontera exige nombres, traducciones y pruebas que deben compensar su coste.

La comprobación más clara sigue siendo pequeña: ejecutar la regla con un adaptador controlado y conectar después el real sin cambiar el caso de uso. Si para sustituir el transportista tienes que editar la promoción de envío gratuito, alguna conversación todavía está mezclando negocio y tecnología.