← dev-notes
ARQUITECTURA · PARTE 2 DE 2

Arquitectura Hexagonal
en la práctica

Estructura de carpetas, patrones de código, testing, comparación con otras arquitecturas y errores frecuentes

HEX-ARCH · P2
📁 Estructura de proyecto 🧪 Testing por capa ⚖️ Comparación de arquitecturas 🚫 Errores frecuentes
01 Estructura de proyecto — cómo organizar las carpetas
Capa de Dominio
domain/
📄 model/Order.java Entity
📄 model/Money.java Value Object
📄 event/OrderPlaced.java Domain Event
🔌 port/out/OrderRepository.java Output Port
🔌 port/out/EmailNotifier.java Output Port
⚠ Sin imports de Spring, JPA, ni ningún framework. Solo Java/Kotlin puro.
🎯
Capa de Aplicación
application/
🔌 port/in/CreateOrderUseCase.java Input Port
🔌 port/in/GetOrderQuery.java Input Port
⚙️ service/CreateOrderService.java Use Case impl
📦 dto/CreateOrderCommand.java Input DTO
📦 dto/OrderResponse.java Output DTO
Solo orquestación. Importa puertos del dominio, no implementaciones concretas.
🔧
Capa de Infraestructura
infrastructure/
🌐 adapter/in/RestOrderController.java Driving
📨 adapter/in/KafkaOrderConsumer.java Driving
🗄 adapter/out/JpaOrderRepository.java Driven
📧 adapter/out/SmtpEmailNotifier.java Driven
⚙️ config/BeanConfiguration.java DI wiring
Aquí viven Spring, JPA, Kafka, SMTP. El único lugar con dependencias externas.
02 Patrones de código — de afuera hacia adentro
domain/port/out/OrderRepository.java — Output Port (el dominio define la interfaz) JAVA
// El dominio define QUÉ necesita, no CÓMO se implementa
package com.myapp.domain.port.out;

public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(OrderId id);
    List<Order> findByCustomer(CustomerId customerId);
}

// Nótese: sin @Repository, sin JPA, sin SQL.
// Solo un contrato en Java puro.
infrastructure/adapter/out/JpaOrderRepository.java — Driven Adapter JAVA
// La infraestructura IMPLEMENTA el contrato del dominio
package com.myapp.infrastructure.adapter.out;

@Repository
public class JpaOrderRepository
    implements OrderRepository {   // ← implementa el port

    private final OrderJpaRepository jpaRepo;
    private final OrderMapper mapper;

    @Override
    public Order save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        return mapper.toDomain(jpaRepo.save(entity));
    }
}
application/service/CreateOrderService.java — Use Case (orquestador) JAVA
@UseCase  // annotation propia, no de Spring
public class CreateOrderService
    implements CreateOrderUseCase {

    // Inyecta INTERFACES (ports), nunca implementaciones
    private final OrderRepository orderRepository;
    private final PaymentGateway paymentGateway;
    private final EmailNotifier emailNotifier;

    @Override
    public OrderId execute(CreateOrderCommand cmd) {
        Order order = Order.create(cmd.customerId(), cmd.items());
        paymentGateway.charge(order.total());
        orderRepository.save(order);
        emailNotifier.sendConfirmation(order);
        return order.id();
    }
}
infrastructure/adapter/in/RestOrderController.java — Driving Adapter JAVA
@RestController
@RequestMapping("/api/orders")
public class RestOrderController {

    // Inyecta el USE CASE (input port), no el servicio directo
    private final CreateOrderUseCase createOrder;

    @PostMapping
    public ResponseEntity<OrderIdResponse>
    create(@RequestBody CreateOrderRequest req) {

        CreateOrderCommand cmd = mapper.toCommand(req);
        OrderId id = createOrder.execute(cmd);
        return ResponseEntity.ok(new OrderIdResponse(id));
    }
}
03 Testing — la ventaja principal de la arquitectura hexagonal
TEST DE DOMINIO
Unit Test — sin mocks externos
El dominio no tiene dependencias externas → tests rapidísimos, sin Spring context, sin BD, sin network. Son los más baratos y los que más deberías tener.
Prueba: Lógica de negocio pura. Que Order.addItem() incremente el total correctamente, que un Money negativo lance una excepción de dominio.
TEST DE APLICACIÓN
Unit Test — con fakes en memoria
El Use Case se prueba inyectando implementaciones en memoria de los ports (Fakes). Verifica la orquestación sin BD real ni servicios externos. Aún muy rápido.
Prueba: CreateOrderService con InMemoryOrderRepository y FakeEmailNotifier. Verifica que guarda la orden y notifica al cliente.
TEST DE ADAPTER DE SALIDA
Integration Test — con infraestructura real
Prueba que el Adapter persiste y recupera correctamente usando una BD real (Testcontainers). Aislado: solo prueba el Adapter, no la lógica de negocio.
Prueba: JpaOrderRepository con Testcontainers (Postgres real). Verifica que el mapper traduce correctamente entre domain model y JPA entity.
TEST DE ADAPTER DE ENTRADA
Integration Test — con framework real
Prueba que el controller HTTP traduce correctamente el request al Command del Use Case. Se mockea el Use Case para no depender de la lógica de negocio.
Prueba: RestOrderController con MockMvc. Mockea CreateOrderUseCase. Verifica status 201, headers y serialización del response.
04 Comparación con otras arquitecturas
Criterio
Layered (MVC clásico)
Clean Architecture
Hexagonal (Ports & Adapters)
Origen
~1970s. Presentación → Negocio → Datos.
Robert C. Martin (Uncle Bob), 2012.
Alistair Cockburn, 2005. Alias: P&A.
Acoplamiento al framework
Alto — el negocio suele depender de Spring annotations, JPA entities.
Bajo — el dominio es framework-agnostic por diseño.
Bajo — igual a Clean. El framework vive solo en adapters.
Testabilidad del dominio
Difícil — necesitas Spring context o mocks complejos.
Excelente — el core es POJO puro.
Excelente — misma garantía. Fakes en lugar de mocks.
Curva de aprendizaje
Baja — todo el mundo conoce MVC.
Alta — más capas y conceptos (Entities, Interactors, Presenters).
Media — menos capas que Clean, más estructura que Layered.
Boilerplate
Bajo — menos archivos por feature.
Alto — muchas interfaces y mappers por capa.
Moderado — más que MVC pero menos que Clean Architecture.
Intercambiabilidad
Difícil — cambiar BD o framework implica tocar el negocio.
Fácil — misma idea que hexagonal.
Fácil — cambias el Adapter sin tocar una línea del dominio.
Alineación con DDD
Parcial — se puede aplicar DDD pero el acoplamiento lo dificulta.
Alta — comparte los mismos principios de aislamiento del dominio.
Alta — combinación canónica: Hexagonal + DDD en el dominio.
05 Errores frecuentes — anti-patterns a evitar
Error
Qué pasa
Cómo evitarlo
🔴JPA Entity = Domain Entity
Usas la misma clase como entidad JPA (@Entity) y como objeto de dominio. El dominio queda atado a JPA y no puedes testearlo sin BD.
Separa los modelos. Crea un Mapper en el Adapter. La entity JPA vive en infraestructura, el domain object vive en domain. Más código, pero el dominio queda limpio.
🔴Importar Spring en el dominio
El dominio tiene @Service, @Transactional o @Autowired. Ahora no puedes usar ese dominio sin Spring — la testabilidad y portabilidad se pierden.
Usa annotations propias (@UseCase, @DomainService) sin dependencia de Spring, o ninguna annotation. Mueve @Transactional al application service.
🟡Anemic Domain Model
Las entidades son solo getters/setters (DTOs disfrazados). Toda la lógica de negocio vive en el Use Case o en un "servicio de dominio" gigante. El dominio no hace nada.
Pon la lógica donde pertenece: order.addItem(), order.cancel(), money.add(other). Si la entidad es solo datos, no estás haciendo DDD.
🟡Demasiados Ports
Un Port por cada método. El dominio tiene 40 interfaces con 1 método cada una. Overengineering puro — el código es innavegable y no aporta valor real.
Agrupa por cohesión. OrderRepository con save/find/delete es un buen Port. Solo extrae a Port lo que realmente tiene más de una implementación posible.
🟡Exponer domain objects al exterior
El controller retorna directamente la Entity de dominio. El cliente HTTP ve la estructura interna — cualquier refactor del dominio rompe la API pública.
Usa DTOs/Response objects en los Adapters. El Mapper convierte entre domain object y DTO. El dominio puede evolucionar sin cambiar los contratos externos.
🟡Saltar capas
El controller llama directo al Repository sin pasar por el Use Case. Se pierde la orquestación y la lógica de negocio se dispersa por los Adapters.
El controller siempre habla con el Use Case (Input Port). El Use Case habla con el Output Port. Nunca saltes capas, aunque la operación parezca "simple".
06 Checklist — ¿tu arquitectura hexagonal está bien implementada?
Valida estos puntos antes de hacer merge
El dominio no importa nada de Spring, JPA ni librerías externas. Solo clases Java/Kotlin puras y tus propias interfaces.
Los puertos de salida son interfaces definidas en el dominio. No hay ninguna referencia a tecnología concreta (JDBC, Redis, SMTP) en la capa de dominio.
El Use Case inyecta interfaces, no implementaciones. El constructor recibe OrderRepository, no JpaOrderRepository.
Puedes ejecutar los tests del dominio sin levantar Spring. Si necesitas @SpringBootTest para lógica de negocio, hay un problema.
Los Adapters tienen un Mapper explícito. La entity JPA y el domain object son clases distintas, conectadas por un mapper en la capa de infraestructura.
Los controllers no conocen las entidades de dominio. El response del controller es un DTO/record, nunca una Order del dominio.
Puedes intercambiar un Adapter sin tocar el dominio. ¿Puedes cambiar de PostgreSQL a MongoDB modificando solo el Adapter? Si no, la dependencia está al revés.
Las reglas de negocio están en el dominio, no en el servicio. order.confirm() lanza excepción si el estado no corresponde — esa validación no vive en el Use Case.