Hexagonal architecture, proposed by Alistair Cockburn in 2005 and also known as Ports and Adapters, was born to solve a chronic problem of layered architecture: that the domain ended up depending on the infrastructure (the database, the web framework, external services). The central idea is brilliant in its simplicity: isolate the business logic in a core that knows nothing about the outside world, and connect it to that world through ports (interfaces) that plug into interchangeable adapters. This way, you can change the database, the framework, or the protocol without touching a single line of your domain. In this lesson we will understand the core, the primary and secondary ports, the adapters, and we will build a complete example in Java.

Contents

  1. The problem hexagonal solves
  2. The domain core
  3. Primary and secondary ports
  4. Adapters
  5. The dependency rule and inversion
  6. Complete Java example: port + adapter
  7. Advantages and drawbacks
  8. Common mistakes and tips
  9. Exercises
  10. Conclusion

  1. The problem hexagonal solves

In classic layered architecture, dependencies point downward: presentation → business → persistence. That means the business depends on persistence, and the database ends up conditioning the domain model.

Hexagonal architecture inverts the situation: it places the domain at the center and makes everything else depend on it, never the other way around.

graph TB
    subgraph Exterior
        UI[Web/UI Adapter]
        CLI[CLI/Test Adapter]
        DB[(Database Adapter)]
        EXT[External Service Adapter]
    end
    subgraph Hexagono[Domain Core]
        PP[Primary Ports]
        APP[Application logic + Domain]
        PS[Secondary Ports]
        PP --> APP --> PS
    end
    UI --> PP
    CLI --> PP
    PS --> DB
    PS --> EXT

The hexagon figure is only symbolic (it does not mean "six sides"): it represents that the core has multiple connection points with the outside world, all through ports.

  1. The domain core

The core contains the reason for the application to exist: the entities, the business rules, and the use cases. Its defining characteristic:

  • It imports nothing from the outside. Zero import of Spring, JPA, HTTP, JDBC. Only pure Java (or your language) and your own abstractions.
  • It is independent of frameworks. You could compile the domain without a database or a web server even existing.
  • It is 100% testable in isolation. It does not need to spin anything up.
// Pure domain: no infrastructure dependency
package com.fiatc.dominio;

public class Poliza {
    private final String cliente;
    private final String riesgo;
    private final double prima;

    public Poliza(String cliente, String riesgo, double prima) {
        if (prima <= 0) throw new IllegalArgumentException("Invalid premium");
        this.cliente = cliente;
        this.riesgo = riesgo;
        this.prima = prima;
    }
    public double prima() { return prima; }
    public String cliente() { return cliente; }
    public String riesgo() { return riesgo; }
}

Note that Poliza validates its own invariant (positive premium) in the constructor and knows nothing external. It is a pure domain object.

  1. Primary and secondary ports

A port is an interface that defines a boundary of the core. There are two types, depending on which direction the conversation flows:

Port type Also called Who uses it Who implements it Example
Primary (driving) Inbound / API The outside calls the core The core "Underwrite policy"
Secondary (driven) Outbound / SPI The core calls the outside An external adapter "Save policy", "Notify"
  • Primary port: describes what the application can do. The outside world (a controller, a test) invokes it to request a use case. It is implemented by the core.
  • Secondary port: describes what the application needs from the outside (persistence, notifications). The core declares it as an interface and it is implemented by an external adapter.
// PRIMARY port (inbound): what the application offers
package com.fiatc.dominio.puertos.entrada;

import com.fiatc.dominio.Poliza;

public interface ContratarPolizaUseCase {
    Poliza contratar(String cliente, String riesgo);
}

// SECONDARY port (outbound): what the application needs from the outside
package com.fiatc.dominio.puertos.salida;

import com.fiatc.dominio.Poliza;

public interface RepositorioPolizas {
    Poliza guardar(Poliza poliza);
}

The crucial point: both interfaces live inside the core. The core owns its ports. The outside adapts to them, not the other way around.

  1. Adapters

An adapter is the code that connects a port to a concrete technology. There are two families, symmetric to the ports:

  • Primary (driving) adapters: translate an external request (HTTP, CLI, queue event, test) into a call to the primary port. Example: a @RestController.
  • Secondary (driven) adapters: implement a secondary port using a concrete technology (JPA, JDBC, an HTTP client to an external service). Example: a JPA repository.
graph LR
    HTTP[HTTP request] --> AP[Primary adapter\nPolizaController]
    AP --> PP[Primary port\nContratarPolizaUseCase]
    PP --> SVC[Application service]
    SVC --> PS[Secondary port\nRepositorioPolizas]
    PS --> AS[Secondary adapter\nRepositorioPolizasJpa]
    AS --> BD[(DB)]

The great advantage: you can have several adapters for the same port. The secondary port RepositorioPolizas can have a JPA adapter in production and an in-memory adapter in tests, without the core being aware.

  1. The dependency rule and inversion

The golden rule of hexagonal: dependencies always point toward the core.

graph LR
    Adaptadores -->|depend on| Puertos
    Puertos -.live in.-> Nucleo[Core]
    Adaptadores -. never the other way .-x Nucleo

This is achieved with the Dependency Inversion Principle (the "D" in SOLID):

  • The core declares the RepositorioPolizas interface (what it needs).
  • The JPA adapter implements that interface (how it is fulfilled).
  • At runtime, the concrete adapter is injected into the core.

Result: the flow of control goes from the core to the adapter (the core calls guardar), but the code dependency goes from the adapter to the core (the adapter implements the core's interface). That inversion is what protects the domain.

  1. Complete Java example: port + adapter

Let's assemble the complete "underwrite policy" use case.

// 1) APPLICATION SERVICE: implements the primary port and uses the secondary one
package com.fiatc.dominio.aplicacion;

import com.fiatc.dominio.Poliza;
import com.fiatc.dominio.puertos.entrada.ContratarPolizaUseCase;
import com.fiatc.dominio.puertos.salida.RepositorioPolizas;

public class ContratarPolizaService implements ContratarPolizaUseCase {

    private final RepositorioPolizas repositorio; // secondary port (interface)

    public ContratarPolizaService(RepositorioPolizas repositorio) {
        this.repositorio = repositorio; // a concrete adapter is injected
    }

    @Override
    public Poliza contratar(String cliente, String riesgo) {
        double prima = calcularPrima(riesgo);          // business rule
        Poliza poliza = new Poliza(cliente, riesgo, prima);
        return repositorio.guardar(poliza);            // calls the outbound port
    }

    private double calcularPrima(String riesgo) {
        return "ALTO".equals(riesgo) ? 1200 : 600;     // pure domain logic
    }
}

Analysis:

  • ContratarPolizaService lives in the core and knows only interfaces (RepositorioPolizas), never technologies.
  • It receives the repository via the constructor (dependency injection): it does not create it, it is given to it.
// 2) PRIMARY ADAPTER: translates HTTP -> primary port
package com.fiatc.infraestructura.web;

import com.fiatc.dominio.puertos.entrada.ContratarPolizaUseCase;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/polizas")
class PolizaController {

    private final ContratarPolizaUseCase useCase; // depends on the port, not the service

    PolizaController(ContratarPolizaUseCase useCase) { this.useCase = useCase; }

    @PostMapping
    PolizaDto contratar(@RequestBody ContratarRequest req) {
        var poliza = useCase.contratar(req.cliente(), req.riesgo());
        return new PolizaDto(poliza.cliente(), poliza.prima()); // translates to a DTO
    }
}
// 3) SECONDARY ADAPTER: implements the outbound port with JPA
package com.fiatc.infraestructura.persistencia;

import com.fiatc.dominio.Poliza;
import com.fiatc.dominio.puertos.salida.RepositorioPolizas;
import org.springframework.stereotype.Repository;

@Repository
class RepositorioPolizasJpa implements RepositorioPolizas {

    private final JpaPolizaDao dao; // concrete technology encapsulated here

    RepositorioPolizasJpa(JpaPolizaDao dao) { this.dao = dao; }

    @Override
    public Poliza guardar(Poliza poliza) {
        var entidad = PolizaEntity.desde(poliza); // domain -> JPA entity mapping
        dao.save(entidad);
        return poliza;
    }
}

And the wiring (composition):

// 4) CONFIGURATION: here the adapters are "plugged into" the ports
@Configuration
class Configuracion {
    @Bean
    ContratarPolizaUseCase contratarPolizaUseCase(RepositorioPolizas repo) {
        return new ContratarPolizaService(repo); // injects the JPA adapter
    }
}

The key to the example: the com.fiatc.dominio package imports nothing from Spring or JPA. All the technology lives in com.fiatc.infraestructura. If tomorrow you swap JPA for MongoDB, you only write a new secondary adapter; the core is not touched.

And to test the use case, you do not even need a database:

// TEST: in-memory secondary adapter, no Spring or DB
class ContratarPolizaServiceTest {
    @Test
    void contrata_y_guarda() {
        var enMemoria = new RepositorioPolizas() {
            Poliza ultima;
            public Poliza guardar(Poliza p) { this.ultima = p; return p; }
        };
        var service = new ContratarPolizaService(enMemoria);

        var poliza = service.contratar("ACME", "ALTO");

        assertEquals(1200, poliza.prima());
    }
}

  1. Advantages and drawbacks

Advantages Drawbacks
The domain stays isolated and free of frameworks More interfaces and classes (more "ceremony")
Interchangeable adapters (JPA, Mongo, tests) Learning curve for the team
Core tests without infrastructure Can be over-engineering in trivial CRUDs
Clear dependency inversion Needs discipline not to "sneak" technology into the core
Makes migrating technologies easy without touching the business Additional domain↔entity mappings

  1. Common Mistakes and Tips

  • Sneaking framework dependencies into the core. If you see an import org.springframework or javax.persistence inside the domain, you have broken hexagonal.
  • Confusing primary and secondary ports. Primary = the outside calls you (implemented by the core). Secondary = you call the outside (implemented by an adapter).
  • Using the JPA entity as the domain object. It couples the domain to persistence. Keep domain and persistence entities separate, with mapping between them.
  • Applying it to everything. In a simple CRUD without rules, hexagonal can be over-engineering. Reserve the effort for rich domains.
  • Tip: use architecture tests (e.g., ArchUnit) to automatically verify that the dominio package imports nothing from infrastructure.

  1. Exercises

Exercise 1. Classify each port as primary or secondary: (a) EnviarNotificacion; (b) ConsultarSaldoUseCase; (c) RepositorioClientes; (d) PasarelaPago.

Exercise 2. Your core needs to send an email when a policy is underwritten. Design the port and name two possible adapters (one for production and one for test).

Exercise 3. Explain why ContratarPolizaService can be tested without spinning up the database.

Solutions

Solution 1. (a) Secondary (the core calls the outside to notify). (b) Primary (the outside invokes a use case). (c) Secondary (persistence). (d) Secondary (external payment service).

Solution 2. Secondary port:

public interface NotificadorEmail {
    void enviar(String destinatario, String asunto, String cuerpo);
}

Adapters: (1) production → NotificadorSmtp that sends over SMTP; (2) test → NotificadorEnMemoria that stores the emails in a list to verify them.

Solution 3. Because ContratarPolizaService depends on the interface RepositorioPolizas, not on its JPA implementation. In the test, an in-memory adapter is injected, so that all the business logic is exercised without touching infrastructure.

  1. Conclusion

Hexagonal architecture places the domain at the center and protects it from the outside through ports (interfaces owned by the core) and adapters (interchangeable implementations that depend on the core). Thanks to dependency inversion, the business is left free of frameworks, fully testable, and allows changing technologies without touching the logic. We have built a complete example of a primary port, a secondary port, and their adapters. These same ideas—domain at the center, dependencies pointing inward—are the foundation of the styles we will see next: Clean Architecture and Onion Architecture (Clean & Onion), which formalize the dependency rule in concentric circles and which we will compare with hexagonal.

Application Architecture Course

Module 1: Fundamentals of Application Architecture

Module 2: Design Principles and Tactics

Module 3: Architectural Styles and Patterns

Module 4: Distributed Architectures and Microservices

Module 5: Event-Driven Architectures and Messaging

Module 6: Domain-Driven Design (DDD)

Module 7: Data and Persistence

Module 8: Cloud Architecture and Deployment

Module 9: Quality, Security and Observability

Module 10: Evolution, Governance and Case Studies

© Copyright 2026. All rights reserved