If you have maintained a Spring Boot application long enough, you have probably seen business rules, controllers, and JPA entities all mixed up in the exact same place. It worksβ€”until the day you need to replace the database, write decent unit tests, or simply understand where business logic ends and the framework begins. That exact pain led me to study Hexagonal Architecture in depth, and in this article, I want to show how I applied these concepts in a real-world Spring Boot application.

What is Hexagonal Architecture?

Hexagonal Architecture (also known as Ports and Adapters) was proposed by Alistair Cockburn in the early 2000s. The core premise is straightforward: the application coreβ€”your business rulesβ€”must not depend on anything external. Not on the database, not on the web framework, and not on any third-party libraries.

To achieve this, the core defines ports (interfaces) that describe what it needs or what it provides. The outside world connects to these ports through adapters, which implement the concrete integration with a specific technology.

There are two types of ports:

The practical outcome is that the domain never imports anything from Spring, Hibernate, or any infrastructure layer. It only knows interfaces.

Why apply this in a Spring Boot project?

Spring Boot makes it deceptively easy to build everything tightly coupled to the framework: @Entity on the very same class representing business rules, @Service directly calling @Repository, everything coupled. It works great at first, but exacts a steep price as the system grows:

Hexagonal Architecture solves this by isolating the domain from any infrastructure decisions. The trade-off is more boilerplate and “plumbing” code (interfaces, mappers), which pays off primarily in complex domains and long-lived systems. For a simple CRUD application that isn’t expected to grow, it might introduce unnecessary complexity. I will address this further in the conclusion.

Folder Structure

This is the directory structure I use as a foundation:

src/main/java/com/exemplo/pedidos/
β”œβ”€β”€ domain/
β”‚   └── Pedido.java                    # domain entity (record), free of framework annotations
β”œβ”€β”€ application/
β”‚   β”œβ”€β”€ port/
β”‚   β”‚   β”œβ”€β”€ in/
β”‚   β”‚   β”‚   └── CriarPedidoUseCase.java   # input port
β”‚   β”‚   └── out/
β”‚   β”‚       └── PedidoRepositoryPort.java # output port
β”‚   └── service/
β”‚       └── CriarPedidoService.java    # implements the use case
└── adapter/
    β”œβ”€β”€ in/
    β”‚   └── web/
    β”‚       └── PedidoController.java  # input adapter (REST)
    └── out/
        └── persistence/
            β”œβ”€β”€ PedidoJpaEntity.java
            β”œβ”€β”€ PedidoJpaRepository.java
            └── PedidoRepositoryAdapter.java  # output adapter (JPA)

Notice that domain depends on nothing. application depends solely on domain. Only the adapter packages are aware of Spring, JPA, and HTTP.

Implementing in Practice

The examples below use Java 25 (LTS) and Spring Boot 4.1, built on top of Spring Framework 7, the modern baseline of the ecosystem. While maintaining a baseline of Java 17+, it takes full advantage of modern language features.

Let’s use a straightforward example: creating an order.

1. The domain, completely free of framework annotations:

package com.exemplo.pedidos.domain;

import java.math.BigDecimal;

public record Pedido(String id, String clienteId, BigDecimal valorTotal) {

    public Pedido {
        if (valorTotal.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Valor do pedido deve ser positivo");
        }
    }
}

Here I leverage a feature that feels right at home in modern Java: Pedido is a record. Validation still takes place before the object exists via the compact constructor, without inheriting mutability, without repetitive this.field = field assignments, and without handcrafted getters: id(), clienteId(), and valorTotal() are generated automatically. This is ideal for a domain that should inherently be immutable.

2. The input port, the use case contract:

package com.exemplo.pedidos.application.port.in;

import com.exemplo.pedidos.domain.Pedido;
import java.math.BigDecimal;

public interface CriarPedidoUseCase {
    Pedido criar(String clienteId, BigDecimal valorTotal);
}

3. The output port, what the domain requires from infrastructure:

package com.exemplo.pedidos.application.port.out;

import com.exemplo.pedidos.domain.Pedido;

public interface PedidoRepositoryPort {
    Pedido salvar(Pedido pedido);
}

4. The application service, implementing the use case:

package com.exemplo.pedidos.application.service;

import com.exemplo.pedidos.application.port.in.CriarPedidoUseCase;
import com.exemplo.pedidos.application.port.out.PedidoRepositoryPort;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.UUID;

@Service
public class CriarPedidoService implements CriarPedidoUseCase {

    private final PedidoRepositoryPort repository;

    public CriarPedidoService(PedidoRepositoryPort repository) {
        this.repository = repository;
    }

    @Override
    public Pedido criar(String clienteId, BigDecimal valorTotal) {
        Pedido pedido = new Pedido(UUID.randomUUID().toString(), clienteId, valorTotal);
        return repository.salvar(pedido);
    }
}

Notice that @Service lives here, not in the domain. This is the only class in the application layer aware of Spring, and solely to be managed as a bean.

5. The input adapter, the REST controller:

package com.exemplo.pedidos.adapter.in.web;

import com.exemplo.pedidos.application.port.in.CriarPedidoUseCase;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

@RestController
@RequestMapping("/pedidos")
public class PedidoController {

    private final CriarPedidoUseCase criarPedidoUseCase;

    public PedidoController(CriarPedidoUseCase criarPedidoUseCase) {
        this.criarPedidoUseCase = criarPedidoUseCase;
    }

    @PostMapping
    public PedidoResponse criar(@RequestBody CriarPedidoRequest request) {
        Pedido pedido = criarPedidoUseCase.criar(request.clienteId(), request.valorTotal());
        return new PedidoResponse(pedido.id(), pedido.clienteId(), pedido.valorTotal());
    }

    record CriarPedidoRequest(String clienteId, BigDecimal valorTotal) {}
    record PedidoResponse(String id, String clienteId, BigDecimal valorTotal) {}
}

The controller depends on the port (CriarPedidoUseCase), never on the concrete implementation. This allows swapping CriarPedidoService with any alternative implementation without ever touching the controller.

6. The output adapter / JPA persistence:

package com.exemplo.pedidos.adapter.out.persistence;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "pedidos")
public class PedidoJpaEntity {

    @Id
    private String id;
    private String clienteId;
    private BigDecimal valorTotal;

    protected PedidoJpaEntity() {}

    public PedidoJpaEntity(String id, String clienteId, BigDecimal valorTotal) {
        this.id = id;
        this.clienteId = clienteId;
        this.valorTotal = valorTotal;
    }

    // getters omitted for brevity
}
package com.exemplo.pedidos.adapter.out.persistence;

import org.springframework.data.jpa.repository.JpaRepository;

public interface PedidoJpaRepository extends JpaRepository<PedidoJpaEntity, String> {}
package com.exemplo.pedidos.adapter.out.persistence;

import com.exemplo.pedidos.application.port.out.PedidoRepositoryPort;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.stereotype.Component;

@Component
public class PedidoRepositoryAdapter implements PedidoRepositoryPort {

    private final PedidoJpaRepository jpaRepository;

    public PedidoRepositoryAdapter(PedidoJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public Pedido salvar(Pedido pedido) {
        PedidoJpaEntity entity = new PedidoJpaEntity(
                pedido.id(), pedido.clienteId(), pedido.valorTotal());
        jpaRepository.save(entity);
        return pedido;
    }
}

Notice that there are two representations of an order: Pedido (domain) and PedidoJpaEntity (persistence). It may feel redundant, but this separation is precisely what keeps Hibernate from leaking into your business rules. If tomorrow a @Column mapping must change due to a database-specific requirement, the domain remains completely unaware.

Testing the Architecture

The most tangible benefit appears during testing. CriarPedidoService can be tested without booting up any Spring context:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class CriarPedidoServiceTest {

    @Test
    void deveCriarPedidoComValorValido() {
        PedidoRepositoryPort repositoryMock = mock(PedidoRepositoryPort.class);
        when(repositoryMock.salvar(any())).thenAnswer(inv -> inv.getArgument(0));

        CriarPedidoService service = new CriarPedidoService(repositoryMock);
        Pedido pedido = service.criar("cliente-1", new BigDecimal("150.00"));

        assertEquals("cliente-1", pedido.clienteId());
    }
}

This test executes in millisecondsβ€”no database, no Spring Context, no heavy mocks. Meanwhile, the persistence adapter gets its own isolated integration test using @DataJpaTest, and the controller uses @WebMvcTest. Each layer is tested at the appropriate abstraction level.

Advantages and When It’s Worth It

Advantages:

When to think twice:

Ultimately, Hexagonal Architecture is not a silver bullet; it is a design strategy for domains that will evolve and endure for years. I initially adopted it prioritizing testability, but the greatest unexpected benefit was how unmistakably clear it madeβ€”both for myself and the teamβ€”where each piece of business logic truly belongs. If you are debating whether to adopt it in your project, ask yourself this first: is this domain expected to grow and evolve its infrastructure over time, or is it a short-lived utility? The answer will guide your decision far better than any architectural dogma.