Hexagonal architecture and how to implement it in Spring Boot

Hexagonal architecture, also known as ports & adapters (Alistair Cockburn, 2005), rests on a simple idea: the business core must depend on nothing external. Not the web framework, not the database, not the message broker. All of that is detail you plug in around the domain, not the other way round.

There’s a single dependency rule: everything points to the domain, the domain points to nothing. On the JVM with Spring Boot, the practical challenge is keeping Spring annotations and types out of the core.

The vocabulary in three words

A package layout that holds up over time

The important part is making the boundary visible in the tree itself:

com.example.account
├── domain
│   ├── model        // Account, AccountId, Money…  (plain POJO/POKO)
│   └── service      // business logic
├── application
│   ├── port.in      // CreateAccountUseCase (driving)
│   └── port.out     // LoadAccountPort, SaveAccountPort (driven)
└── adapter
    ├── in.web       // AccountController (Spring MVC)
    └── out.persistence  // AccountJpaAdapter, AccountEntity

Only the adapter packages know about Spring, JPA or Jackson. domain and application import nothing but the JDK and their own types.

Ports: plain interfaces

The inbound port expresses a use case, the outbound port a need of the domain.

// application/port/in
public interface CreateAccountUseCase {
    AccountId create(CreateAccountCommand command);
}

// application/port/out
public interface SaveAccountPort {
    void save(Account account);
}

public interface LoadAccountPort {
    Optional<Account> loadById(AccountId id);
}

No Spring annotations here, these are domain contracts.

The application service: where orchestration lives

The service implements the inbound port and leans on the outbound ports. It’s the only place on the application side where the Spring @Service annotation is tolerated, because the object has to be managed by the container. It still depends only on interfaces:

@Service
class CreateAccountService implements CreateAccountUseCase {

    private final SaveAccountPort saveAccount;
    private final LoadAccountPort loadAccount;

    CreateAccountService(SaveAccountPort saveAccount, LoadAccountPort loadAccount) {
        this.saveAccount = saveAccount;
        this.loadAccount = loadAccount;
    }

    @Override
    public AccountId create(CreateAccountCommand command) {
        Account account = Account.open(command.owner(), command.initialBalance());
        saveAccount.save(account);
        return account.id();
    }
}

If you want even @Service out of the application code, declare the bean in a @Configuration class at the adapter level. It’s more purist. In practice, an annotation as neutral as @Service stays an acceptable trade-off.

The inbound adapter: the REST controller

The controller holds no business logic. It turns HTTP into a command, calls the port, then turns the result into a response:

@RestController
@RequestMapping("/accounts")
class AccountController {

    private final CreateAccountUseCase createAccount;

    AccountController(CreateAccountUseCase createAccount) {
        this.createAccount = createAccount;
    }

    @PostMapping
    ResponseEntity<AccountResponse> create(@RequestBody @Valid CreateAccountRequest body) {
        AccountId id = createAccount.create(body.toCommand());
        return ResponseEntity.created(URI.create("/accounts/" + id.value()))
                             .body(new AccountResponse(id.value()));
    }
}

CreateAccountRequest and AccountResponse are DTOs that belong to the web adapter. They never leak into the domain, and that’s exactly what lets you evolve the REST API without touching the business code.

The outbound adapter: persistence

On the persistence side, you separate the JPA entity, which is only a mapping detail, from the domain entity, and implement the outbound ports:

@Component
class AccountPersistenceAdapter implements LoadAccountPort, SaveAccountPort {

    private final AccountRepository repository;   // Spring Data JPA
    private final AccountMapper mapper;

    AccountPersistenceAdapter(AccountRepository repository, AccountMapper mapper) {
        this.repository = repository;
        this.mapper = mapper;
    }

    @Override
    public Optional<Account> loadById(AccountId id) {
        return repository.findById(id.value()).map(mapper::toDomain);
    }

    @Override
    public void save(Account account) {
        repository.save(mapper.toEntity(account));
    }
}

AccountEntity (with its @Entity, @Id and so on) stays in the persistence package. The domain doesn’t even know JPA exists. The day you switch stores, you rewrite this adapter and nothing else.

Enforcing the boundary

The theory only holds if the dependency rule gets checked automatically. An ArchUnit test is enough to stop the domain from importing Spring again by accident:

@Test
void domain_does_not_depend_on_spring() {
    noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat().resideInAnyPackage(
            "org.springframework..", "jakarta.persistence..")
        .check(new ClassFileImporter().importPackages("com.example"));
}

Without that guardrail, the separation erodes within a few sprints. There’s always someone who slips in an annotation “just to move faster”.

What you get, and what it costs

The real upside: the business logic is testable without booting Spring (fast unit tests on the domain and services, with simple fakes in place of the ports), and technical choices become replaceable. The cost: more classes, DTO to domain to entity mapping, and a bit of ceremony.

The trade-off pays off on a rich domain that’s meant to last. It pays off far less on an anaemic CRUD, where the mapping layer just copies fields from one object to the next. As usual, the right dose of hexagonal depends on how much business complexity you actually have to protect.