Hexagonal архитектура в Java: чистая логика без боли

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему ваше приложение — это не его база данных

Представьте, что вы строите дом. Фундамент, стены, крыша — всё продумано. Но через год вы решаете поменять проводку. И тут выясняется, что провода вмурованы в бетон, а розетки приварены к арматуре. Чтобы заменить один выключатель, нужно снести полдома.

Примерно так выглядит классическое 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                

Блоги

Рекомендаций по книгам