Перейти к содержанию

Hexagonal

Структура пакетов

Базовый каркас модуля

api.http / application / domain / infrastructure не обязательный каркас ни для DDD, ни для гексагональной архитектуры. Это удобная современная договорённость о пакетах.

Исторически ты близок к сути, но связь немного другая: у Эрика Эванса в DDD описана слоистая схема User Interface → Application → Domain → Infrastructure. Гексагональная архитектура Кокберна описывает границу «внутри ↔ снаружи» через порты и адаптеры и не предписывает такие имена папок. Более того, Эванс прямо рассматривает гексагональную архитектуру как другой способ изолировать доменную модель.

api.http — осмысленное имя для HTTP-входа в модуль, но это уже выбранная тобой Java-конвенция, а не термин, обязательный по DDD.

  • https://www.domainlanguage.com/wp-content/uploads/2016/05/DDD_Reference_2015-03.pdf
  • https://alistair.cockburn.us/hexagonal-architecture
module
├── api
│   └── http
│       ├── Controller.java
│       └── contract
│           ├── DTORequest.java
│           └── DTOResponse.java
├── application
├── domain
└── infrastructure

  1. Классический слоистый DDD

presentation — название роли слоя. Если входов больше одного, там могут появиться http, messaging, cli. Это ближе всего к исходной схеме DDD, хотя Эванс задаёт ответственности слоёв, а не буквальные имена Java-пакетов. DDD Reference.

catalog
├── presentation
│   └── http
├── application
├── domain
└── infrastructure
  1. API как внешняя поверхность

Этот вариант явно показывает, как в модуль входят разные потребители. Но у слова api появляется риск двусмысленности: HTTP API, опубликованные события и Java API для соседних модулей имеют разные контракты. Поэтому подпакеты здесь важны.

catalog
├── api
│   ├── http
│   ├── events
│   └── module
├── application
├── domain
└── infrastructure
  1. Сначала бизнес-возможность

Подходит, когда catalog уже крупный, а tariff и pricing — устойчивые части его языка. Не стоит автоматически делать такой подпакет для каждой сущности: иначе внутри одного bounded context возникнут искусственные границы.

catalog
├── tariff
│   ├── api
│   │   └── http
│   ├── application
│   ├── domain
│   └── persistence
└── pricing
    ├── application
    └── domain
  1. Сначала сценарий использования

Здесь usecase фактически играет роль application. Он подчёркивает сценарии, а domain остаётся общей моделью модуля. Это удобно, если application разрастается и искать файлы по действиям становится проще, чем по типам классов.

catalog
├── api
│   └── http
├── usecase
│   ├── createTariff
│   ├── changePrice
│   └── getTariff
├── domain
└── persistence

Application

application — это слой сценариев работы с системой. Он отвечает на вопрос: «Какие шаги нужно выполнить, когда пользователь хочет активировать подписку?» domain отвечает на другой вопрос: «При каких условиях подписку вообще можно активировать и как меняется её состояние?»

По формулировке Эрика Эванса, application определяет задачи системы, координирует доменные объекты и остаётся относительно тонким; бизнес-правила принадлежат доменной модели. Описание слоя application.

Пример:

в api.http.SubscriptionController пришел запрос application.ActivateSubscription

в application:

// application/ActivateSubscription.java
@Transactional
public void execute(ActivateSubscriptionCommand command) {
    Subscription subscription = subscriptions.findById(command.subscriptionId()).orElseThrow(SubscriptionNotFoundException::new);
    subscription.activate();
    subscriptions.save(subscription);
}

// domain/Subscription.java
public class Subscription {
    private SubscriptionStatus status;

    public void activate() {
        if (status != SubscriptionStatus.PENDING) throw new SubscriptionCannotBeActivatedException();
        status = SubscriptionStatus.ACTIVE;
    }
}

Разница принципиальна: application решает, что загрузить, в какой последовательности вызвать и что сохранить. domain решает, допустима ли операция и какое состояние получится.

Что конкретно класть в application Обычно там находятся:

  • Сценарии записи: создать заказ, активировать подписку, изменить тариф.
  • Сценарии чтения: получить детали, собрать список, подготовить данные для экрана.
  • Входные и выходные типы сценария: ActivateSubscriptionCommand, SubscriptionDetails. Это Java-контракт сценария, а не HTTP Request/Response.
  • Интерфейсы требуемых зависимостей, если они нужны сценарию: репозиторий, платёжный шлюз, доступ к данным другого модуля.
  • Граница транзакции, когда несколько шагов должны выполняться атомарно. В Spring её часто задают на методе application service через @Transactional. Документация Spring по транзакциям.
  • Координация других модулей через их опубликованные контракты, если сценарий этого требует.

В application обычно не стоит помещать @RestController, HTTP-статусы, JPA-запросы, DTO внешнего провайдера и правила вроде «подписку в статусе CANCELLED нельзя активировать». Последнее — правило домена.

  1. Application Service, группирующий связанные сценарии
application
├── TariffManagementService.java
├── TariffQueryService.java
├── command
│   └── CreateTariffCommand.java
└── result
    └── TariffDetails.java
  1. Один класс на сценарий использования
application
├── CreateTariffUseCase.java
├── ChangeTariffPriceUseCase.java
├── GetTariffUseCase.java
├── command
└── result
  1. Группировка по сценариям
application
├── create
│   ├── CreateTariff.java
│   ├── CreateTariffCommand.java
│   └── CreateTariffResult.java
├── changeprice
│   ├── ChangeTariffPrice.java
│   └── ChangeTariffPriceCommand.java
└── get
    ├── GetTariff.java
    └── TariffDetails.java
  1. Разделение команд и запросов

ТУТ ПРО CQRS

application
├── command
│   ├── CreateTariffCommand.java
│   └── CreateTariffHandler.java
└── query
    ├── GetTariffQuery.java
    ├── GetTariffHandler.java
    └── TariffView.java
  • Application должен быть тонким
  • Application описывает Use Cases (Методы должны отражать действия системы, а не абстрактные технические операции)
  • Не передавать HTTP DTO внутрь Application, надо делать маппинг, и не должен знать через что пришел вызов апи
  • Транзакционная граница обычно находится в Application (Потому что именно Use Case знает границы операции)
  • Запись обычно пропускаем через Domain (Чтение не обязательно пропускать через Domain)
  • @RestController ❌ HTTP
  • ResponseEntity ❌ HTTP
  • HttpStatus ❌ HTTP
  • @Entity ❌ persistence
  • JpaRepository ❌ конкретная persistence-технология
  • SQL ❌ infrastructure
  • StripePaymentDto ❌ DTO внешней системы
  • сложные бизнес-инварианты ❌ Domain
  • @Service ✓
  • @Transactional ✓
  • UseCase ✓
  • ApplicationService ✓
  • Command / Query ✓
  • Result / View ✓
  • Repository interface ✓*
  • Gateway / Port interface ✓

Domain

Какие правила бизнеса существуют, допустима ли операция и как в результате должна измениться бизнес-модель?

Application → orchestration / use cases / последовательность действий Domain → business rules / invariants / состояние модели

Application говорит: найти тариф → изменить цену → сохранить

Domain говорит: архивный тариф менять нельзя, цена не может быть отрицательной, если всё нормально → изменить состояние

Главная особенность Entity — идентичность.

Но не надо автоматически делать Entity из каждой таблицы БД. Entity — понятие бизнес-модели, а не SQL.

Entity должна содержать поведение

Плохо — anemic domain model:

public class Subscription {
    private SubscriptionStatus status;
    public SubscriptionStatus getStatus() return status;
    public void setStatus(SubscriptionStatus status) this.status = status;
}

// А бизнес-правило оказывается в Application:
if (subscription.getStatus() != PENDING) throw new RuntimeException();
subscription.setStatus(ACTIVE);

объект должен сам защищать своё состояние

Избегай публичных setter'ов для бизнес-состояния, лучше делать методы конкретных действий, чтобы был бизнес смысл операций:

Вместо:
tariff.setPrice(newPrice);
tariff.setStatus(ACTIVE);

лучше:
tariff.changePrice(newPrice);
tariff.activate();
tariff.archive();

Value Object — объект, который определяется своим значением, а не идентификатором. Например деньги:

public record Money(
BigDecimal amount,
Currency currency
) {

    public Money {
        if (amount.signum() < 0) {
            throw new InvalidMoneyException();
        }
    }
}

Здесь: Money(100, EUR) и Money(100, EUR) логически являются одинаковым значением. Им не нужен: moneyId = 123

Типичные Value Objects:

  • Money
  • Email
  • PhoneNumber
  • Address
  • Coordinates
  • DateRange
  • Percentage
  • TariffPrice
  • OrderNumber

Value Object защищает корректность значения

Вместо: String email; BigDecimal price; String currency;

можно получить: Email email; Money price;

Например:

public record Email(String value) {
    public Email {
        if (value == null || !isValid(value)) {
            throw new InvalidEmailException();
        }
    }
}

Тогда невозможно нормально создать: new Email("qwerty");

и протащить неправильное значение дальше по системе. Очень полезный принцип: Если значение имеет бизнес-смысл и собственные правила корректности — подумай о Value Object.

Но опять же не надо делать: FirstName LastName MiddleName Description Title Comment

Value Object'ами автоматически. Это оправдано, когда объект действительно выражает понятие домена или защищает важные ограничения.

Value Object желательно делать immutable

Aggregate -- группа связанных Domain Objects, которые образуют одну границу согласованности. Aggregate Root Главная задача Aggregate — защищать invariants Не делай Aggregate слишком большим Repository представляет доступ к Aggregate Root. Интерфейс Repository и его реализация — разные вещи

Repository interface не обязательно должен лежать в Domain

Domain Service Теперь важный момент, потому что Service часто понимают неправильно. Domain Service — не любой @Service, находящийся в domain. Domain Service нужен, когда есть настоящее бизнес-правило, которое: 1. относится к предметной области; 2. неестественно помещается в одну Entity или Value Object.

Domain Service ≠ Application Service

Domain Event - описывает значимое бизнес-событие, которое уже произошло. Domain Event не должен знать про Kafka

Domain Exception

По типам DDD:

domain
├── model
│   ├── Tariff.java
│   ├── TariffId.java
│   └── Money.java
├── service
│   └── PricingPolicy.java
├── repository
│   └── TariffRepository.java
└── event
└── TariffPriceChanged.java

По агрегатам или бизнес-понятиям:

domain
├── tariff
│   ├── Tariff.java
│   ├── TariffId.java
│   ├── TariffRepository.java
│   └── TariffPriceChanged.java
└── pricing
├── Money.java
└── PricingPolicy.java

Infrastructure

infrastructure
├── persistence
│   ├── JpaTariffRepository.java
│   ├── TariffJpaEntity.java
│   └── SpringDataTariffRepository.java
├── payment
│   └── ProviderPaymentGateway.java
└── configuration
    └── CatalogConfiguration.java

Публичный контракт