8 min read
Abrís una clase de servicio de tu aplicación para entender cómo se cobra una factura.
Encontrás anotaciones de JPA, llamadas directas al SDK (Software Development Kit — Kit de Desarrollo de Software) de la pasarela de pagos, objetos de HTTP del framework mezclados con reglas de negocio, y un test que necesita una base de datos real y credenciales de la pasarela para correr.
La lógica que realmente importa, “una factura pagada no se puede pagar otra vez”, está enterrada en el medio de todo eso.
Cuando la lógica de negocio está acoplada a la infraestructura, cambiar la base de datos, el framework o el proveedor de pagos se convierte en un proyecto de meses, y probar una regla simple requiere levantar medio sistema.
La Arquitectura Hexagonal (también llamada Puertos y Adaptadores) ataca justo ese problema.
¿Qué es la Arquitectura Hexagonal?
Alistair Cockburn la propuso con una idea simple: el centro de tu aplicación es el dominio (las reglas de negocio), y todo lo demás vive afuera y se conecta a través de puertos.
Un puerto es una interfaz definida por el dominio que dice “esto necesito” o “esto ofrezco”.
Un adaptador es la implementación concreta de ese puerto para una tecnología específica.
- Puertos de entrada (driving): lo que el mundo puede pedirle a tu aplicación. Ejemplo:
PayInvoiceUseCase. - Puertos de salida (driven): lo que tu aplicación necesita del mundo. Ejemplo:
InvoiceRepository,PaymentGateway. - Adaptadores de entrada: un controlador REST (Representational State Transfer), un job programado, una consola.
- Adaptadores de salida: un repositorio JPA, un cliente de Stripe, un publicador de Kafka.
La regla de oro: las dependencias siempre apuntan hacia el dominio.
El dominio nunca importa nada de JPA, Spring o Stripe.
Si ya leíste los posts del Patrón Adaptador y de SOLID, esto te va a sonar familiar: es el principio de Inversión de Dependencias llevado a la arquitectura completa.
El diagrama

Guía paso a paso
Paso 1 — Modelar el dominio sin dependencias externas
Clases Java puras con las reglas de negocio. Ningún import de framework, base de datos o HTTP.
Paso 2 — Definir los puertos de salida como interfaces
Cada cosa que el dominio necesita del exterior (guardar facturas, cobrar) se expresa como una interfaz en el lenguaje del negocio, no de la tecnología: InvoiceRepository, no JpaInvoiceRepository.
Paso 3 — Implementar los casos de uso
Un servicio de aplicación que orquesta el dominio usando solo los puertos. Aquí vive el flujo “buscar factura, verificar, cobrar, guardar”.
Paso 4 — Escribir los adaptadores
Las implementaciones concretas: el repositorio JPA, el cliente de Stripe. Son los únicos que conocen la tecnología.
Paso 5 — Conectar todo en el arranque
En un solo lugar (en Spring, la configuración de beans) se crean los adaptadores y se inyectan en los casos de uso.
El código
Implementamos el caso de uso “pagar una factura” con dos adaptadores de entrada (REST y un job programado) que usan el mismo caso de uso, y mostramos cómo se prueba sin ninguna infraestructura real.
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
// ===================== DOMINIO (sin dependencias externas) =====================
@Getter
@AllArgsConstructor
class Invoice {
private final String id;
private final String customerId;
private final double amount;
private boolean paid;
public void ensurePayable() {
if (paid) {
throw new IllegalStateException("La factura " + id + " ya fue pagada");
}
}
public void markAsPaid() {
this.paid = true;
}
}
@Getter
@AllArgsConstructor
class PaymentReceipt {
private final String invoiceId;
private final String transactionId;
}
// ===================== PUERTOS =====================
// Puerto de entrada: lo que el mundo puede pedirle a la aplicación
interface PayInvoiceUseCase {
PaymentReceipt pay(String invoiceId);
}
// Puertos de salida: lo que la aplicación necesita del mundo
interface InvoiceRepository {
Optional<Invoice> findById(String id);
void save(Invoice invoice);
}
interface PaymentGateway {
String charge(String customerId, double amount);
}
// ===================== CASO DE USO (usa solo puertos) =====================
@AllArgsConstructor
class PayInvoiceService implements PayInvoiceUseCase {
private final InvoiceRepository invoices;
private final PaymentGateway gateway;
@Override
public PaymentReceipt pay(String invoiceId) {
Invoice invoice = invoices.findById(invoiceId)
.orElseThrow(() -> new IllegalArgumentException("Factura no encontrada: " + invoiceId));
invoice.ensurePayable(); // la regla de negocio se valida antes de cobrar
String transactionId = gateway.charge(invoice.getCustomerId(), invoice.getAmount());
invoice.markAsPaid();
invoices.save(invoice);
return new PaymentReceipt(invoiceId, transactionId);
}
}
// ===================== ADAPTADORES DE SALIDA =====================
// En producción esto sería un repositorio JPA
class InMemoryInvoiceRepository implements InvoiceRepository {
private final Map<String, Invoice> table = new HashMap<>();
@Override
public Optional<Invoice> findById(String id) {
return Optional.ofNullable(table.get(id));
}
@Override
public void save(Invoice invoice) {
table.put(invoice.getId(), invoice);
}
}
// En producción esto llamaría al SDK o a la API HTTP de Stripe
class StripePaymentAdapter implements PaymentGateway {
@Override
public String charge(String customerId, double amount) {
System.out.println(" [Stripe] Cobrando $" + amount + " al cliente " + customerId);
return "TXN-" + customerId + "-" + (int) amount;
}
}
// ===================== ADAPTADORES DE ENTRADA =====================
@AllArgsConstructor
class InvoiceRestController {
private final PayInvoiceUseCase useCase;
public String handlePost(String invoiceId) {
try {
PaymentReceipt receipt = useCase.pay(invoiceId);
return "200 OK " + receipt.getTransactionId();
} catch (IllegalArgumentException e) {
return "404 " + e.getMessage();
} catch (IllegalStateException e) {
return "409 " + e.getMessage();
}
}
}
@AllArgsConstructor
class OverdueInvoicesJob {
private final PayInvoiceUseCase useCase;
public void run(List<String> invoiceIds) {
for (String id : invoiceIds) {
PaymentReceipt receipt = useCase.pay(id);
System.out.println("Job: factura " + id + " cobrada → " + receipt.getTransactionId());
}
}
}
// ===================== MAIN (aquí se conecta todo) =====================
public class HexagonalArchitectureDemo {
public static void main(String[] args) {
InvoiceRepository repository = new InMemoryInvoiceRepository();
repository.save(new Invoice("INV-1", "CUST-1", 250.0, false));
repository.save(new Invoice("INV-2", "CUST-2", 90.0, false));
PayInvoiceUseCase useCase = new PayInvoiceService(repository, new StripePaymentAdapter());
System.out.println("=== Adaptador REST ===");
InvoiceRestController controller = new InvoiceRestController(useCase);
System.out.println("POST /invoices/INV-1/pay → " + controller.handlePost("INV-1"));
System.out.println("POST /invoices/INV-1/pay → " + controller.handlePost("INV-1"));
System.out.println("POST /invoices/INV-99/pay → " + controller.handlePost("INV-99"));
System.out.println("\n=== Adaptador de job programado (mismo caso de uso) ===");
new OverdueInvoicesJob(useCase).run(List.of("INV-2"));
System.out.println("\n=== Prueba sin infraestructura real (pasarela falsa) ===");
InvoiceRepository testRepository = new InMemoryInvoiceRepository();
testRepository.save(new Invoice("INV-T", "CUST-T", 10.0, false));
PaymentGateway fakeGateway = (customerId, amount) -> "FAKE-TXN";
InvoiceRestController testController =
new InvoiceRestController(new PayInvoiceService(testRepository, fakeGateway));
System.out.println("POST /invoices/INV-T/pay → " + testController.handlePost("INV-T"));
}
}
Output esperado:
=== Adaptador REST ===
[Stripe] Cobrando $250.0 al cliente CUST-1
POST /invoices/INV-1/pay → 200 OK TXN-CUST-1-250
POST /invoices/INV-1/pay → 409 La factura INV-1 ya fue pagada
POST /invoices/INV-99/pay → 404 Factura no encontrada: INV-99
=== Adaptador de job programado (mismo caso de uso) ===
[Stripe] Cobrando $90.0 al cliente CUST-2
Job: factura INV-2 cobrada → TXN-CUST-2-90
=== Prueba sin infraestructura real (pasarela falsa) ===
POST /invoices/INV-T/pay → 200 OK FAKE-TXN
Tres cosas para observar.
Primero, la segunda llamada a la misma factura se rechazó antes de cobrar, porque la regla vive en el dominio.
Segundo, el controlador REST y el job usan exactamente el mismo caso de uso, sin duplicar lógica.
Tercero, la prueba final no necesitó base de datos ni Stripe: bastó reemplazar un adaptador por una lambda.
Para tener en cuenta en producción
- Estructura de paquetes: una organización común es
domain(entidades y puertos),application(casos de uso) yadaptercon subpaquetesinyout. La separación física hace evidente cuando alguien rompe la regla de dependencias. - Hacé cumplir la regla con una herramienta: ArchUnit permite escribir un test que falla si una clase del dominio importa algo de
org.springframeworko de JPA. Una regla de arquitectura que no se verifica automáticamente se erosiona con el tiempo. - Spring encaja perfecto como pegamento: la inyección de dependencias de Spring conecta puertos con adaptadores. El framework vive en los bordes y en la configuración, no en el dominio.
- No lo apliques a un CRUD simple: si tu servicio solo guarda y lee filas sin reglas de negocio, tres capas de interfaces son innecesarias. La arquitectura hexagonal rinde cuando hay lógica de negocio que proteger y tecnologías que probablemente cambien.
Conclusión
La Arquitectura Hexagonal no te obliga a elegir una tecnología distinta, te permite postergar y cambiar esas decisiones.
La lógica que le da valor a tu negocio queda en el centro, probable en milisegundos y libre de frameworks, y la base de datos, el proveedor de pagos o el broker de mensajes pasan a ser detalles intercambiables en el borde.
Recordá suscribirte aquí para recibir los próximos posts directamente en tu correo.
Referencias:
Alistair Cockburn — Hexagonal Architecture: https://alistair.cockburn.us/hexagonal-architecture/
Clean Architecture — Robert C. Martin
Get Your Hands Dirty on Clean Architecture — Tom Hombergs