Если вы когда-нибудь сталкивались с ситуацией, когда внесение простых изменений в базу данных или пользовательский интерфейс заставляло вас переписывать весь код, то вы точно оцените элегантность гексагонального подхода. Эта архитектура разделяет приложение на изолированные части с чётко определёнными границами, что значительно упрощает тестирование, обновление и замену компонентов.
Гексагональная архитектура, также известная как "Ports and Adapters" (порты и адаптеры), была предложена Алистером Кокберном в 2005 году. Её главная идея — отделить ядро приложения от внешних сервисов через специальные интерфейсы. В результате бизнес-логика остаётся незатронутой внешними изменениями, будь то смена базы данных или переработка API. Что делает этот подход привлекательным для Java-разработчиков? Spring Boot с его гибкостью и мощной системой внедрения зависимостей идеально подходит для реализации гексагональной архитектуры. Фреймворк предоставляет множество готовых компонентов, которые легко интегрируются в отдельные адаптеры, при этом сохраняя ядро приложения чистым и независимым.
В традиционных многослойных архитектурах зависимости обычно направлены от верхних уровней к нижним. Например, слой представления зависит от слоя бизнес-логики, который, в свою очередь, зависит от слоя доступа к данным. Проблема возникает, когда нужно менять нижние уровни — это может повлечь за собой каскад изменений во всём приложении. Гексагональная архитектура переворачивает эту парадигму, позволяя внешним компонентам зависеть от ядра приложения через порты (интерфейсы), а не наоборот. Это значительно снижает сцепленность между компонентами системы и делает её более устойчивой к изменениям.
Многие разработчики долгое время боролись с проблемами тестирования, когда приходилось создавать сложные моки и заглушки для баз данных или других внешних систем. В гексагональной архитектуре эта проблема решается естественным образом — порты позволяют легко подменять реальные реализации на тестовые.
Что такое гексагональная архитектура
Гексагональная архитектура представляет собой подход к проектированию программного обеспечения, который фокусируется на изоляции бизнес-логики от внешних систем. Несмотря на название, в визуальном представлении форма шестиугольника не играет критической роли — ключевая идея заключается в концепции ядра, окружённого взаимодействующими компонентами. Автор концепции, доктор Алистер Кокберн, предложил эту модель в 2005 году как решение проблемы плотной связанности компонентов, которая характерна для многих приложений. По его замыслу, ядро приложения должно оставаться незатронутым при изменении любых внешних факторов, будь то база данных, пользовательский интерфейс или интеграция с другими сервисами.
За почти два десятилетия гексагональная архитектура зарекомендовала себя как надёжный подход в различных областях разработки, от корпоративных приложений до микросервисов. В основе лежит принцип "защиты домена" — прямой противоположности классической трёхуровневой архитектуре, где доменная логика часто оказывается зависимой от инфраструктурного уровня. Ключевой принцип гексагональной архитектуры — разделение на "внутреннюю" и "внешнюю" части приложения. Внутренняя часть содержит бизнес-логику и правила домена, а внешняя — все механизмы взаимодействия с окружающим миром. Эти части соединяются через "порты" (интерфейсы) и "адаптеры" (их конкретные реализации).
Представьте себе приложение для управления задачами. Его ядро содержит бизнес-логику: создание задач, назначение их исполнителям, установка сроков и приоритетов. Эта логика не должна зависеть от того, хранятся ли данные в PostgreSQL, MongoDB или вообще в файловой системе. Аналогично, она не должна меняться в зависимости от того, как пользователи взаимодействуют с системой — через REST API, веб-интерфейс или мобильное приложение. В традиционной архитектуре такое разделение часто нарушается. Вы, вероятно, видели ситуации, когда SQL-запросы вплетались в бизнес-логику или когда изменение формата ответа API требовало переработки внутренних моделей. Гексагональная архитектура помогает избежать подобных проблем.
В центре архитектуры находится доменный слой. Здесь располагаются:- Сущности (Entities): основные бизнес-объекты, такие как "Задача", "Пользователь", "Проект".
- Объекты-значения (Value Objects): неизменяемые объекты, представляющие концепции домена без идентичности.
- Сервисы домена (Domain Services): классы, реализующие бизнес-операции, которые не вписываются в рамки отдельных сущностей.
Вокруг доменного слоя строится слой приложения, который содержит:- Порты: интерфейсы, определяющие, как домен взаимодействует с внешним миром.
- Сервисы приложения: координаторы бизнес-процессов, использующие доменные сервисы.
Наконец, самый внешний слой — инфраструктурный, включающий:- Адаптеры "первичные": реализующие взаимодействие пользователей с системой (REST-контроллеры, GUI и т.д.).
- Адаптеры "вторичные": обеспечивающие взаимодействие с внешними сервисами (репозитории БД, клиенты API и т.д.).
Важно понимать, что направление зависимостей всегда идёт извне внутрь. Внешние компоненты зависят от портов, определённых в домене, а не наоборот. Это ключевое отличие от трёхслойной архитектуры, где слои образуют иерархическую структуру зависимостей.
Один из главных принципов гексагональной архитектуры — инверсия зависимостей. Вместо того чтобы доменный код зависел от инфраструктурного, создаются интерфейсы в домене, которые реализуются инфраструктурными компонентами. Например, домен определяет интерфейс TaskRepository с методами для работы с задачами, а инфраструктура предоставляет его реализацию для конкретной базы данных. Такой подход обеспечивает гибкость и возможность замены компонентов без изменения ядра приложения. Если вам нужно перейти с реляционной базы данных на NoSQL, достаточно создать новую реализацию репозитория, не затрагивая бизнес-логику.
Кроме того, гексагональная архитектура естественным образом поддерживает принципы разработки через тестирование (TDD). Поскольку домен взаимодействует с внешним миром через порты, можно легко создавать заглушки для тестирования. Например, для тестирования бизнес-логики можно использовать in-memory реализацию репозитория вместо работы с реальной базой данных.
Ещё одно преимущество — устойчивость к изменениям требований. Когда бизнес-логика изолирована, она развивается независимо от технических деталей реализации. Если завтра понадобится добавить новый способ взаимодействия с системой — например, GraphQL API или интеграцию с чат-ботом — это потребует только добавления нового адаптера, без изменения ядра.
В контексте Spring Boot гексагональная архитектура особенно хорошо работает благодаря встроенной поддержке внедрения зависимостей и аспектно-ориентированного программирования. Spring позволяет легко подключать различные реализации портов и управлять их жизненным циклом, что делает его идеальной платформой для этой архитектуры.
Project 'org.springframework.boot:spring-boot-starter-parent:2.3.2.RELEASE' not found <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
... Spring Boot VS Tomcat+Spring - что выбрать? Всем доброго дня!
Я наверное еще из старой школы - пилю мелкие проект на Spring + Tomcat...
Но хотелось бы чего-то нового )))
... Что такое Spring, Spring Boot? Здравствуйте.
Никогда не использовал Spring, Spring Boot.
Возник такой вопрос можно ли его использовать в IDE для java Se.
Или для этого... Spring Boot или Spring MVC? Добрый день форумчане. Прошу совета у опытных коллег знающих и работающих с фреймворком Spring.
Недавно решил сделать проект для портфолио: типовое...
Основные концепции
Порты и адаптеры — сердце гексагональной архитектуры
Разобравшись с общей структурой гексагональной архитектуры, давайте углубимся в её ключевые концепции. Центральное место здесь занимают порты и адаптеры — механизмы, которые обеспечивают взаимодействие между ядром приложения и внешним миром.
Порты — это, по сути, интерфейсы, определённые в доменном слое. Они выступают в роли контрактов, описывающих, как приложение может взаимодействовать с внешними системами или как внешние системы могут взаимодействовать с приложением. Порты бывают двух типов:
1. Первичные (входящие) порты — интерфейсы, определяющие функциональность, которую приложение предоставляет внешнему миру. Например, интерфейс TaskService , определяющий методы для создания, обновления и получения задач.
2. Вторичные (исходящие) порты — интерфейсы, определяющие функциональность, которую приложение ожидает от внешних систем. Примером может служить интерфейс TaskRepository для работы с хранилищем задач.
Адаптеры — это конкретные реализации портов, которые связывают приложение с внешними системами. Они также делятся на два типа:
1. Первичные адаптеры обрабатывают запросы от пользователей и вызывают соответствующие методы первичных портов. К ним относятся REST-контроллеры, GraphQL-резолверы, обработчики сообщений и т.д.
2. Вторичные адаптеры реализуют вторичные порты и обеспечивают взаимодействие с внешними сервисами, базами данных или API. Например, класс JpaTaskRepository может реализовывать интерфейс TaskRepository .
Давайте рассмотрим пример. Представим, что у нас есть сервис управления задачами. В доменном слое мы определяем сущность Task :
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| public class Task {
private TaskId id;
private String title;
private String description;
private TaskStatus status;
private UserId assigneeId;
// Конструкторы, геттеры и методы с бизнес-логикой
public void assignTo(UserId userId) {
this.assigneeId = userId;
// Дополнительная бизнес-логика при назначении задачи
}
public void complete() {
if (this.status != TaskStatus.IN_PROGRESS) {
throw new IllegalStateException("Только задачи в работе могут быть завершены");
}
this.status = TaskStatus.COMPLETED;
// Другие побочные эффекты при завершении задачи
}
} |
|
Первичный порт для работы с задачами может выглядеть так:
Java | 1
2
3
4
5
6
7
| public interface TaskService {
TaskId createTask(String title, String description);
Task getTask(TaskId id);
void assignTask(TaskId taskId, UserId assigneeId);
void completeTask(TaskId taskId);
List<Task> findTasksByAssignee(UserId assigneeId);
} |
|
А вторичный порт для хранения задач:
Java | 1
2
3
4
5
| public interface TaskRepository {
Task findById(TaskId id);
void save(Task task);
List<Task> findByAssigneeId(UserId assigneeId);
} |
|
Примером первичного адаптера может быть REST-контроллер:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| @RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
@Autowired
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping
public ResponseEntity<TaskDto> createTask(@RequestBody CreateTaskRequest request) {
TaskId taskId = taskService.createTask(request.getTitle(), request.getDescription());
Task task = taskService.getTask(taskId);
return ResponseEntity.status(HttpStatus.CREATED).body(TaskDto.fromDomain(task));
}
@PutMapping("/{taskId}/assign")
public ResponseEntity<Void> assignTask(@PathVariable String taskId, @RequestBody AssignTaskRequest request) {
taskService.assignTask(new TaskId(taskId), new UserId(request.getAssigneeId()));
return ResponseEntity.ok().build();
}
// Другие эндпоинты
} |
|
А вторичный адаптер — реализация репозитория с использованием JPA:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| @Repository
public class JpaTaskRepository implements TaskRepository {
private final TaskJpaRepository jpaRepository;
@Autowired
public JpaTaskRepository(TaskJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Task findById(TaskId id) {
return jpaRepository.findById(id.getValue())
.map(TaskMapper::toDomain)
.orElseThrow(() -> new TaskNotFoundException(id));
}
@Override
public void save(Task task) {
TaskEntity entity = TaskMapper.toEntity(task);
jpaRepository.save(entity);
}
@Override
public List<Task> findByAssigneeId(UserId assigneeId) {
return jpaRepository.findByAssigneeId(assigneeId.getValue()).stream()
.map(TaskMapper::toDomain)
.collect(Collectors.toList());
}
} |
|
В приведенном примере TaskService — первичный порт, TaskRepository — вторичный порт. TaskController — первичный адаптер, а JpaTaskRepository — вторичный адаптер.
Доменная логика и изоляция
Одна из главных целей гексагональной архитектуры — изоляция доменной логики от внешних зависимостей. Доменный слой содержит бизнес-правила и логику, которые не должны зависеть от деталей реализации инфраструктуры. В доменном слое мы работаем с чистыми объектами домена, а не с сущностями ORM или DTO. Это позволяет сосредоточиться на бизнес-правилах, а не на технических аспектах. Например, доменная модель Task содержит методы assignTo() и complete() , которые реализуют бизнес-логику назначения и завершения задачи.
Важная концепция при работе с доменным слоем — использование объектов-значений (Value Objects) для представления концепций, которые не имеют собственной идентичности. Например, TaskId и UserId в нашем примере могут быть представлены как объекты-значения.
Инверсия зависимостей в гексагональной архитектуре
Ключевой принцип гексагональной архитектуры — инверсия зависимостей. Согласно этому принципу, модули верхнего уровня не должны зависеть от модулей нижнего уровня, а оба типа модулей должны зависеть от абстракций.
В контексте гексагональной архитектуры это означает, что доменный слой определяет порты (абстракции), а внешние слои предоставляют их реализации (адаптеры). Таким образом, направление зависимостей всегда идёт от внешних слоев к внутренним, а не наоборот.
Сравнение с другими архитектурными паттернами
Гексагональная архитектура имеет много общего с другими популярными архитектурными подходами, но также обладает своими уникальными особенностями. Рассмотрим сравнение с несколькими распространёнными паттернами:
Гексагональная vs Трёхслойная архитектура. В трёхслойной архитектуре (презентация, бизнес-логика, доступ к данным) зависимости каскадно идут сверху вниз. Это часто приводит к ситуации, когда бизнес-логика зависит от инфраструктурных решений. В гексагональной архитектуре же инфраструктурные компоненты зависят от доменного слоя через порты, а не наоборот.
Гексагональная vs Clean Architecture. Эти подходы очень близки по философии и принципам. Роберт Мартин, автор Clean Architecture, даже ссылается на гексагональную архитектуру как на один из источников вдохновения. Основное различие в терминологии и деталях организации слоёв. В Clean Architecture больше внимания уделяется созданию "чистых" сущностей и случаев использования, тогда как гексагональная архитектура фокусируется на концепции портов и адаптеров.
Гексагональная vs Onion Architecture. Архитектура "луковицы" Джеффри Палермо также очень похожа на гексагональную. Она тоже помещает доменную модель в центр и строит остальные слои вокруг неё. Основное отличие в деталях организации слоёв и терминологии. Onion Architecture явно выделяет слой сервисов приложения, тогда как в гексагональной архитектуре всё, что не является внешним адаптером, относится к ядру приложения.
Взаимодействие с внешними системами через порты и адаптеры
Порты и адаптеры обеспечивают гибкий механизм взаимодействия ядра приложения с внешними системами. Рассмотрим типичные сценарии:
Реализация в Spring Boot
Spring Boot с его богатой экосистемой предоставляет идеальную платформу для воплощения гексагональной архитектуры. Благодаря мощному механизму внедрения зависимостей и гибким возможностям конфигурирования, Spring позволяет естественным образом разделить приложение на слои и обеспечить их слабую связанность.
Структура проекта
При реализации гексагональной архитектуры в Spring Boot важно правильно организовать структуру проекта, чтобы она отражала разделение на слои. Вот пример организации пакетов:
Code | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| com.example.application/
├── domain/ // Доменный слой (ядро приложения)
│ ├── model/ // Доменные сущности и объекты-значения
│ │ └── Todo.java
│ ├── port/ // Определения портов
│ │ ├── api/ // Первичные порты (API)
│ │ │ └── TodoService.java
│ │ └── spi/ // Вторичные порты (SPI)
│ │ └── TodoRepository.java
│ └── service/ // Реализации первичных портов
│ └── TodoServiceImpl.java
├── application/ // Прикладной слой (координация сценариев)
│ └── usecase/ // Сценарии использования
│ ├── CreateTodoUseCase.java
│ └── GetTodosUseCase.java
└── infrastructure/ // Инфраструктурный слой
├── adapter/ // Адаптеры
│ ├── input/ // Первичные адаптеры (входные)
│ │ └── rest/ // REST-контроллеры
│ │ └── TodoController.java
│ └── output/ // Вторичные адаптеры (выходные)
│ └── persistence/ // Реализации репозиториев
│ ├── entity/ // JPA-сущности
│ │ └── TodoEntity.java
│ ├── mapper/ // Маппинг между сущностями и доменом
│ │ └── TodoMapper.java
│ ├── repository/ // Spring Data репозитории
│ │ └── SpringDataTodoRepository.java
│ └── TodoRepositoryAdapter.java
└── config/ // Конфигурация Spring
└── BeanConfiguration.java |
|
Такая структура четко отражает слои гексагональной архитектуры:- Доменный слой содержит бизнес-логику и определения портов.
- Инфраструктурный слой включает реализации адаптеров.
- Прикладной слой координирует взаимодействие между ними.
Примеры кода портов и адаптеров
Рассмотрим пример реализации простого приложения для управления задачами (Todo List) с использованием гексагональной архитектуры в Spring Boot. Начнем с доменного слоя. Сначала определим доменную модель:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
| // domain/model/Todo.java
package com.example.application.domain.model;
import java.util.UUID;
public class Todo {
private final TodoId id;
private String title;
private String description;
private boolean completed;
public Todo(String title, String description) {
this.id = new TodoId(UUID.randomUUID());
this.title = title;
this.description = description;
this.completed = false;
}
// Конструктор для существующих Todo
public Todo(TodoId id, String title, String description, boolean completed) {
this.id = id;
this.title = title;
this.description = description;
this.completed = completed;
}
public void markAsCompleted() {
this.completed = true;
}
public void updateDetails(String title, String description) {
this.title = title;
this.description = description;
}
// Геттеры
public TodoId getId() {
return id;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public boolean isCompleted() {
return completed;
}
}
// domain/model/TodoId.java
package com.example.application.domain.model;
import java.util.Objects;
import java.util.UUID;
public class TodoId {
private final UUID id;
public TodoId(UUID id) {
this.id = id;
}
public UUID getValue() {
return id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TodoId todoId = (TodoId) o;
return Objects.equals(id, todoId.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return id.toString();
}
} |
|
Теперь определим порты. Начнем с первичного порта — сервиса API:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // domain/port/api/TodoService.java
package com.example.application.domain.port.api;
import com.example.application.domain.model.Todo;
import com.example.application.domain.model.TodoId;
import java.util.List;
import java.util.Optional;
public interface TodoService {
TodoId createTodo(String title, String description);
Optional<Todo> getTodoById(TodoId id);
List<Todo> getAllTodos();
void markTodoAsCompleted(TodoId id);
void updateTodo(TodoId id, String title, String description);
void deleteTodo(TodoId id);
} |
|
Затем вторичный порт — репозиторий:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // domain/port/spi/TodoRepository.java
package com.example.application.domain.port.spi;
import com.example.application.domain.model.Todo;
import com.example.application.domain.model.TodoId;
import java.util.List;
import java.util.Optional;
public interface TodoRepository {
Todo save(Todo todo);
Optional<Todo> findById(TodoId id);
List<Todo> findAll();
void deleteById(TodoId id);
} |
|
Далее реализуем сервис, который будет предоставлять основную бизнес-логику:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| // domain/service/TodoServiceImpl.java
package com.example.application.domain.service;
import com.example.application.domain.model.Todo;
import com.example.application.domain.model.TodoId;
import com.example.application.domain.port.api.TodoService;
import com.example.application.domain.port.spi.TodoRepository;
import java.util.List;
import java.util.Optional;
public class TodoServiceImpl implements TodoService {
private final TodoRepository todoRepository;
public TodoServiceImpl(TodoRepository todoRepository) {
this.todoRepository = todoRepository;
}
@Override
public TodoId createTodo(String title, String description) {
Todo todo = new Todo(title, description);
Todo savedTodo = todoRepository.save(todo);
return savedTodo.getId();
}
@Override
public Optional<Todo> getTodoById(TodoId id) {
return todoRepository.findById(id);
}
@Override
public List<Todo> getAllTodos() {
return todoRepository.findAll();
}
@Override
public void markTodoAsCompleted(TodoId id) {
todoRepository.findById(id).ifPresent(todo -> {
todo.markAsCompleted();
todoRepository.save(todo);
});
}
@Override
public void updateTodo(TodoId id, String title, String description) {
todoRepository.findById(id).ifPresent(todo -> {
todo.updateDetails(title, description);
todoRepository.save(todo);
});
}
@Override
public void deleteTodo(TodoId id) {
todoRepository.deleteById(id);
}
} |
|
Теперь перейдем к инфраструктурному слою. Сначала определим JPA-сущность и Spring Data репозиторий:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| // infrastructure/adapter/output/persistence/entity/TodoEntity.java
package com.example.application.infrastructure.adapter.output.persistence.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
@Entity
@Table(name = "todos")
public class TodoEntity {
@Id
private UUID id;
private String title;
private String description;
private boolean completed;
// Геттеры и сеттеры
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
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/adapter/output/persistence/repository/SpringDataTodoRepository.java
package com.example.application.infrastructure.adapter.output.persistence.repository;
import com.example.application.infrastructure.adapter.output.persistence.entity.TodoEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface SpringDataTodoRepository extends JpaRepository<TodoEntity, UUID> {
} |
|
Далее создадим маппер для преобразования между доменными объектами и сущностями JPA:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // infrastructure/adapter/output/persistence/mapper/TodoMapper.java
package com.example.application.infrastructure.adapter.output.persistence.mapper;
import com.example.application.domain.model.Todo;
import com.example.application.domain.model.TodoId;
import com.example.application.infrastructure.adapter.output.persistence.entity.TodoEntity;
public class TodoMapper {
public static TodoEntity toEntity(Todo domain) {
TodoEntity entity = new TodoEntity();
entity.setId(domain.getId().getValue());
entity.setTitle(domain.getTitle());
entity.setDescription(domain.getDescription());
entity.setCompleted(domain.isCompleted());
return entity;
}
public static Todo toDomain(TodoEntity entity) {
return new Todo(
new TodoId(entity.getId()),
entity.getTitle(),
entity.getDescription(),
entity.isCompleted()
);
}
} |
|
Теперь реализуем адаптер репозитория, который свяжет домен с инфраструктурой:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| // infrastructure/adapter/output/persistence/TodoRepositoryAdapter.java
package com.example.application.infrastructure.adapter.output.persistence;
import com.example.application.domain.model.Todo;
import com.example.application.domain.model.TodoId;
import com.example.application.domain.port.spi.TodoRepository;
import com.example.application.infrastructure.adapter.output.persistence.mapper.TodoMapper;
import com.example.application.infrastructure.adapter.output.persistence.repository.SpringDataTodoRepository;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Component
public class TodoRepositoryAdapter implements TodoRepository {
private final SpringDataTodoRepository repository;
public TodoRepositoryAdapter(SpringDataTodoRepository repository) {
this.repository = repository;
}
@Override
public Todo save(Todo todo) {
var entity = TodoMapper.toEntity(todo);
var savedEntity = repository.save(entity);
return TodoMapper.toDomain(savedEntity);
}
@Override
public Optional<Todo> findById(TodoId id) {
return repository.findById(id.getValue())
.map(TodoMapper::toDomain);
}
@Override
public List<Todo> findAll() {
return repository.findAll().stream()
.map(TodoMapper::toDomain)
.collect(Collectors.toList());
}
@Override
public void deleteById(TodoId id) {
repository.deleteById(id.getValue());
}
} |
|
Создадим REST-контроллер — первичный адаптер, который будет обрабатывать HTTP-запросы:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| // infrastructure/adapter/input/rest/TodoController.java
package com.example.application.infrastructure.adapter.input.rest;
import com.example.application.domain.model.Todo;
import com.example.application.domain.model.TodoId;
import com.example.application.domain.port.api.TodoService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/todos")
public class TodoController {
private final TodoService todoService;
public TodoController(TodoService todoService) {
this.todoService = todoService;
}
@PostMapping
public ResponseEntity<UUID> createTodo(@RequestBody CreateTodoRequest request) {
TodoId todoId = todoService.createTodo(request.title, request.description);
return new ResponseEntity<>(todoId.getValue(), HttpStatus.CREATED);
}
@GetMapping
public ResponseEntity<List<Todo>> getAllTodos() {
return ResponseEntity.ok(todoService.getAllTodos());
}
@GetMapping("/{id}")
public ResponseEntity<Todo> getTodoById(@PathVariable UUID id) {
return todoService.getTodoById(new TodoId(id))
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PutMapping("/{id}")
public ResponseEntity<Void> updateTodo(
@PathVariable UUID id,
@RequestBody UpdateTodoRequest request) {
todoService.updateTodo(new TodoId(id), request.title, request.description);
return ResponseEntity.noContent().build();
}
@PatchMapping("/{id}/complete")
public ResponseEntity<Void> markTodoAsCompleted(@PathVariable UUID id) {
todoService.markTodoAsCompleted(new TodoId(id));
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTodo(@PathVariable UUID id) {
todoService.deleteTodo(new TodoId(id));
return ResponseEntity.noContent().build();
}
// Вспомогательные классы для запросов
public static class CreateTodoRequest {
public String title;
public String description;
}
public static class UpdateTodoRequest {
public String title;
public String description;
}
} |
|
Конфигурация компонентов
Финальным шагом будет настройка конфигурации Spring для связывания всех компонентов:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // infrastructure/config/BeanConfiguration.java
package com.example.application.infrastructure.config;
import com.example.application.domain.port.api.TodoService;
import com.example.application.domain.port.spi.TodoRepository;
import com.example.application.domain.service.TodoServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BeanConfiguration {
@Bean
public TodoService todoService(TodoRepository todoRepository) {
return new TodoServiceImpl(todoRepository);
}
} |
|
Обратите внимание на ключевой момент: класс TodoServiceImpl из доменного слоя внедряется в контейнер Spring через конфигурацию, находящуюся в инфраструктурном слое. Это соответствует принципу инверсии зависимостей, так как инфраструктурный слой зависит от доменного, а не наоборот.
Интеграция с Spring Data
При разработке приложений с гексагональной архитектурой на Spring Boot, интеграция с системами хранения данных — одна из самых распространённых задач. Spring Data значительно упрощает этот процесс, обеспечивая высокоуровневые абстракции для работы с различными типами хранилищ.
Главное преимущество Spring Data при работе с гексагональной архитектурой — возможность скрыть детали реализации хранилища за интерфейсами. Вам достаточно определить Spring Data репозиторий в инфраструктурном слое, а затем использовать адаптер для преобразования вызовов между этим репозиторием и портом из доменного слоя.
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // Расширяем наш пример TodoRepositoryAdapter
@Component
public class TodoRepositoryAdapter implements TodoRepository {
// Здесь автоматически внедряется реализация SpringDataTodoRepository
private final SpringDataTodoRepository jpaRepository;
// В адаптере происходит конвертация между доменными и
// инфраструктурными типами данных
@Override
public List<Todo> findByCompletionStatus(boolean isCompleted) {
return jpaRepository.findByCompleted(isCompleted).stream()
.map(TodoMapper::toDomain)
.collect(Collectors.toList());
}
} |
|
Работа с асинхронными операциями
Асинхронная обработка имеет всё большее значение в современных приложениях. Spring Boot предоставляет удобные инструменты для работы с асинхронностью при создании систем на основе гексагональной архитектуры. Наиболее естественный подход — определить асинхронные методы в портах:
Java | 1
2
3
| public interface AsyncNotificationPort {
CompletableFuture<Void> sendTaskCompletionNotification(UUID taskId, String userEmail);
} |
|
И реализовать их в соответствующих адаптерах, используя, например, @Async из Spring:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
| @Component
public class EmailNotificationAdapter implements AsyncNotificationPort {
private final EmailService emailService;
@Override
@Async
public CompletableFuture<Void> sendTaskCompletionNotification(UUID taskId, String userEmail) {
return CompletableFuture.runAsync(() -> {
emailService.sendEmail(userEmail, "Task completed",
"Task with ID " + taskId + " has been marked as completed.");
});
}
} |
|
Комбинируя гексагональную архитектуру с асинхронностью, вы получаете приложения, которые не только хорошо структурированы, но и могут эффективно обрабатывать большие нагрузки, не блокируя основной поток выполнения.
Практический пример
Теперь, когда мы разобрались с теоретическими основами гексагональной архитектуры и её реализацией в Spring Boot, пришло время создать небольшое практическое приложение с нуля. Возьмем простую, но показательную задачу — систему управления библиотекой книг. Это позволит нам продемонстрировать применение гексагональной архитектуры на реальном примере. Наше приложение будет отвечать за основные операции: добавление новых книг, их поиск по различным критериям, выдачу читателям и возврат. Мы организуем его в соответствии с принципами гексагональной архитектуры, чётко разделяя доменную логику, порты, адаптеры и внешние интерфейсы.
Определение доменных моделей
Начнём с создания основных сущностей домена — моделей, которые отражают ключевые бизнес-концепции нашей библиотечной системы:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
| // domain/model/Book.java
package com.example.library.domain.model;
import java.util.UUID;
public class Book {
private BookId id;
private String title;
private String author;
private String isbn;
private BookStatus status;
// Конструктор для создания новой книги
public Book(String title, String author, String isbn) {
this.id = new BookId(UUID.randomUUID());
this.title = title;
this.author = author;
this.isbn = isbn;
this.status = BookStatus.AVAILABLE;
}
// Конструктор для восстановления существующей книги из хранилища
public Book(BookId id, String title, String author, String isbn, BookStatus status) {
this.id = id;
this.title = title;
this.author = author;
this.isbn = isbn;
this.status = status;
}
public void borrowBook() {
if (status != BookStatus.AVAILABLE) {
throw new IllegalStateException("Книга недоступна для выдачи");
}
status = BookStatus.BORROWED;
}
public void returnBook() {
if (status != BookStatus.BORROWED) {
throw new IllegalStateException("Книга не была выдана");
}
status = BookStatus.AVAILABLE;
}
// Геттеры и базовые методы
public BookId getId() {
return id;
}
public String getTitle() {
return title;
}
public String getAuthor() {
return author;
}
public String getIsbn() {
return isbn;
}
public BookStatus getStatus() {
return status;
}
}
// domain/model/BookId.java
package com.example.library.domain.model;
import java.util.Objects;
import java.util.UUID;
public class BookId {
private final UUID value;
public BookId(UUID value) {
this.value = value;
}
public UUID getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BookId bookId = (BookId) o;
return Objects.equals(value, bookId.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
// domain/model/BookStatus.java
package com.example.library.domain.model;
public enum BookStatus {
AVAILABLE,
BORROWED,
RESERVED,
LOST
} |
|
Обратите внимание, что все бизнес-правила инкапсулированы внутри модели Book . Например, книга может быть выдана только если она имеет статус AVAILABLE , и возвращена только если статус BORROWED . Это защищает нашу доменную модель от неверного использования и обеспечивает её целостность.
Определение портов
Теперь определим порты для нашего приложения. Начнем с первичного порта, который будет предоставлять API для работы с книгами:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // domain/port/api/BookService.java
package com.example.library.domain.port.api;
import com.example.library.domain.model.Book;
import com.example.library.domain.model.BookId;
import java.util.List;
import java.util.Optional;
public interface BookService {
BookId addBook(String title, String author, String isbn);
Optional<Book> findById(BookId id);
List<Book> findAll();
List<Book> findByAuthor(String author);
void borrowBook(BookId bookId);
void returnBook(BookId bookId);
} |
|
Затем определим вторичный порт для работы с хранилищем книг:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // domain/port/spi/BookRepository.java
package com.example.library.domain.port.spi;
import com.example.library.domain.model.Book;
import com.example.library.domain.model.BookId;
import java.util.List;
import java.util.Optional;
public interface BookRepository {
Book save(Book book);
Optional<Book> findById(BookId id);
List<Book> findAll();
List<Book> findByAuthor(String author);
} |
|
Реализация сервисного слоя
Далее реализуем бизнес-логику в сервисе:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| // domain/service/BookServiceImpl.java
package com.example.library.domain.service;
import com.example.library.domain.model.Book;
import com.example.library.domain.model.BookId;
import com.example.library.domain.port.api.BookService;
import com.example.library.domain.port.spi.BookRepository;
import java.util.List;
import java.util.Optional;
public class BookServiceImpl implements BookService {
private final BookRepository bookRepository;
public BookServiceImpl(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Override
public BookId addBook(String title, String author, String isbn) {
Book book = new Book(title, author, isbn);
Book savedBook = bookRepository.save(book);
return savedBook.getId();
}
@Override
public Optional<Book> findById(BookId id) {
return bookRepository.findById(id);
}
@Override
public List<Book> findAll() {
return bookRepository.findAll();
}
@Override
public List<Book> findByAuthor(String author) {
return bookRepository.findByAuthor(author);
}
@Override
public void borrowBook(BookId bookId) {
bookRepository.findById(bookId)
.ifPresent(book -> {
book.borrowBook();
bookRepository.save(book);
});
}
@Override
public void returnBook(BookId bookId) {
bookRepository.findById(bookId)
.ifPresent(book -> {
book.returnBook();
bookRepository.save(book);
});
}
} |
|
Создание адаптеров
Теперь реализуем адаптеры для взаимодействия с внешним миром. Начнем с персистентности:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
| // infrastructure/adapter/persistence/entity/BookEntity.java
package com.example.library.infrastructure.adapter.persistence.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Enumerated;
import jakarta.persistence.EnumType;
import java.util.UUID;
@Entity
@Table(name = "books")
public class BookEntity {
@Id
private UUID id;
private String title;
private String author;
private String isbn;
@Enumerated(EnumType.STRING)
private BookStatusEntity status;
// Геттеры и сеттеры
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public BookStatusEntity getStatus() {
return status;
}
public void setStatus(BookStatusEntity status) {
this.status = status;
}
}
// infrastructure/adapter/persistence/entity/BookStatusEntity.java
package com.example.library.infrastructure.adapter.persistence.entity;
public enum BookStatusEntity {
AVAILABLE,
BORROWED,
RESERVED,
LOST
} |
|
Далее создадим Spring Data репозиторий и маппер для конвертации между доменными и JPA-сущностями:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| // infrastructure/adapter/persistence/repository/SpringBookRepository.java
package com.example.library.infrastructure.adapter.persistence.repository;
import com.example.library.infrastructure.adapter.persistence.entity.BookEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface SpringBookRepository extends JpaRepository<BookEntity, UUID> {
List<BookEntity> findByAuthor(String author);
}
// infrastructure/adapter/persistence/mapper/BookMapper.java
package com.example.library.infrastructure.adapter.persistence.mapper;
import com.example.library.domain.model.Book;
import com.example.library.domain.model.BookId;
import com.example.library.domain.model.BookStatus;
import com.example.library.infrastructure.adapter.persistence.entity.BookEntity;
import com.example.library.infrastructure.adapter.persistence.entity.BookStatusEntity;
public class BookMapper {
public static BookEntity toEntity(Book domain) {
BookEntity entity = new BookEntity();
entity.setId(domain.getId().getValue());
entity.setTitle(domain.getTitle());
entity.setAuthor(domain.getAuthor());
entity.setIsbn(domain.getIsbn());
entity.setStatus(mapStatus(domain.getStatus()));
return entity;
}
public static Book toDomain(BookEntity entity) {
return new Book(
new BookId(entity.getId()),
entity.getTitle(),
entity.getAuthor(),
entity.getIsbn(),
mapStatus(entity.getStatus())
);
}
private static BookStatusEntity mapStatus(BookStatus status) {
return BookStatusEntity.valueOf(status.name());
}
private static BookStatus mapStatus(BookStatusEntity status) {
return BookStatus.valueOf(status.name());
}
} |
|
Теперь реализуем адаптер репозитория:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| // infrastructure/adapter/persistence/BookRepositoryAdapter.java
package com.example.library.infrastructure.adapter.persistence;
import com.example.library.domain.model.Book;
import com.example.library.domain.model.BookId;
import com.example.library.domain.port.spi.BookRepository;
import com.example.library.infrastructure.adapter.persistence.mapper.BookMapper;
import com.example.library.infrastructure.adapter.persistence.repository.SpringBookRepository;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Component
public class BookRepositoryAdapter implements BookRepository {
private final SpringBookRepository springRepository;
public BookRepositoryAdapter(SpringBookRepository springRepository) {
this.springRepository = springRepository;
}
@Override
public Book save(Book book) {
var entity = BookMapper.toEntity(book);
var savedEntity = springRepository.save(entity);
return BookMapper.toDomain(savedEntity);
}
@Override
public Optional<Book> findById(BookId id) {
return springRepository.findById(id.getValue())
.map(BookMapper::toDomain);
}
@Override
public List<Book> findAll() {
return springRepository.findAll().stream()
.map(BookMapper::toDomain)
.collect(Collectors.toList());
}
@Override
public List<Book> findByAuthor(String author) {
return springRepository.findByAuthor(author).stream()
.map(BookMapper::toDomain)
.collect(Collectors.toList());
}
} |
|
И наконец, создадим REST-контроллер для обработки HTTP-запросов:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| // infrastructure/adapter/web/BookController.java
package com.example.library.infrastructure.adapter.web;
import com.example.library.domain.model.Book;
import com.example.library.domain.model.BookId;
import com.example.library.domain.port.api.BookService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@PostMapping
public ResponseEntity<UUID> addBook(@RequestBody AddBookRequest request) {
BookId bookId = bookService.addBook(request.title, request.author, request.isbn);
return new ResponseEntity<>(bookId.getValue(), HttpStatus.CREATED);
}
@GetMapping
public ResponseEntity<List<Book>> getAllBooks() {
return ResponseEntity.ok(bookService.findAll());
}
@GetMapping("/{id}")
public ResponseEntity<Book> getBookById(@PathVariable UUID id) {
return bookService.findById(new BookId(id))
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/by-author/{author}")
public ResponseEntity<List<Book>> getBooksByAuthor(@PathVariable String author) {
return ResponseEntity.ok(bookService.findByAuthor(author));
}
@PostMapping("/{id}/borrow")
public ResponseEntity<Void> borrowBook(@PathVariable UUID id) {
bookService.borrowBook(new BookId(id));
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/return")
public ResponseEntity<Void> returnBook(@PathVariable UUID id) {
bookService.returnBook(new BookId(id));
return ResponseEntity.noContent().build();
}
// Вспомогательные классы для запросов
public static class AddBookRequest {
public String title;
public String author;
public String isbn;
}
} |
|
Конфигурация Spring
Соберем все компоненты вместе с помощью конфигурации Spring:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // infrastructure/config/BeanConfiguration.java
package com.example.library.infrastructure.config;
import com.example.library.domain.port.api.BookService;
import com.example.library.domain.port.spi.BookRepository;
import com.example.library.domain.service.BookServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BeanConfiguration {
@Bean
public BookService bookService(BookRepository bookRepository) {
return new BookServiceImpl(bookRepository);
}
} |
|
Тестирование приложения
Доменный слой нашего приложения легко тестировать благодаря его изолированности. Вот пример юнит-теста для сервиса:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| // test/java/com/example/library/domain/service/BookServiceImplTest.java
package com.example.library.domain.service;
import com.example.library.domain.model.Book;
import com.example.library.domain.model.BookId;
import com.example.library.domain.port.spi.BookRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class BookServiceImplTest {
@Mock
private BookRepository bookRepository;
private BookServiceImpl bookService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
bookService = new BookServiceImpl(bookRepository);
}
@Test
void shouldAddBook() {
// Arrange
String title = "Чистая архитектура";
String author = "Роберт Мартин";
String isbn = "9785446107728";
Book savedBook = new Book(title, author, isbn);
when(bookRepository.save(any(Book.class))).thenReturn(savedBook);
// Act
BookId bookId = bookService.addBook(title, author, isbn);
// Assert
assertNotNull(bookId);
verify(bookRepository).save(any(Book.class));
}
@Test
void shouldBorrowBook() {
// Arrange
BookId bookId = new BookId(UUID.randomUUID());
Book book = new Book("Гексагональная архитектура", "Алистер Кокберн", "12345");
when(bookRepository.findById(bookId)).thenReturn(Optional.of(book));
when(bookRepository.save(any(Book.class))).thenReturn(book);
// Act
bookService.borrowBook(bookId);
// Assert
verify(bookRepository).findById(bookId);
verify(bookRepository).save(any(Book.class));
}
} |
|
Гексагональная архитектура особенно удобна для создания интеграционных тестов. Вы можете легко заменить вторичные адаптеры заглушками для тестирования взаимодействия между различными слоями:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| @SpringBootTest
@AutoConfigureMockMvc
class BookControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private BookRepository bookRepository;
@Test
void shouldReturnBooksList() throws Exception {
// Создаем тестовые данные
List<Book> books = List.of(
new Book("Паттерны проектирования", "Банда четырех", "123456"),
new Book("Spring в действии", "Крейг Уоллс", "654321")
);
when(bookRepository.findAll()).thenReturn(books);
// Выполняем запрос и проверяем результат
mockMvc.perform(get("/api/books"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].title", is("Паттерны проектирования")))
.andExpect(jsonPath("$[1].title", is("Spring в действии")));
}
} |
|
Рефакторинг существующего приложения
Переделка монолитного приложения под гексагональную архитектуру требует пошагового подхода:
1. Идентификация доменных сущностей и их преобразование в чистые классы без зависимостей от фреймворков.
2. Создание портов для внешних взаимодействий.
3. Разработка адаптеров, реализующих эти порты.
4. Постепенная замена прямых зависимостей на взаимодействие через порты.
Главное правило — двигаться небольшими, тестируемыми шагами, регулярно проверяя работоспособность приложения.
Преимущества и ограничения
Гексагональная архитектура предлагает многочисленные преимущества для разработки корпоративных приложений, но также имеет свои ограничения. Понимание обеих сторон позволяет разработчикам принимать обоснованные решения о применимости этого подхода к конкретным проектам.
Когда использовать гексагональную архитектуру
Гексагональная архитектура особенно полезна в следующих случаях:
Сложные бизнес-правила. Когда доменная логика вашего приложения содержит множество сложных бизнес-правил и процессов, гексагональная архитектура помогает сохранить эту логику чистой и изолированной от технических деталей. Это обеспечивает лучшее понимание кода и снижает риск внесения ошибок при изменениях.
Длительная поддержка. Для долгоживущих проектов, которые будут развиваться и поддерживаться годами, гексагональная архитектура обеспечивает устойчивую основу. Изменения в технологиях неизбежны, и эта архитектура позволяет заменять внешние компоненты без изменения ядра системы.
Интеграция с множеством внешних систем. Если ваше приложение взаимодействует с различными базами данных, API, очередями сообщений или другими внешними сервисами, гексагональная архитектура облегчает эти интеграции, изолируя их за соответствующими портами и адаптерами.
Микросервисная архитектура. При разработке микросервисов каждый сервис должен иметь чётко определённые границы и интерфейсы. Гексагональная архитектура отлично подходит для определения этих границ и облегчает интеграцию между сервисами.
Разработка через тестирование (TDD). Если команда практикует TDD, гексагональная архитектура значительно упрощает процесс, позволяя легко подменять внешние зависимости на тестовые заглушки.
Типичные ошибки при внедрении
При внедрении гексагональной архитектуры разработчики часто сталкиваются с рядом типичных проблем:
Избыточная абстракция. Слишком много портов и адаптеров может привести к перегруженности кода и снижению его понимаемости. Важно находить баланс между гибкостью и простотой.
Нарушение принципа инверсии зависимостей. Нередко разработчики случайно допускают зависимость доменного слоя от инфраструктурного, например, используя JPA-аннотации в доменных сущностях. Это подрывает основной принцип изоляции домена.
Утечка доменной логики в адаптеры. Иногда бизнес-логика просачивается в адаптеры, особенно когда разработчики торопятся с реализацией и не уделяют должного внимания дизайну. Это делает логику приложения фрагментированной и менее поддерживаемой.
Чрезмерное использование DTO. Создание слишком многих слоёв преобразования данных между компонентами (DTO для каждого слоя) может привести к дублированию кода и усложнению потока данных.
Сравнение с монолитными архитектурами на реальных проектах
Опыт реальных проектов показывает как преимущества, так и недостатки гексагональной архитектуры по сравнению с традиционными монолитными подходами.
В одном из проектов миграция с трёхслойной архитектуры к гексагональной позволила снизить время регрессионного тестирования на 40% благодаря лучшей изоляции компонентов. Это привело к ускорению цикла разработки и более частым поставкам. В другом случае, при переходе от монолитного приложения к микросервисной архитектуре, использование гексагонального подхода упростило выделение отдельных функциональных модулей в самостоятельные сервисы. Каждый микросервис имел чётко определённые порты, что облегчало их интеграцию.
Однако не все истории успешны. Для небольших проектов с простой доменной логикой и ограниченным числом интеграций гексагональная архитектура иногда оказывалась избыточной. Накладные расходы на создание и поддержание дополнительных слоёв абстракции не всегда окупались выгодами от их использования. Кроме того, для команд без опыта работы с подобными архитектурами переход может быть сложным и требовать времени на обучение и адаптацию. В некоторых случаях это приводило к снижению продуктивности на начальных этапах проекта.
В целом, ключ к успешному применению гексагональной архитектуры — это прагматичный подход, учитывающий специфику конкретного проекта, опыт команды и бизнес-требования. Не существует универсального архитектурного решения, подходящего для всех ситуаций, и гексагональная архитектура не исключение.
Советы по масштабированию
Гексагональная архитектура особенно хорошо проявляет себя при масштабировании приложений. Давайте рассмотрим несколько стратегий, которые помогут эффективно развивать системы на её основе.
Работа с микросервисами
Гексагональная архитектура и микросервисы – практически идеальная пара. Изолированные доменные ядра легко превращаются в отдельные микросервисы с чётко определёнными границами. При этом каждый сервис может развиваться независимо, имея собственный жизненный цикл. Для эффективной работы с микросервисами, построенными на гексагональной архитектуре, полезно придерживаться следующих принципов:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Пример API-шлюза, маршрутизирующего запросы к микросервисам
@RestController
public class ApiGatewayController {
private final BookServiceClient bookServiceClient;
private final UserServiceClient userServiceClient;
@GetMapping("/api/books/{id}")
public ResponseEntity<BookDto> getBook(@PathVariable UUID id) {
return bookServiceClient.getBook(id);
}
@GetMapping("/api/users/{id}/borrowed-books")
public ResponseEntity<List<BookDto>> getUserBorrowedBooks(@PathVariable UUID id) {
UserDto user = userServiceClient.getUser(id).getBody();
List<BookDto> books = user.getBorrowedBookIds().stream()
.map(bookId -> bookServiceClient.getBook(bookId).getBody())
.collect(Collectors.toList());
return ResponseEntity.ok(books);
}
} |
|
Для защиты от каскадных отказов при взаимодействии микросервисов эффективно использовать шаблон Circuit Breaker:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| @Service
public class ResilientBookServiceClient {
private final BookServiceClient bookServiceClient;
@CircuitBreaker(name = "bookService", fallbackMethod = "getDefaultBook")
public ResponseEntity<BookDto> getBook(UUID id) {
return bookServiceClient.getBook(id);
}
private ResponseEntity<BookDto> getDefaultBook(UUID id, Exception e) {
// Возвращаем резервные данные при недоступности сервиса
return ResponseEntity.ok(new BookDto(id, "Недоступно", "Сервис временно недоступен", "N/A"));
}
} |
|
Производительность и оптимизация
В гексагональной архитектуре оптимизация производительности может быть сложнее, поскольку данные проходят через несколько слоёв абстракции. Однако существует ряд приёмов, позволяющих минимизировать накладные расходы:
1. Кэширование на уровне адаптеров. Реализуйте кэширование в адаптерах, особенно для операций чтения:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| @Component
public class CachingBookRepositoryAdapter implements BookRepository {
private final SpringBookRepository springRepository;
private final Cache<BookId, Book> bookCache;
@Override
public Optional<Book> findById(BookId id) {
return Optional.ofNullable(bookCache.get(id, key ->
springRepository.findById(key.getValue())
.map(BookMapper::toDomain)
.orElse(null)
));
}
} |
|
2. Асинхронная обработка. Используйте реактивное программирование для операций, не требующих немедленного ответа:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
| @Component
public class AsyncNotificationAdapter implements NotificationPort {
private final NotificationSender sender;
private final Executor executor;
@Override
public void sendBorrowNotification(BookId bookId, UserId userId) {
CompletableFuture.runAsync(() -> {
// Асинхронная отправка уведомления
sender.send(bookId, userId);
}, executor);
}
} |
|
3. Оптимизация запросов. В адаптерах баз данных реализуйте специализированные запросы для конкретных случаев использования:
Java | 1
2
3
4
5
6
| @Repository
public interface SpringBookRepository extends JpaRepository<BookEntity, UUID> {
// Специализированный запрос для оптимизации производительности
@Query("SELECT b FROM BookEntity b WHERE b.author = :author AND b.status = 'AVAILABLE' ORDER BY b.title")
List<BookEntity> findAvailableBooksByAuthor(String author);
} |
|
DDD и гексагональная архитектура: синергия подходов
Domain-Driven Design (DDD) и гексагональная архитектура отлично дополняют друг друга. DDD фокусируется на моделировании сложных доменов, а гексагональная архитектура обеспечивает техническую структуру для изоляции этих моделей. Применение агрегатов, ограниченных контекстов и событий домена из DDD повышает масштабируемость приложений на основе гексагональной архитектуры:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // Пример агрегата в DDD
public class Order {
private OrderId id;
private CustomerId customerId;
private List<OrderLine> orderLines;
private OrderStatus status;
public void addItem(Product product, int quantity) {
orderLines.add(new OrderLine(new OrderLineId(), product.getId(), quantity, product.getPrice()));
// Публикуем доменное событие
DomainEventPublisher.publish(new OrderItemAddedEvent(this.id, product.getId(), quantity));
}
public Money calculateTotal() {
return orderLines.stream()
.map(OrderLine::getLineTotal)
.reduce(Money.ZERO, Money::add);
}
} |
|
Управление транзакциями в распределенной среде
В распределенной среде с множеством сервисов управление транзакциями становится сложнее. Вместо традиционных ACID-транзакций часто приходится использовать паттерн Saga:
Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| @Service
public class OrderProcessingSaga {
private final OrderService orderService;
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final NotificationService notificationService;
@Transactional
public void processOrder(OrderId orderId) {
try {
// Шаг 1: Резервируем товары
inventoryService.reserveItems(orderId);
// Шаг 2: Обрабатываем платёж
paymentService.processPayment(orderId);
// Шаг 3: Подтверждаем заказ
orderService.confirmOrder(orderId);
// Шаг 4: Отправляем уведомление
notificationService.sendOrderConfirmation(orderId);
} catch (InventoryException e) {
// Компенсирующая транзакция не нужна, просто помечаем заказ как проваленный
orderService.markOrderAsFailed(orderId, "Недостаточно товаров на складе");
throw e;
} catch (PaymentException e) {
// Компенсирующая транзакция: отменяем резервацию товаров
inventoryService.releaseReservation(orderId);
orderService.markOrderAsFailed(orderId, "Ошибка оплаты");
throw e;
} catch (Exception e) {
// Отменяем все предыдущие шаги
paymentService.refund(orderId);
inventoryService.releaseReservation(orderId);
orderService.markOrderAsFailed(orderId, "Неизвестная ошибка");
throw e;
}
}
} |
|
При работе с гексагональной архитектурой важно помнить, что транзакции часто пересекают границы адаптеров. Чтобы справиться с этим, можно использовать шаблон Unit of Work или перенести управление транзакциями на уровень сценариев использования.
Гексагональная архитектура в сочетании с этими стратегиями масштабирования обеспечивает надёжный фундамент для растущих приложений, позволяя им развиваться вместе с бизнесом, не теряя при этом ясности и поддерживаемости.
Источники
1. Кокберн А., "Гексагональная архитектура", 2005.
2. Мартин Р., "Чистая архитектура: искусство разработки программного обеспечения", Питер, 2018.
3. Эванс Э., "Предметно-ориентированное проектирование (DDD). Структуризация сложных программных систем", Вильямс, 2018.
4. Палермо Д., "The Onion Architecture", 2008.
5. Верной В., "Реализация Domain-Driven Design", ДМК Пресс, 2016.
6. Ричардсон К., "Микросервисы. Паттерны разработки и рефакторинга", Питер, 2019.
7. Уоллс К., "Spring в действии", ДМК Пресс, 2019.
8. Фаулер М., "Шаблоны корпоративных приложений", Вильямс, 2016.
9. Ньюмен С., "Создание микросервисов", Питер, 2016.
10. Хорстманн К., "Java SE 8. Вводный курс", Вильямс, 2014.
Spring в Spring Boot context ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext(
"applicationContext.xml"
);
... Архитектура микросервисов на Spring Всем доброго дня!
Подскажите плз.
Может ли EurecaServer и SpringGetaway быть на одним сервере?
Или EurecaServer всегда должно быть... Spring Boot Всем привет, подскажите пожалуйста, создаю проект через Spring Initializer!
Создаю класс SpringBootWebApplication
@SpringBootApplication ... Spring Boot В чем может быть ошибка? При запуске приложения на браузере открывается страница с ошибкой Whitelabel Error Page
Контроллер
package pac; ... Spring Boot oauth2 Ответ корректный по oauth/token получаю. По bearer токену доступ имею. Роли тоже работают. Но... Spring Boot чат Вcем привет, в общем есть пример проекта Spring Boot чат , все работает, зашедшие пользователи видят друг друга и могут отсылать друг другу... Spring Boot mvc Нужно создать веб страницу с полем для ввода сообщений и кнопкой отправки, эти сообщения должны сохраняться в ArrayList, затем выводить на другой веб... Сайт на Spring (boot 2) Хочу начать делать сайт. Не что-то типа CRUD приложение, Registration приложение и так далее. А уже что-то крупное, сложное(возможно не сложное для... Spring boot multitenancy Здраствуйте, помогите пожалуйста внедрить этот проект в мой spring boot проект. Мне необхидимо реализовать мультитенаси архетектуру с возможностью... Spring Boot Thymeleaf есть всем пример
https://o7planning.org/ru/11545/spring-boot-and-thymeleaf-tutorial
А как в Idea правильно скомпилировать рабочий - war файл... Spring Boot Pom После клонирования своего проекта с гита на ноут, идея не видит пакет спринга.Вот ошибки:
Error:(3, 32) java: package org.springframework.boot... Spring Boot многопоточность Есть REST веб-сервис и клиент, написанные с помощью Spring, в клиенте реализуются запросы к сервису GET, POST, PUT, DELETE, все прекрасно работает,...
|