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
- Классический слоистый DDD
presentation — название роли слоя. Если входов больше одного, там могут появиться http, messaging, cli. Это ближе всего к исходной схеме DDD, хотя Эванс задаёт ответственности слоёв, а не буквальные имена Java-пакетов. DDD Reference.
- API как внешняя поверхность
Этот вариант явно показывает, как в модуль входят разные потребители. Но у слова api появляется риск двусмысленности: HTTP API, опубликованные события и Java API для соседних модулей имеют разные контракты. Поэтому подпакеты здесь важны.
- Сначала бизнес-возможность
Подходит, когда catalog уже крупный, а tariff и pricing — устойчивые части его языка. Не стоит автоматически делать такой подпакет для каждой сущности: иначе внутри одного bounded context возникнут искусственные границы.
catalog
├── tariff
│ ├── api
│ │ └── http
│ ├── application
│ ├── domain
│ └── persistence
└── pricing
├── application
└── domain
- Сначала сценарий использования
Здесь 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 нельзя активировать». Последнее — правило домена.
- Application Service, группирующий связанные сценарии
application
├── TariffManagementService.java
├── TariffQueryService.java
├── command
│ └── CreateTariffCommand.java
└── result
└── TariffDetails.java
- Один класс на сценарий использования
application
├── CreateTariffUseCase.java
├── ChangeTariffPriceUseCase.java
├── GetTariffUseCase.java
├── command
└── result
- Группировка по сценариям
application
├── create
│ ├── CreateTariff.java
│ ├── CreateTariffCommand.java
│ └── CreateTariffResult.java
├── changeprice
│ ├── ChangeTariffPrice.java
│ └── ChangeTariffPriceCommand.java
└── get
├── GetTariff.java
└── TariffDetails.java
- Разделение команд и запросов
ТУТ ПРО 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
- 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