Гексагональная архитектура
Ports & Adapters: изоляция бизнес-логики от инфраструктуры. Структура Gradle multi-module, домен на чистой Java, тесты без Spring.
Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс. В этой статье мы фокусируемся на одном сервисе из C2-диаграммы — Order Service. Стек: Java 21, Spring Boot 3, jOOQ, Gradle multi-module.
Зачем ещё одна архитектура?
Классическая трёхслойная архитектура (Controller → Service → Repository) знакома каждому Java-разработчику. Она проста, понятна и… создаёт проблемы, когда проект растёт.
Типичная картина: сервисный слой знает про Spring, JPA, Kafka, REST-клиенты внешних систем. Бизнес-логика размазана между контроллерами и репозиториями. Юнит-тесты требуют поднятия половины Spring-контекста. Замена базы данных или брокера сообщений превращается в рефакторинг на месяц.
Гексагональная архитектура (она же Ports & Adapters, она же архитектура Алистера Кокбёрна) решает одну задачу: изолировать бизнес-логику от инфраструктуры.
Ключевая идея
Представьте приложение как ядро, окружённое портами. Порт — это интерфейс: контракт взаимодействия. Адаптер — это реализация порта для конкретной технологии.
┌──────────────────────────────┐
REST API ──adapter──►│ port │
│ ┌───────────┐ │
Admin API ──adapter──►│ port │ DOMAIN │ │──port──adapter──► PostgreSQL (jOOQ)
│ │ (бизнес- │ │
Scheduler ──adapter──►│ port │ логика) │ │──port──adapter──► Платёжный шлюз
│ └───────────┘ │
│ │──port──adapter──► Складская система
│ │
│ │──port──adapter──► Доставка
└──────────────────────────────┘
Три зоны:
- Domain — сущности, value objects, бизнес-правила. Не знает ни про Spring, ни про базу данных, ни про HTTP. Чистая Java.
- Порты — интерфейсы. Входящие (driving) описывают, что домен умеет делать. Исходящие (driven) описывают, что домену нужно от внешнего мира.
- Адаптеры — реализации портов. REST-контроллер — входящий адаптер. jOOQ-репозиторий — исходящий адаптер. HTTP-клиент к платёжному шлюзу — тоже исходящий адаптер.
Правило зависимостей
Единственное правило, которое нельзя нарушать:
Зависимости направлены внутрь. Домен не зависит ни от чего. Всё зависит от домена.
Адаптеры ──зависят от──► Порты ──определены в──► Домен
Домен не импортирует spring-*, javax.persistence.*, com.fasterxml.jackson.*. Если вы видите такой импорт в доменном классе — архитектура нарушена.
Структура проекта: Gradle multi-module
В трёхслойке границы между слоями — это пакеты. Ничто не мешает контроллеру импортнуть репозиторий напрямую. В multi-module границы — это модули Gradle. Компилятор не даст адаптеру залезть в другой адаптер.
orders-service/ # корневой проект
├── core/ # Домен: модель, порты, use cases
│ └── src/main/java/
│ └── ru/example/orders/core/
│ ├── domain/
│ │ ├── aggregate/ # Aggregate Roots (Order)
│ │ ├── entity/ # Entities (OrderItem, PaymentOrder, Customer)
│ │ ├── valueobject/ # Value Objects (Money, ShippingAddress)
│ │ ├── factory/ # Фабрики создания доменных объектов
│ │ ├── port/out/ # Исходящие порты (внешние системы)
│ │ └── repository/ # Порты для персистенции
│ ├── usecase/
│ │ ├── command/ # Команды (изменяют состояние)
│ │ └── query/ # Запросы (только чтение)
│ ├── exception/ # Доменные исключения
│ └── service/ # Доменные сервисы
│
├── persistence/ # Исходящий адаптер: PostgreSQL + jOOQ
├── payment-out-adapter/ # Исходящий адаптер: платёжный шлюз
├── inventory-out-adapter/ # Исходящий адаптер: складская система
├── shipping-out-adapter/ # Исходящий адаптер: служба доставки
├── receipt-out-adapter/ # Исходящий адаптер: фискальные чеки
├── scheduler-out-adapter/ # Исходящий адаптер: фоновые задачи
│
├── customer-api-in-adapter/ # Входящий адаптер: REST API для клиентов
├── admin-api-in-adapter/ # Входящий адаптер: REST API для админки
│
└── bootstrap/ # Точка входа: Spring Boot, конфиги, wiring
Каждый внешний сервис — отдельный модуль. Хотите заменить одного платёжного провайдера на другого? Пишете новый <provider>-payment-out-adapter, реализуете тот же PaymentPort — и всё. Домен не меняется. Другие адаптеры не меняются.
Домен: Aggregate Root
Order — центральный объект домена. Содержит бизнес-логику внутри себя: защищает инварианты, управляет дочерними сущностями.
@Getter
@Builder
public class Order {
private Long id;
private Long customerId;
private Long reservationId;
private OrderStatus status;
private boolean needShippingInsurance;
private OffsetDateTime expiresAt;
private OffsetDateTime confirmedAt;
private OffsetDateTime cancelledAt;
private PaymentOrder payment;
private final List<OrderItem> items = new ArrayList<>();
private final List<Receipt> receipts = new ArrayList<>();
// --- Бизнес-логика: оплата ---
public void createPayment(String orderNumber, Money amount, UUID gatewayOrderId,
PaymentStatus status, String paymentFormUrl) {
if (this.payment != null) {
throw new PaymentOrderException.AlreadyExists();
}
this.payment = PaymentOrderFactory.create(
this.id, orderNumber, amount, gatewayOrderId, status, paymentFormUrl);
}
public void depositPayment(PaymentStatusResponse response) {
if (this.payment == null) {
throw new PaymentOrderException.NotFound(
"Payment not found for orderId=" + this.id);
}
this.payment.markDeposited(response);
markPendingConfirmation();
}
// --- Бизнес-логика: подтверждение / отмена ---
public void confirm(InventoryReservation reservation) {
this.status = OrderStatus.CONFIRMED;
this.confirmedAt = reservation.confirmedAt();
for (OrderItem item : items) {
ReservationLine line = findReservationLine(
reservation, item.getReservationLineId());
if (line != null) {
item.confirm(line);
}
}
}
public void cancel(InventoryReservation reservation) {
if (this.status == OrderStatus.CANCELLED) return;
this.status = OrderStatus.CANCELLED;
this.cancelledAt = reservation.cancelledAt();
for (OrderItem item : items) {
item.cancel(reservation);
}
}
public void markPendingConfirmation() {
if (this.status == OrderStatus.PENDING_CONFIRMATION) return;
if (this.status != OrderStatus.BOOKING) {
throw new IllegalStateException(
"Only BOOKING -> PENDING_CONFIRMATION, current=" + this.status);
}
this.status = OrderStatus.PENDING_CONFIRMATION;
}
// --- Бизнес-логика: расчёты ---
public Money calculateTotalAmount() {
Money itemsTotal = items.stream()
.map(OrderItem::getPrice)
.reduce(Money.ZERO, Money::add);
Money insuranceTotal = getShippingInsurances().stream()
.map(ShippingInsurance::getPrice)
.reduce(Money.ZERO, Money::add);
return itemsTotal.add(insuranceTotal);
}
// --- Бизнес-логика: страхование доставки ---
public boolean canCancelShippingInsurance(Long itemId, OffsetDateTime now) {
OrderItem item = findItemById(itemId).orElse(null);
if (item == null || item.getShippingInsurance() == null || !needShippingInsurance) {
return false;
}
ShippingInsurance insurance = item.getShippingInsurance();
if (!insurance.isConfirmed()) return false;
OffsetDateTime expectedDelivery = item.getExpectedDeliveryAt();
if (expectedDelivery == null) return false;
long secToDelivery = Duration.between(now, expectedDelivery).toSeconds();
long daysSinceCreation = Duration.between(insurance.getCreatedAt(), now).toDays();
return secToDelivery > 86400 && daysSinceCreation < 14;
}
}
Ни одной аннотации @Entity, @Table, @Column. Это чистый Java-объект. Бизнес-правила живут в нём: нельзя создать дублирующий платёж, нельзя подтвердить заказ не из статуса BOOKING, отмена страховки доставки возможна только если до доставки > 1 дня и < 14 дней с покупки.
Домен: Value Objects
public record Money(BigDecimal amount) {
public static final Money ZERO = new Money(BigDecimal.ZERO);
public Money {
Objects.requireNonNull(amount);
amount = amount.setScale(2, RoundingMode.HALF_UP);
}
public static Money of(BigDecimal amount) {
return amount == null ? ZERO : new Money(amount);
}
public Money add(Money other) {
return new Money(this.amount.add(other.amount));
}
public Money subtract(Money other) {
return new Money(this.amount.subtract(other.amount));
}
public boolean isPositive() {
return amount.compareTo(BigDecimal.ZERO) > 0;
}
public long toKopecks() {
return amount.movePointRight(2).longValueExact();
}
public static Money fromKopecks(long kopecks) {
return new Money(BigDecimal.valueOf(kopecks, 2));
}
}
Java record — идеальный инструмент для Value Objects: неизменяемый, с equals/hashCode из коробки. Компактный конструктор гарантирует инварианты: Money всегда имеет 2 знака после запятой. toKopecks() / fromKopecks() — конвертация для платёжного шлюза, который работает в копейках.
Домен: исходящие порты
Домен описывает свои потребности через интерфейсы:
// Порт: оплата (реализует payment-out-adapter)
public interface PaymentPort {
PaymentRegisterResponse register(PaymentRegisterRequest request);
PaymentRefundResponse refund(PaymentRefundRequest request);
PaymentStatusResponse getOrderStatus(UUID gatewayOrderId);
}
// Порт: резервация на складе (реализует inventory-out-adapter)
public interface InventoryPort {
List<Product> searchProducts(String query, Long categoryId, Long limit);
AvailabilityList checkAvailability(AvailabilityFilter filter);
InventoryReservation reserveItems(ReservationRequest request);
InventoryReservation confirmReservation(Long id);
InventoryReservation cancelReservation(Long id);
}
// Порт: страхование доставки (реализует shipping-out-adapter)
public interface ShippingInsurancePort {
InsuranceCreationResult createInsurances(List<OrderItem> items);
ShippingInsuranceDto getInsurance(UUID uuid);
void confirm(List<UUID> uuids);
ShippingInsuranceDto cancel(UUID uuid);
}
// Порт: персистенция (реализует persistence модуль)
public interface OrderRepository {
Optional<Order> findById(Long id, SelectMode mode);
Optional<Order> findOne(OrderFilter filter, SelectMode mode);
List<Order> findAll(OrderFilter filter, int limit, SelectMode mode);
Order save(Order order);
}
Домен говорит: «мне нужно зарезервировать товар на складе», «мне нужно провести оплату». Как именно — его не касается. Завтра поменяется платёжный провайдер, jOOQ заменят на Hibernate — домен останется прежним.
Входящие порты: Use Cases + CQRS
Вместо одного OrderService на 30 методов — каждая операция в отдельном классе. Команды меняют состояние, запросы только читают:
// Команда — record с данными для выполнения
public record CreateOrderCommand(
String cartId,
List<CreateOrderItemDto> items,
Boolean needShippingInsurance
) implements UseCase<Order> {}
// Обработчик — один use case, один класс
@Component
@RequiredArgsConstructor
public class CreateOrderCommandHandler
implements UseCaseHandler<CreateOrderCommand, Order> {
private final InventoryPort inventoryPort;
private final OrderRepository orderRepository;
private final OrderConfirmationTaskRepository taskRepository;
private final CustomerService customerService;
private final ShippingInsurancePort shippingInsurancePort;
private final CreateOrderToInventoryMapper mapper;
@Transactional
@Override
public Order handle(CreateOrderCommand command) {
// 1. Получаем текущего покупателя
Customer customer = customerService.currentCustomer();
// 2. Маппим DTO и резервируем товары на складе
List<ReservationItemDto> reservationItems = mapper
.toReservationItems(command.items());
ReservationRequest request = mapper.toReservationRequest(
command.cartId(), reservationItems, command.needShippingInsurance());
InventoryReservation reservation = inventoryPort.reserveItems(request);
// 3. Создаём доменный объект через фабрику
Order order = OrderFactory.createFromReservation(
customer.getId(), reservation, reservationItems,
command.needShippingInsurance());
// 4. Оформляем страховку доставки, если нужно
if (command.needShippingInsurance()) {
InsuranceCreationResult result =
shippingInsurancePort.createInsurances(order.getItems());
order.addShippingInsurances(result.insurances());
}
// 5. Сохраняем и создаём фоновую задачу подтверждения
order = orderRepository.save(order);
OrderConfirmationTask task = OrderConfirmationTaskFactory.createForOrder(
order.getId(), order.getExpiresAt());
taskRepository.save(task);
return order;
}
}
Обработчик оркестрирует: вызывает порты, создаёт доменные объекты, сохраняет. Бизнес-правила при этом живут внутри Order, OrderItem, Money — не в обработчике.
Входящий адаптер: REST-контроллер
Контроллер — тонкая прослойка между HTTP и use cases:
@RestController
@RequiredArgsConstructor
public class OrderController implements OrdersApi {
private final UseCaseDispatcher dispatcher;
private final OrderJsonMapper orderJsonMapper;
private final OrderItemMapper orderItemMapper;
@Override
public ResponseEntity<OrderResponse> createOrder(
CreateOrderRequest request) {
List<CreateOrderItemDto> items = orderItemMapper
.toCreateOrderItemDtoList(request.getItems());
CreateOrderCommand command = new CreateOrderCommand(
request.getCartId(),
items,
request.getNeedShippingInsurance());
// Диспетчер находит нужный handler по типу команды
Order result = dispatcher.dispatch(command);
OrderJson json = orderJsonMapper.toJson(result);
return ResponseEntity.ok(
new OrderResponse().success(true).data(json));
}
}
Контроллер реализует сгенерированный интерфейс OrdersApi (из OpenAPI-спецификации). Его задача: принять HTTP → собрать команду → отдать диспетчеру → вернуть JSON. Никакой бизнес-логики.
UseCaseDispatcher — единая точка входа. Контроллер не знает, какой именно обработчик выполнит команду. Это даёт гибкость: можно добавить логирование, метрики, авторизацию — в одном месте.
Исходящий адаптер: платёжный шлюз
@Component
@RequiredArgsConstructor
public class PaymentGatewayAdapter implements PaymentPort {
private final PaymentGatewayApi api; // HTTP-клиент (Retrofit/OpenFeign)
private final PaymentGatewayProperties config;
private final PaymentAdapterMapper mapper;
@Override
public PaymentRegisterResponse register(PaymentRegisterRequest request) {
PaymentApiRequest apiRequest = PaymentApiRequest.builder()
.merchantId(config.merchantId())
.secretKey(config.secretKey())
.orderNumber(request.orderNumber())
.amount(MoneyUtil.rublesToKopecks(request.amount()))
.returnUrl(request.returnUrl())
.build();
PaymentApiResponse response = executeCall(api.register(apiRequest));
return mapper.toRegisterResponse(response);
}
@Override
public PaymentStatusResponse getOrderStatus(UUID gatewayOrderId) {
PaymentStatusApiResponse response =
executeCall(api.getOrderStatus(gatewayOrderId));
return mapper.toStatusResponse(response);
}
}
Адаптер маппит доменные DTO в формат конкретного API (платёжный шлюз обычно работает в копейках — MoneyUtil.rublesToKopecks()). Если провайдер сменит API — меняется только этот файл.
Исходящий адаптер: персистенция (jOOQ)
@Repository
@RequiredArgsConstructor
public class JooqOrderRepository implements OrderRepository {
private final DSLContext dsl;
private final OrderDomainRecordMapper mapper;
@Override
public Optional<Order> findById(Long id, SelectMode mode) {
OrdersRecord record = dsl.selectFrom(ORDERS)
.where(ORDERS.ID.eq(id))
.forUpdate() // или forShare — зависит от SelectMode
.fetchOneInto(OrdersRecord.class);
if (record == null) return Optional.empty();
return Optional.of(mapper.toDomainOrder(record));
}
@Override
public Order save(Order order) {
OrdersRecord record = mapper.toRecord(order);
record.merge(dsl); // Insert or update
return mapper.toDomainOrder(record);
}
}
Маппинг между jOOQ-записями и доменной моделью — ответственность адаптера. Домен не знает про DSLContext, OrdersRecord или SQL.
Фабрика: инкапсуляция создания
Создание агрегата — нетривиальная операция. Нужно синхронизировать данные из внешней системы, связать позиции с резервацией, рассчитать время истечения. Фабрика инкапсулирует эту логику:
public final class OrderFactory {
private OrderFactory() {}
public static Order createFromReservation(Long customerId,
InventoryReservation reservation,
List<ReservationItemDto> items,
boolean needShippingInsurance) {
OffsetDateTime now = DateTimeUtil.currentDateTime();
Order order = Order.builder()
.customerId(customerId)
.reservationId(reservation.id())
.reservationHash(reservation.hash())
.status(OrderStatus.BOOKING)
.reservationExpiresAt(now.plusSeconds(reservation.secondsLeftToCancel()))
.expiresAt(OrderExpiresDateTimeUtil
.calculateExpiresAt(reservation.secondsLeftToCancel()))
.needShippingInsurance(needShippingInsurance)
.deleted(false)
.createdAt(reservation.createdAt())
.updatedAt(reservation.createdAt())
.build();
createItemsFromReservation(reservation.lines(), items)
.forEach(order::addItemDirect);
return order;
}
}
Фабрика — часть домена. Она не зависит от Spring, базы данных или внешних API.
Доменные исключения
public abstract class OrderException extends RuntimeException {
protected OrderException(String message) { super(message); }
public static final class NotFound extends OrderException {
public NotFound(String message) { super(message); }
}
public static final class NotPaid extends OrderException {
public NotPaid() { super("Order has not been paid"); }
}
public static final class ReservationExpired extends OrderException {
public ReservationExpired() { super("Reservation time has expired"); }
}
}
Вложенные static final class — компактный паттерн: одна иерархия на агрегат, гранулярные типы для каждой бизнес-ошибки.
Доменные исключения не наследуют ничего из Spring — маппинг в HTTP-коды происходит в адаптере:
// bootstrap/RestExceptionHandler.java
@RestControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(OrderException.NotFound.class)
public ResponseEntity<ErrorResponse> handle(OrderException.NotFound ex) {
return ResponseEntity.status(404)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(OrderException.ReservationExpired.class)
public ResponseEntity<ErrorResponse> handle(OrderException.ReservationExpired ex) {
return ResponseEntity.status(400)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(PaymentGatewayException.class)
public ResponseEntity<ErrorResponse> handle(PaymentGatewayException ex) {
return ResponseEntity.status(502)
.body(new ErrorResponse("Payment service error"));
}
}
Тестирование: главный выигрыш
Тест доменной модели — без Spring, без моков
class OrderTest {
@Test
void calculatesTotalWithShippingInsurance() {
Order order = OrderFactory.createFromReservation(
1L, testReservation(), testReservationItems(), true);
order.addShippingInsurances(List.of(
testShippingInsurance(Money.of(new BigDecimal("150.00")))));
Money total = order.calculateTotalAmount();
// цена товаров + страховка доставки
assertThat(total.amount()).isEqualByComparingTo("650.00");
}
@Test
void preventsDoublePayment() {
Order order = testOrder();
order.createPayment("ORD-1", Money.of(500), UUID.randomUUID(),
PaymentStatus.REGISTERED, "https://pay.example.com");
assertThatThrownBy(() -> order.createPayment("ORD-2", Money.of(500),
UUID.randomUUID(), PaymentStatus.REGISTERED, "https://pay.example.com"))
.isInstanceOf(PaymentOrderException.AlreadyExists.class);
}
@Test
void cannotConfirmFromWrongStatus() {
Order order = testOrder();
order.cancel(testCancelReservation());
assertThatThrownBy(() -> order.markPendingConfirmation())
.isInstanceOf(IllegalStateException.class);
}
}
Тесты запускаются за миллисекунды. Не нужен @SpringBootTest, не нужен Testcontainers. Тестируется чистая бизнес-логика: расчёты, инварианты, переходы статусов.
Тест use case — с моками портов
class CreateOrderCommandHandlerTest {
private final InventoryPort inventory = mock(InventoryPort.class);
private final OrderRepository orders = mock(OrderRepository.class);
private final ShippingInsurancePort shippingInsurance = mock(ShippingInsurancePort.class);
// ...
@Test
void createsOrderWithShippingInsurance() {
when(inventory.reserveItems(any())).thenReturn(testReservation());
when(shippingInsurance.createInsurances(any()))
.thenReturn(new InsuranceCreationResult(testInsurances()));
when(orders.save(any())).thenAnswer(inv -> inv.getArgument(0));
CreateOrderCommand command = new CreateOrderCommand(
"cart-1", testItemDtos(), true);
Order result = handler.handle(command);
assertThat(result.getStatus()).isEqualTo(OrderStatus.BOOKING);
assertThat(result.getShippingInsurances()).hasSize(2);
verify(orders).save(any());
}
}
Порты — интерфейсы, поэтому мокаются тривиально. Адаптеры тестируются отдельно: persistence — через Testcontainers + PostgreSQL, payment-адаптер — через WireMock.
Трёхслойная vs гексагональная
| Аспект | Трёхслойная | Гексагональная |
|---|---|---|
| Границы | пакеты (можно нарушить) | Gradle-модули (компилятор не даст) |
| Направление зависимостей | сверху вниз (Controller → Service → Repo) | снаружи внутрь (Adapters → Domain) |
| Где бизнес-логика | размазана по сервисам | внутри агрегатов и use cases |
| Зависимость от фреймворка | Service знает про Spring, JPA | домен на чистой Java |
| Замена внешней системы | рефакторинг сервисного слоя | новый адаптер, один модуль |
| Юнит-тесты домена | моки JPA, Spring-контекст | работают без фреймворков |
| Количество модулей | 1 | 10+ |
| Порог входа | низкий | средний |
Частые ошибки
1. Анемичная модель
// Плохо: логика снаружи, модель — контейнер данных
public class Order {
private OrderStatus status;
public void setStatus(OrderStatus s) { this.status = s; }
}
// orderService.cancel(order):
order.setStatus(OrderStatus.CANCELLED); // кто угодно может поставить любой статус
// Хорошо: модель защищает свои инварианты
public void cancel(InventoryReservation reservation) {
if (this.status == OrderStatus.CANCELLED) return;
this.status = OrderStatus.CANCELLED;
this.cancelledAt = reservation.cancelledAt();
for (OrderItem item : items) {
item.cancel(reservation);
}
}
2. Аннотации JPA/Jackson в домене
// Плохо: домен привязан к инфраструктуре
@Entity
@Table(name = "orders")
public class Order {
@Id private Long id;
@Enumerated(EnumType.STRING) private OrderStatus status;
@JsonProperty("total") private Money total;
}
// Хорошо: домен чистый, маппинг в адаптерах
@Getter
@Builder
public class Order {
private Long id;
private OrderStatus status;
}
3. God Service вместо use cases
// Плохо: один сервис на 30 методов
public class OrderService {
public Order create(...) { }
public void pay(...) { }
public void cancel(...) { }
public void confirm(...) { }
public List<Order> findAll(...) { }
public void processExpired(...) { }
public void syncFromProvider(...) { }
}
// Хорошо: один use case — один класс
CreateOrderCommandHandler
ConfirmOrderCommandHandler
CancelOrderCommandHandler
CreatePaymentOrderCommandHandler
ProcessOrderConfirmationTaskCommandHandler
SyncOrderCommandHandler
GetOrderByIdQueryHandler
GetOrdersQueryHandler
4. Доменные исключения наследуют Spring-классы
// Плохо: домен привязан к HTTP
public class OrderNotFoundException extends ResponseStatusException {
public OrderNotFoundException() { super(HttpStatus.NOT_FOUND); }
}
// Хорошо: доменное исключение, маппинг в RestExceptionHandler
public static final class NotFound extends OrderException {
public NotFound(String message) { super(message); }
}
Когда применять
Подходит:
- Сложная бизнес-логика с правилами и инвариантами
- Несколько входных каналов (REST API + Admin API + планировщик)
- Интеграция с внешними системами, которые могут меняться
- Команда больше 3-4 разработчиков
- Долгоживущий проект
Избыточна:
- CRUD без бизнес-логики
- Прототипы и MVP
- Простые микросервисы-прокси
- Проект на одного разработчика на 2 месяца
Итого
Гексагональная архитектура — это не про количество модулей и интерфейсов. Это про одно правило: домен не зависит от инфраструктуры.
Бизнес-логика живёт в чистых Java-классах: Order знает как отменить заказ, Money гарантирует 2 знака после запятой, OrderFactory инкапсулирует создание. Фреймворки, базы данных, платёжные шлюзы подключаются снаружи через адаптеры. Каждый адаптер — отдельный Gradle-модуль с физической границей.
Цена — больше модулей, классов, интерфейсов. Выигрыш — код, который можно тестировать без Spring-контекста, менять без страха сломать всё остальное, и масштабировать командой, где каждый работает со своим адаптером.
Ссылки
- CQRS: разделение чтения и записи — паттерн use cases в гексагональной архитектуре.
- Тактические паттерны DDD — Aggregate, Value Object, Domain Event.
- Building blocks для Java — готовые абстракции для домена.