Arquitectura Hexagonal en Java con Spring Boot: API REST
Java Backend Clean Architecture

Arquitectura Hexagonal en Java con Spring Boot: API REST

Silverio Martínez García
Silverio Martínez García

En este artículo vamos a construir una pequeña aplicación de gestión de tareas utilizando Arquitectura Hexagonal (Ports & Adapters) con Java y Spring Boot.

La idea es aislar completamente la lógica de negocio (nuestro dominio) del framework y detalles de infraestructura (como el acceso a base de datos o los controladores REST). Esto nos permite tener un dominio limpio, fácil de testear y muy adaptable.

En este ejemplo, se han separado los casos de uso tanto en la capa de 'infraestructure' (entrada - RestControllers) como en la capa de 'application'. Se utiliza un servicio específico para cada caso de uso: uno dedicado a la creación de tareas (createTask) y otro encargado de la obtención de todas las tareas (getAllTasks). Esta separación permite una mayor modularidad y separación de responsabilidad en cada componente, facilitando el mantenimiento, la escalabilidad y las pruebas unitarias. Al aislar cada caso de uso, evitamos mezclas innecesarias de lógica y conseguimos una arquitectura más alineada con los principios de diseño limpio y la responsabilidad única.

En este ejemplo utilizaremos una B.D. en memoria H2.

En este ejemplo no vamos a utilizar DDD (Domain Driven Design) para no complicar demasiado y centrarnos directamente en las ventajas que aporta la Arquitectura Hexagonal. El uso de DDD en la Arquitectura Hexagonal es opcional y se recomienda su uso cuando el dominio tiene bastante lógica. Cuando las entidades de dominio actúan casi en su totalidad como DTOs (Data Transfer Objects) y no tienen lógica, se suele obviar el uso de DDD y se dice que el proyecto tiene un Dominio Anémico.

Separación entre el modelo de dominio y el modelo de base de datos:

Se debe mantener separado el modelo de dominio (la clase 'Task' que representa la lógica del negocio) del modelo de persistencia ('TaskEntity', usado por JPA). Aunque puedan parecer similares, tienen responsabilidades distintas: el dominio expresa reglas y conceptos del negocio, mientras que la definición de la entidad de persistencia depende de como se guardan los datos físicamente en B.D. Esto se ve más claramente si se utilizase DDD ya que la entidad de dominio 'Task' tendría lógica y se diferenciaría cláramente de la entidad de persistencia que carecería de dicha lógica.

Esta separación evita que detalles técnicos (como anotaciones JPA, IDs autogenerados o relaciones) contaminen la lógica del negocio. También nos permite modificar la persistencia sin tocar el núcleo de la aplicación, e incluso cambiar de base de datos o usar almacenamiento alternativo sin afectar al dominio. Esto mejora mucho la cohesión, claridad y mantenibilidad del proyecto a largo plazo.

Separación de SpringBoot en la infraestructura:

En la siguiente ejemplo se ha estructurado el código para que todas las anotaciones y configuraciones específicas de Spring Boot (como @Component, @RestController, @Bean, etc.) estén contenidas exclusivamente en la capa de infraestructura. Esto no es casual: forma parte del principio de independencia del dominio que promueve la 'Arquitectura Hexagonal'.

Gracias a esta separación, las capas de dominio y aplicación no dependen de ningún framework ni librería externa, lo que las hace más testeables, reutilizables y desacopladas. Podríamos usar exactamente el mismo núcleo de negocio en una app de consola, escritorio, REST o eventos, simplemente conectando adaptadores distintos. El resultado es un sistema más flexible y sostenible a largo plazo.

Estructura de paquetes:

src/
└── main/
    └── java/
        └── net/atopecode/todo/
            ├── domain/
            │   ├── model/
            │   │   └── Task.java
            │   └── port/
            │       ├── TaskRepository.java
            │       ├── CreateTaskService.java
            │       └── ListTasksService.java
            ├── application/
            │   ├── create/
            │   │   └── CreateTaskServiceImpl.java
            │   └── list/
            │       └── ListTasksServiceImpl.java
            ├── infrastructure/
            │   ├── input/
            │   │   └── rest/
            │   │       ├── create/
            │   │       │   └── CreateTaskController.java
            │   │       └── list/
            │   │           └── ListTasksController.java
            │   └── output/
            │       └── persistence/
            │           ├── TaskEntity.java
            │           ├── JpaTaskRepositoryAdapter.java
            │           └── SpringDataTaskRepository.java
            └── MainApplication.java

pom.xml

<modelVersion>4.0.0</modelVersion>

<groupId>net.atopecode</groupId>
<artifactId>todo-hexagonal</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>

<properties>
    <java.version>17</java.version>
    <spring-boot.version>3.2.0</spring-boot.version>
</properties>

<dependencies>
    <!-- Spring Boot Starter Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Boot Starter Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- H2 In-Memory Database -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </depend

application.properties

# Configuración de la base de datos H2 en memoria
spring.datasource.url=jdbc:h2:mem:todo-db;DB_CLOSE_DELAY=-1
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA / Hibernate
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

# Consola H2 habilitada (opcional para desarrollo)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
  1. Capa de Dominio:
domain/model/Task.java

package net.atopecode.todo.domain.model;

public class Task {
    private String id;
    private String description;
    private boolean completed;

    public Task(String id, String description, boolean completed) {
        this.id = id;
        this.description = description;
        this.completed = completed;
    }

    public String getId() { return id; }
    public String getDescription() { return description; }
    public boolean isCompleted() { return completed; }

    public void setCompleted(boolean completed) {
        this.completed = completed;
    }
}
domain/port/TaskRepository.java

package net.atopecode.todo.domain.port;

import net.atopecode.todo.domain.model.Task;
import java.util.List;

public interface TaskRepository {
    void save(Task task);
    List<Task> findAll();
}
domain/port/CreateTaskService.java

package net.atopecode.todo.domain.port;

public interface CreateTaskService {
    void createTask(String description);
}
domain/port/ListTasksService.java

package net.atopecode.todo.domain.port;

import net.atopecode.todo.domain.model.Task;
import java.util.List;

public interface ListTasksService {
    List<Task> getAllTasks();
}

2. Capa de Aplicación:

application/create/CreateTaskServiceImpl.java

package net.atopecode.todo.application.create;

import net.atopecode.todo.domain.model.Task;
import net.atopecode.todo.domain.port.CreateTaskService;
import net.atopecode.todo.domain.port.TaskRepository;

import java.util.UUID;

public class CreateTaskServiceImpl implements CreateTaskService {

    private final TaskRepository repository;

    public CreateTaskServiceImpl(TaskRepository repository) {
        this.repository = repository;
    }

    @Override
    public void createTask(String description) {
        Task task = new Task(UUID.randomUUID().toString(), description, false);
        repository.save(task);
    }
}
application/list/ListTasksServiceImpl.java

package net.atopecode.todo.application.list;

import net.atopecode.todo.domain.model.Task;
import net.atopecode.todo.domain.port.ListTasksService;
import net.atopecode.todo.domain.port.TaskRepository;

import java.util.List;

public class ListTasksServiceImpl implements ListTasksService {

    private final TaskRepository repository;

    public ListTasksServiceImpl(TaskRepository repository) {
        this.repository = repository;
    }

    @Override
    public List<Task> getAllTasks() {
        return repository.findAll();
    }
}

3. Capa de Infraestructura:

infrastructure/input/rest/create/CreateTaskController.java

package net.atopecode.todo.infrastructure.input.rest.create;

import net.atopecode.todo.domain.port.CreateTaskService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/tasks")
public class CreateTaskController {

    private final CreateTaskService createTaskService;

    public CreateTaskController(CreateTaskService createTaskService) {
        this.createTaskService = createTaskService;
    }

    @PostMapping
    public ResponseEntity<Void> create(@RequestBody CreateTaskDto dto) {
        createTaskService.createTask(dto.getDescription());
        return ResponseEntity.ok().build();
    }

    public static class CreateTaskDto {
        private String description;
        public String getDescription() { return description; }
        public void setDescription(String description) { this.description = description; }
    }
}
infrastructure/input/rest/list/ListTasksController.java

package net.atopecode.todo.infrastructure.input.rest.list;

import net.atopecode.todo.domain.model.Task;
import net.atopecode.todo.domain.port.ListTasksService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/tasks")
public class ListTasksController {

    private final ListTasksService listTasksService;

    public ListTasksController(ListTasksService listTasksService) {
        this.listTasksService = listTasksService;
    }

    @GetMapping
    public ResponseEntity<List<TaskDto>> list() {
        List<Task> tasks = listTasksService.getAllTasks();
        List<TaskDto> dtos = tasks.stream()
                .map(task -> new TaskDto(task.getId(), task.getDescription(), task.isCompleted()))
                .collect(Collectors.toList());
        return ResponseEntity.ok(dtos);
    }

    public static class TaskDto {
        private String id;
        private String description;
        private boolean completed;

        public TaskDto(String id, String description, boolean completed) {
            this.id = id;
            this.description = description;
            this.completed = completed;
        }

        public String getId() { return id; }
        public String getDescription() { return description; }
        public boolean isCompleted() { return completed; }
    }
}

infrastructure/output/persistence/TaskEntity.java

package net.atopecode.todo.infrastructure.output.persistence;

import jakarta.persistence.*;
import net.atopecode.todo.domain.model.Task;

@Entity
@Table(name = "tasks")
public class TaskEntity {

    @Id
    private String id;
    private String description;
    private boolean completed;

    public TaskEntity() {}

    public TaskEntity(Task task) {
        this.id = task.getId();
        this.description = task.getDescription();
        this.completed = task.isCompleted();
    }

    public Task toDomain() {
        return new Task(id, description, completed);
    }

    // Getters y setters
    public String getId() { return id; }
    public void setId(String id) { this.id = id; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public boolean isCompleted() { return completed; }
    public void setCompleted(boolean completed) { this.completed = completed; }
}
infrastructure/output/persistence/SpringDataTaskRepository.java

package net.atopecode.todo.infrastructure.output.persistence;

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

public interface SpringDataTaskRepository extends JpaRepository<TaskEntity, String> {
}
infrastructure/output/persistence/JpaTaskRepositoryAdapter.java

package net.atopecode.todo.infrastructure.output.persistence;

import net.atopecode.todo.domain.model.Task;
import net.atopecode.todo.domain.port.TaskRepository;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class JpaTaskRepositoryAdapter implements TaskRepository {

    private final SpringDataTaskRepository repository;

    public JpaTaskRepositoryAdapter(SpringDataTaskRepository repository) {
        this.repository = repository;
    }

    @Override
    public void save(Task task) {
        repository.save(new TaskEntity(task));
    }

    @Override
    public List<Task> findAll() {
        return repository.findAll()
                .stream()
                .map(TaskEntity::toDomain)
                .toList();
    }
}

4. Main

MainApplication.java

package net.atopecode.todo;

import net.atopecode.todo.application.create.CreateTaskServiceImpl;
import net.atopecode.todo.application.list.ListTasksServiceImpl;
import net.atopecode.todo.domain.port.CreateTaskService;
import net.atopecode.todo.domain.port.ListTasksService;
import net.atopecode.todo.domain.port.TaskRepository;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication(scanBasePackages = "net.atopecode.todo")
public class MainApplication {

    public static void main(String[] args) {
        SpringApplication.run(MainApplication.class, args);
    }

    @Bean
    public CreateTaskService createTaskService(TaskRepository taskRepository) {
        return new CreateTaskServiceImpl(taskRepository);
    }

    @Bean
    public ListTasksService listTasksService(TaskRepository taskRepository) {
        return new ListTasksServiceImpl(taskRepository);
    }
}

Conclusión

La Arquitectura Hexagonal sirve para organizar el código de forma limpia, mantenible y flexible. En este artículo hemos visto cómo separar el núcleo de negocio del resto de la infraestructura, permitiendo que nuestra lógica sea independiente de frameworks como SpringBoot o de detalles técnicos como la base de datos.

Esta forma de estructurar aplicaciones facilita la escalabilidad, el testing aislado y la evolución del sistema facilitando el refactoring y la posiblidad de añadir nuevas funcionalidades sin esfuerzo.
Aunque al principio pueda parecer que este enfoque añade complejidad, con el tiempo se convierte en un diseño más robusto, ideal para proyectos medianos y grandes.

Los controladores, repositorios o incluso Spring Boot son adaptadores, lo importante es que el 'dominio' (core) de tu aplicación sea simple, enfocado en el negocio y libre de acoplamientos innecesarios.

Artículos relacionados:

Puedes ver en el artículo anterior una implementación de Arquitectura Hexagonal en Java pero sin utilizar ningún Framework como SpringBoot: introduccion-a-la-arquitectura-hexagonal-en-java