Введение: почему ваше приложение — это не его база данных
Представьте, что вы строите дом. Фундамент, стены, крыша — всё продумано. Но через год вы решаете поменять проводку. И тут выясняется, что провода вмурованы в бетон, а розетки приварены к арматуре. Чтобы заменить один выключатель, нужно снести полдома.
Примерно так выглядит классическое Java-приложение, где бизнес-логика плотно переплетена с фреймворком, базой данных или REST-контроллером. Вы хотите перейти с PostgreSQL на MongoDB? Переписать контроллеры с Spring MVC на JAX-RS? Добро пожаловать в ад рефакторинга.
Выход — Hexagonal архитектура (она же «Порты и Адаптеры», она же «Clean Architecture» в интерпретации Алистера Коберна). Это не серебряная пуля, но способ сделать ваше приложение гибким, тестируемым и независимым от внешнего мира.
Сегодня мы разберём Hexagonal архитектуру на Java с реальными примерами кода, без воды и абстрактных рассуждений. Поехали.
Что такое Hexagonal архитектура (Порты и Адаптеры)?
Суть проста: ваше приложение — это ядро (core), которое содержит бизнес-логику. Вокруг ядра — «шестиугольник» (на самом деле произвольное количество граней), через который внешний мир общается с ядром.
У каждой грани — свой порт (интерфейс). А адаптер — это реализация порта для конкретной технологии. Например:
- Порт «UserRepository» — адаптер «JpaUserRepository» или «MongoUserRepository»
- Порт «SendNotification» — адаптер «EmailSender» или «SmsSender»
Главное правило: ядро ничего не знает о внешних библиотеках, базах данных, веб-фреймворках. Оно знает только свои порты (интерфейсы). Это даёт несколько преимуществ:
- Независимость от инфраструктуры — меняете БД или фреймворк, не трогая логику.
- Тестируемость — бизнес-логику можно тестировать без поднятия Spring контекста или базы.
- Читаемость — код ядра рассказывает историю домена, а не технические детали.
Структура проекта: как организовать код
Не существует единственно верной структуры пакетов, но я предлагаю проверенный вариант на основе DDD (Domain-Driven Design) и принципов Hexagonal:
com.example.app
├── domain
│ ├── model // Сущности, Value Objects
│ ├── port // Интерфейсы портов (inbound/outbound)
│ └── service // Бизнес-сервисы (use cases)
├── adapter
│ ├── inbound
│ │ ├── rest // REST-контроллеры (адаптеры входящие)
│ │ └── messaging // Kafka/RabbitMQ слушатели
│ └── outbound
│ ├── persistence // JPA, JDBC, MongoDB репозитории
│ └── client // HTTP-клиенты, внешние API
└── config // Конфигурация Spring (если используете)
Обратите внимание: domain не импортирует ничего из adapter. Это критично. Если вы увидели @Entity или @RestController в domain — значит, архитектура сломана.
Пример: система управления заказами
Давайте спроектируем простую систему: пользователь может создать заказ, а система проверяет наличие товара на складе и отправляет уведомление.
Шаг 1. Модель домена (domain/model)
// Order.java
public class Order {
private final OrderId id;
private final List<OrderItem> items;
private OrderStatus status;
public Order(List<OrderItem> items) {
this.id = new OrderId(UUID.randomUUID().toString());
this.items = Collections.unmodifiableList(items);
this.status = OrderStatus.PENDING;
}
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Only PENDING orders can be confirmed");
}
this.status = OrderStatus.CONFIRMED;
}
// getters...
}
Заметьте: ни одной аннотации Spring, JPA или Jackson. Чистый Java-объект с бизнес-правилами.
Шаг 2. Порты (domain/port)
Входящий порт (inbound port) — это контракт для use case. Обычно это интерфейс сервиса, который вызывается из адаптера (например, REST-контроллера).
// OrderUseCase.java (inbound port)
public interface OrderUseCase {
Order createOrder(List<OrderItem> items);
void confirmOrder(String orderId);
}
Исходящий порт (outbound port) — это контракт для взаимодействия с внешним миром (БД, API, очереди).
// OrderRepository.java (outbound port)
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String orderId);
}
// InventoryServiceClient.java (outbound port)
public interface InventoryServiceClient {
boolean checkAvailability(String productId, int quantity);
}
Шаг 3. Бизнес-сервис (domain/service)
Реализуем use case. Сервис использует только порты, ничего не знает о конкретных адаптерах.
// OrderService.java
public class OrderService implements OrderUseCase {
private final OrderRepository orderRepository;
private final InventoryServiceClient inventoryClient;
// Конструктор — внедрение зависимостей через интерфейсы (DI контейнер сделает остальное)
public OrderService(OrderRepository orderRepository,
InventoryServiceClient inventoryClient) {
this.orderRepository = orderRepository;
this.inventoryClient = inventoryClient;
}
@Override
public Order createOrder(List<OrderItem> items) {
// Проверяем наличие каждого товара
for (OrderItem item : items) {
if (!inventoryClient.checkAvailability(item.getProductId(), item.getQuantity())) {
throw new IllegalArgumentException("Product not available: " + item.getProductId());
}
}
Order order = new Order(items);
orderRepository.save(order);
return order;
}
@Override
public void confirmOrder(String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
order.confirm(); // бизнес-правило внутри модели
orderRepository.save(order);
}
}
Вся логика здесь — чисто доменная. Нет SQL, нет HTTP-запросов, нет JSON.
Шаг 4. Адаптеры (adapter)
Теперь реализуем порты с помощью конкретных технологий.
REST-адаптер (входящий):
// OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderUseCase orderUseCase;
public OrderController(OrderUseCase orderUseCase) {
this.orderUseCase = orderUseCase;
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
List<OrderItem> items = request.getItems().stream()
.map(i