Гексагональная архитектура
Ports & Adapters простыми словами: как отделить бизнес-логику от базы, фреймворка и внешних API. Порты, адаптеры, правило зависимостей и выигрыш в тестах.
Когда проект маленький, достаточно трёх слоёв: контроллер → сервис → репозиторий. Но с ростом проекта одна проблема начинает повторяться снова и снова: бизнес-логика оказывается перемешана с инфраструктурой. Сервис знает про ORM, про HTTP-клиент к платёжному шлюзу, про Kafka. Чтобы протестировать одно бизнес-правило, нужно поднять базу данных.
Гексагональная архитектура (её ещё называют Ports & Adapters, автор — Алистер Кокбёрн) решает одну конкретную задачу: держать бизнес-логику отдельно от всего внешнего.
Проблема трёхслойной архитектуры
Представьте сервис оформления заказов. В классическом варианте:
@Service
public class OrderService {
@Autowired private OrderRepository repository; // JPA-зависимость
@Autowired private PaymentClient payment; // HTTP-клиент
@Autowired private KafkaTemplate<String, Order> kafka; // брокер
public Order createOrder(CreateOrderRequest request) {
// логика перемешана с вызовами инфраструктуры
Order order = new Order(request.getItems());
payment.charge(order.getTotal()); // внешний вызов прямо здесь
repository.save(order); // ORM прямо здесь
kafka.send("orders", order); // брокер прямо здесь
return order;
}
}
type OrderService struct {
db *pgxpool.Pool // драйвер базы
payment *http.Client // HTTP-клиент
kafka *kafka.Writer // брокер
}
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (Order, error) {
// логика перемешана с вызовами инфраструктуры
order := NewOrder(req.Items)
if _, err := s.payment.Post(paymentURL, "application/json", body(order.Total())); err != nil { // внешний вызов прямо здесь
return Order{}, err
}
if _, err := s.db.Exec(ctx, "INSERT INTO orders ...", order.ID, order.Total()); err != nil { // SQL прямо здесь
return Order{}, err
}
err := s.kafka.WriteMessages(ctx, kafka.Message{Topic: "orders", Value: toJSON(order)}) // брокер прямо здесь
return order, err
}
export class OrderService {
constructor(pool: Pool, http: HttpClient, kafka: Producer) { // драйвер базы, HTTP-клиент, брокер
Object.assign(this, { pool, http, kafka });
}
async createOrder(request: CreateOrderRequest): Promise<Order> {
// логика перемешана с вызовами инфраструктуры
const order = new Order(request.items);
await this.http.post(paymentUrl, { amount: order.total() }); // внешний вызов прямо здесь
await this.pool.query('INSERT INTO orders ...', [order.id, order.total()]); // SQL прямо здесь
await this.kafka.send({ topic: 'orders', messages: [{ value: JSON.stringify(order) }] }); // брокер прямо здесь
return order;
}
}
class OrderService:
def __init__(self, session: Session, http: httpx.Client, producer: KafkaProducer):
self.session, self.http, self.producer = session, http, producer # ORM, HTTP-клиент, брокер
def create_order(self, request: CreateOrderRequest) -> Order:
# логика перемешана с вызовами инфраструктуры
order = Order(request.items)
self.http.post(PAYMENT_URL, json={"amount": str(order.total())}) # внешний вызов прямо здесь
self.session.add(OrderRow.from_domain(order)) # ORM прямо здесь
self.session.commit()
self.producer.send("orders", order.to_json()) # брокер прямо здесь
return order
Что плохо:
- чтобы протестировать расчёт суммы заказа, нужны заглушки для базы, Kafka и HTTP-клиента;
- замена платёжного провайдера затрагивает сам сервис с бизнес-логикой;
- бизнес-правила читаются между строк с вызовами инфраструктуры.
Ключевая идея: домен в центре
Гексагональная архитектура разделяет всё на три зоны:
Домен внутри, всё чужое снаружи, а на границе — порты: домен объявляет, что ему нужно, а как это устроено на самом деле, знает только адаптер. Поменять базу или платёжный шлюз значит переписать один адаптер, а не бизнес-правила.
- Домен — сущности, бизнес-правила, логика. Не знает ни про базу данных, ни про HTTP, ни про фреймворк.
- Порт — интерфейс, который домен определяет сам. «Мне нужно сохранить заказ» — это порт. Как именно — домену не важно.
- Адаптер — реализация порта для конкретной технологии. PostgreSQL-адаптер реализует порт сохранения. HTTP-адаптер реализует порт отправки платежа.
Адаптеры бывают двух видов:
- входящие (driving) — инициируют вызовы в домен: REST-контроллер, планировщик, Kafka-слушатель;
- исходящие (driven) — домен вызывает их через порты: репозиторий, HTTP-клиент к внешнему сервису.
Правило зависимостей
Единственное, что нужно запомнить:
Зависимости всегда направлены внутрь. Домен не зависит ни от чего. Всё зависит от домена.
Стрелка показывает не порядок вызовов, а кто про кого знает: домен не упоминает ни одного класса снаружи, поэтому его можно собрать и прогнать тестами вообще без базы и без веб-сервера. Плата — лишний слой интерфейсов даже там, где реализация одна.
Если в доменном классе появился импорт org.springframework, jakarta.persistence или com.fasterxml.jackson — правило нарушено.
Как выглядит код
Посмотрим на конкретный пример — сервис заказов.
Домен
Агрегат Order содержит бизнес-логику внутри себя и не знает ни про какую инфраструктуру:
public class Order {
private OrderId id;
private OrderStatus status;
private PaymentOrder payment;
private Instant cancelledAt;
private final List<OrderItem> items = 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 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 Money calculateTotalAmount() {
return items.stream()
.map(OrderItem::getPrice)
.reduce(Money.ZERO, Money::add);
}
}
type Order struct {
id OrderID
status OrderStatus
payment *PaymentOrder
cancelledAt time.Time
items []OrderItem
}
func (o *Order) CreatePayment(orderNumber string, amount Money, gatewayOrderID uuid.UUID,
status PaymentStatus, paymentFormURL string) error {
if o.payment != nil {
return ErrPaymentAlreadyExists
}
o.payment = NewPaymentOrder(o.id, orderNumber, amount, gatewayOrderID, status, paymentFormURL)
return nil
}
func (o *Order) Cancel(reservation InventoryReservation) {
if o.status == OrderCancelled {
return
}
o.status = OrderCancelled
o.cancelledAt = reservation.CancelledAt
for i := range o.items {
o.items[i].Cancel(reservation)
}
}
func (o *Order) TotalAmount() Money {
total := MoneyZero
for _, item := range o.items {
total = total.Add(item.Price)
}
return total
}
export class Order {
#id: OrderId;
#status: OrderStatus;
#payment: PaymentOrder | null = null;
#cancelledAt: Date | null = null;
#items: OrderItem[] = [];
createPayment(orderNumber: string, amount: Money, gatewayOrderId: string,
status: PaymentStatus, paymentFormUrl: string): void {
if (this.#payment) throw new PaymentAlreadyExists();
this.#payment = PaymentOrderFactory.create(this.#id, orderNumber, amount, gatewayOrderId, status, paymentFormUrl);
}
cancel(reservation: InventoryReservation): void {
if (this.#status === OrderStatus.CANCELLED) return;
this.#status = OrderStatus.CANCELLED;
this.#cancelledAt = reservation.cancelledAt;
for (const item of this.#items) item.cancel(reservation);
}
calculateTotalAmount(): Money {
return this.#items.reduce((sum, item) => sum.add(item.price), Money.ZERO);
}
}
class Order:
def __init__(self, id: OrderId, status: OrderStatus, items: list[OrderItem]):
self._id, self._status, self._items = id, status, items
self._payment: PaymentOrder | None = None
self._cancelled_at: datetime | None = None
def create_payment(self, order_number: str, amount: Money, gateway_order_id: UUID,
status: PaymentStatus, payment_form_url: str) -> None:
if self._payment is not None:
raise PaymentAlreadyExists()
self._payment = PaymentOrder.create(self._id, order_number, amount, gateway_order_id, status, payment_form_url)
def cancel(self, reservation: InventoryReservation) -> None:
if self._status is OrderStatus.CANCELLED:
return
self._status = OrderStatus.CANCELLED
self._cancelled_at = reservation.cancelled_at
for item in self._items:
item.cancel(reservation)
def calculate_total_amount(self) -> Money:
return sum((item.price for item in self._items), Money.ZERO)
Ни одной инфраструктурной пометки: ни аннотаций ORM и JSON (@Entity, @JsonProperty), ни тегов db и json на полях, ни декораторов. Бизнес-правила читаются напрямую: нельзя создать дублирующий платёж, отмена обновляет статус и дочерние позиции.
Исходящий порт
Домен описывает свои потребности через интерфейс — это порт:
// Определён в доменном слое — домен говорит что ему нужно
public interface OrderRepository {
Optional<Order> findById(OrderId id);
Order save(Order order);
}
public interface PaymentPort {
PaymentRegisterResponse register(PaymentRegisterRequest request);
PaymentStatusResponse getOrderStatus(UUID gatewayOrderId);
}
// Объявлены в пакете ядра — домен говорит, что ему нужно
type OrderRepository interface {
FindByID(ctx context.Context, id OrderID) (*Order, error) // ErrOrderNotFound, если заказа нет
Save(ctx context.Context, order *Order) error
}
type PaymentPort interface {
Register(ctx context.Context, req PaymentRegisterRequest) (PaymentRegisterResponse, error)
OrderStatus(ctx context.Context, gatewayOrderID uuid.UUID) (PaymentStatusResponse, error)
}
// Объявлены в ядре — домен говорит, что ему нужно
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<Order>;
}
export interface PaymentPort {
register(request: PaymentRegisterRequest): Promise<PaymentRegisterResponse>;
getOrderStatus(gatewayOrderId: string): Promise<PaymentStatusResponse>;
}
# Объявлены в ядре — домен говорит, что ему нужно
class OrderRepository(Protocol):
def find_by_id(self, id: OrderId) -> Order | None: ...
def save(self, order: Order) -> Order: ...
class PaymentPort(Protocol):
def register(self, request: PaymentRegisterRequest) -> PaymentRegisterResponse: ...
def get_order_status(self, gateway_order_id: UUID) -> PaymentStatusResponse: ...
Исходящий адаптер
Адаптер реализует порт. Он живёт в отдельном модуле и знает про конкретную технологию:
// В модуле persistence — адаптер знает про jOOQ и БД
@Repository
@RequiredArgsConstructor
public class JooqOrderRepository implements OrderRepository {
private final DSLContext dsl;
private final OrderDomainRecordMapper mapper;
@Override
public Optional<Order> findById(OrderId id) {
OrdersRecord record = dsl.selectFrom(ORDERS)
.where(ORDERS.ID.eq(id.value()))
.fetchOne();
if (record == null) return Optional.empty();
return Optional.of(mapper.toDomainOrder(record));
}
@Override
public Order save(Order order) {
OrdersRecord record = mapper.toRecord(order);
dsl.attach(record);
record.merge();
return mapper.toDomainOrder(record);
}
}
// В пакете persistence — адаптер знает про pgx и базу
type PgxOrderRepository struct {
pool *pgxpool.Pool
mapper OrderRowMapper
}
func (r *PgxOrderRepository) FindByID(ctx context.Context, id OrderID) (*Order, error) {
rows, err := r.pool.Query(ctx, `SELECT id, status, cancelled_at, version FROM orders WHERE id = $1`, id)
if err != nil {
return nil, err
}
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[orderRow])
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrOrderNotFound
}
if err != nil {
return nil, err
}
return r.mapper.ToDomain(row), nil
}
func (r *PgxOrderRepository) Save(ctx context.Context, order *Order) error {
row := r.mapper.ToRow(order)
_, err := r.pool.Exec(ctx, `
INSERT INTO orders (id, status, cancelled_at, version) VALUES ($1, $2, $3, 1)
ON CONFLICT (id) DO UPDATE
SET status = EXCLUDED.status, cancelled_at = EXCLUDED.cancelled_at, version = orders.version + 1`,
row.ID, row.Status, row.CancelledAt)
return err
}
// В пакете persistence — адаптер знает про pg и базу
export class PgOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool, private readonly mapper: OrderRowMapper) {}
async findById(id: OrderId): Promise<Order | null> {
const { rows } = await this.pool.query<OrderRow>(
'SELECT id, status, cancelled_at, version FROM orders WHERE id = $1', [id.value]);
return rows[0] ? this.mapper.toDomain(rows[0]) : null;
}
async save(order: Order): Promise<Order> {
const row = this.mapper.toRow(order);
const { rows } = await this.pool.query<OrderRow>(
`INSERT INTO orders (id, status, cancelled_at, version) VALUES ($1, $2, $3, 1)
ON CONFLICT (id) DO UPDATE
SET status = EXCLUDED.status, cancelled_at = EXCLUDED.cancelled_at, version = orders.version + 1
RETURNING id, status, cancelled_at, version`,
[row.id, row.status, row.cancelledAt]);
return this.mapper.toDomain(rows[0]);
}
}
# В пакете persistence — адаптер знает про SQLAlchemy и базу
class SqlAlchemyOrderRepository:
def __init__(self, session: Session, mapper: OrderRowMapper):
self._session, self._mapper = session, mapper
def find_by_id(self, id: OrderId) -> Order | None:
row = self._session.get(OrderRow, id.value)
return self._mapper.to_domain(row) if row is not None else None
def save(self, order: Order) -> Order:
row = self._session.merge(self._mapper.to_row(order)) # merge: состояние копируется в объект сессии
self._session.flush()
return self._mapper.to_domain(row)
Две мелочи в этом коде легко сделать неправильно.
selectFrom(ORDERS) уже знает тип строки, поэтому достаточно fetchOne() — он вернёт OrdersRecord. Вариант fetchOneInto(OrdersRecord.class) лишний и вдобавок вредный: он собирает новую запись, не связанную с подключением.
А связь с подключением как раз нужна для merge(). Запись, созданную маппером вручную, jOOQ считает отсоединённой: она не знает, через какое подключение себя сохранять, и merge() на ней упадёт с DetachedException. dsl.attach(record) эту связь и устанавливает. Метод merge() вызывается без аргументов — DSLContext в него не передают, такой перегрузки у jOOQ нет.
Две мелочи в этом коде легко сделать неправильно.
pgx.CollectOneRow возвращает pgx.ErrNoRows, когда строки нет, и это единственное место, где техническое «нет строк» превращается в доменную ErrOrderNotFound. Сравнивают через errors.Is, а не ==: ошибка по дороге может оказаться обёрнутой.
А Save через INSERT ... ON CONFLICT DO UPDATE это не «сохранить как получится»: колонки в DO UPDATE перечисляют явно, и новое поле структуры, забытое в этом списке, молча перестаёт сохраняться при обновлении. Ловит это тест адаптера «загрузить, изменить, сохранить, загрузить снова», а не тест ядра.
Две мелочи в этом коде легко сделать неправильно.
pg не бросает исключение на пустой выборке, rows[0] просто undefined, поэтому «не найдено» решает адаптер и возвращает null; ядро при этом договорилось о Order | null, а не об исключении, и это решение записано в порте.
А save через INSERT ... ON CONFLICT DO UPDATE это не «сохранить как получится»: колонки в DO UPDATE перечисляют явно, и новое поле, забытое в этом списке, молча перестаёт обновляться. С Prisma та же ловушка выглядит как разные объекты create и update в upsert. Ловит это тест адаптера «загрузить, изменить, сохранить, загрузить снова».
Две мелочи в этом коде легко сделать неправильно.
session.get возвращает None, если строки нет, и «не найдено» решает адаптер; ядро при этом договорилось об Order | None, а не об исключении, и это решение записано в протоколе порта.
А merge это не add: объект, собранный преобразователем вручную, сессии не принадлежит, и add попытается вставить его заново. merge копирует состояние в объект сессии и возвращает его, поэтому дальше работают с возвращённым значением, а не с исходным. С «голым» psycopg то же самое пишут как INSERT ... ON CONFLICT DO UPDATE с явным списком колонок.
Маппинг между доменной моделью и строкой в БД — ответственность адаптера. Домен не знает про детали хранения.
Входящий адаптер: контроллер
Контроллер — тонкая прослойка между HTTP и бизнес-логикой:
@RestController
@RequiredArgsConstructor
public class OrderController {
private final CreateOrderCommandHandler createOrderHandler;
private final OrderHttpMapper mapper;
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
CreateOrderCommand command = mapper.toCommand(request);
Order result = createOrderHandler.handle(command);
return ResponseEntity.ok(mapper.toResponse(result));
}
}
type OrderHandler struct {
createOrder *CreateOrderCommandHandler
mapper OrderHTTPMapper
}
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
result, err := h.createOrder.Handle(r.Context(), h.mapper.ToCommand(req))
if err != nil {
writeError(w, err) // перевод доменной ошибки в код ответа — во входящем адаптере
return
}
writeJSON(w, http.StatusOK, h.mapper.ToResponse(result))
}
export function orderRoutes(app: FastifyInstance, createOrder: CreateOrderCommandHandler, mapper: OrderHttpMapper) {
app.post<{ Body: CreateOrderRequest }>('/orders', async (request, reply) => {
const command = mapper.toCommand(request.body);
const result = await createOrder.handle(command);
return reply.send(mapper.toResponse(result));
});
}
@router.post("/orders")
def create_order(
request: CreateOrderRequest,
handler: CreateOrderCommandHandler = Depends(create_order_handler),
) -> OrderResponse:
command = mapper.to_command(request)
result = handler.handle(command)
return mapper.to_response(result)
Никакой бизнес-логики. Контроллер принимает HTTP, собирает команду, вызывает обработчик, возвращает JSON.
Обработчик (Use Case)
Вместо одного сервиса на 30 методов — каждая операция в отдельном классе:
@Component
@RequiredArgsConstructor
public class CreateOrderCommandHandler {
private final InventoryPort inventoryPort;
private final OrderRepository orderRepository;
@Transactional
public Order handle(CreateOrderCommand command) {
InventoryReservation reservation = inventoryPort.reserveItems(command.getItems());
Order order = OrderFactory.createFromReservation(command.getCustomerId(), reservation);
return orderRepository.save(order);
}
}
type CreateOrderCommandHandler struct {
inventory InventoryPort
orders OrderRepository
tx TxRunner // граница транзакции — см. раздел ниже
}
func (h *CreateOrderCommandHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (*Order, error) {
var order *Order
err := h.tx.RunInTx(ctx, func(ctx context.Context) error {
reservation, err := h.inventory.ReserveItems(ctx, cmd.Items)
if err != nil {
return err
}
order = NewOrderFromReservation(cmd.CustomerID, reservation)
return h.orders.Save(ctx, order)
})
return order, err
}
export class CreateOrderCommandHandler {
constructor(
private readonly inventory: InventoryPort,
private readonly orders: OrderRepository,
private readonly tx: TxRunner, // граница транзакции — см. раздел ниже
) {}
handle(command: CreateOrderCommand): Promise<Order> {
return this.tx.runInTx(async () => {
const reservation = await this.inventory.reserveItems(command.items);
const order = OrderFactory.createFromReservation(command.customerId, reservation);
return this.orders.save(order);
});
}
}
class CreateOrderCommandHandler:
def __init__(self, inventory: InventoryPort, uow: UnitOfWork):
self._inventory, self._uow = inventory, uow
def handle(self, command: CreateOrderCommand) -> Order:
with self._uow: # граница транзакции — см. раздел ниже
reservation = self._inventory.reserve_items(command.items)
order = OrderFactory.create_from_reservation(command.customer_id, reservation)
saved = self._uow.orders.save(order)
self._uow.commit()
return saved
Обработчик оркестрирует: вызывает порты, создаёт доменные объекты, сохраняет. Сама бизнес-логика при этом живёт внутри Order.
Структура модулей
В трёхслойной архитектуре слои разделены пакетами — ничто не мешает контроллеру обратиться к репозиторию напрямую. В гексагональной границы физические: отдельные модули сборки (Gradle или Maven в Java, пакеты с internal/ или модули рабочей области в Go, пакеты рабочей области npm в Node, пакеты uv в Python).
orders-service/
├── core/ # Домен: сущности, порты, use cases
├── persistence/ # Адаптер: PostgreSQL
├── payment-out/ # Адаптер: платёжный шлюз
├── rest-api/ # Адаптер: REST-контроллеры
└── bootstrap/ # Точка входа: конфигурация, сборка
Хотите заменить платёжного провайдера? Пишете новый payment-out модуль, реализуете тот же PaymentPort — и всё. Домен и остальные адаптеры не меняются.
Стрелка читается «зависит от»: адаптеры видят только ядро, модуль сборки видит всех, а из ядра не выходит ни одной стрелки.
Чем граница держится физически
Дерево модулей выше — это картинка, а сила гексагональной раскладки в том, что границу нельзя обойти, и держится это настройками сборки. Вот они, по одной строке на модуль:
// core/build.gradle.kts — не зависит ни от кого
dependencies {
// пусто: ни Spring, ни базы, ни соседних модулей
testImplementation(libs.junit.jupiter)
testImplementation(libs.assertj)
}
// persistence/build.gradle.kts — видит ядро, но не другие адаптеры
dependencies {
implementation(project(":core"))
implementation(libs.jooq)
}
// rest-api/build.gradle.kts — видит ядро, но не persistence
dependencies {
implementation(project(":core"))
implementation(libs.spring.boot.starter.web)
}
// bootstrap/build.gradle.kts — видит всех
dependencies {
implementation(project(":core"))
implementation(project(":persistence"))
implementation(project(":rest-api"))
implementation(libs.spring.boot.starter)
}
Что физически мешает адаптеру увидеть другой адаптер: у него этого модуля нет в зависимостях. Попытка импортировать класс из persistence в rest-api не компилируется — не «не по правилам», а буквально не собирается. Это и есть разница между соглашением и границей.
Почему implementation, а не api. Ключевая деталь, которую легко упустить: implementation(project(":core")) означает, что зависимость не протекает дальше. Модуль сборки, подключив rest-api, не получает автоматически доступ к классам core — ему нужно объявить core самому. Если поставить api, границы начнут просачиваться транзитивно, и через полгода кто-нибудь случайно импортирует класс persistence в контроллер, потому что он «оказался доступен».
Две вещи, которые ломают эту схему чаще всего. Первая: плагин сборки приложения, применённый не только в модуле сборки — тогда у каждого модуля включается сборка исполняемого архива и выключается обычная библиотечная, и подключить его как зависимость нельзя. Вторая: общий модуль common, в который постепенно уезжает всё, что нужно двоим, — и через полгода он зависит от всего, а все зависят от него. Разбор обеих — в статье про раскладку модулей (Java, Go, Node, Python).
И главная проверка того, что раскладка настоящая: core собирается и его тесты проходят без всего остального. Если для сборки ядра нужен модуль сборки — граница уже нарушена.
В Go границу держат две вещи. Видимость internal/: пакеты под core/internal/ не может импортировать никто снаружи core, и это проверяет сам компилятор. И правила импортов: то, что ядро не импортирует драйверы и адаптеры, компилятор не запретит, поэтому правило записывают линтеру, по одному на модуль:
# .golangci.yml — depguard: кому что можно импортировать
version: "2"
linters:
settings:
depguard:
rules:
core:
files: ["**/core/**"]
deny:
- pkg: github.com/jackc/pgx
desc: ядро не знает про базу
- pkg: net/http
desc: ядро не знает про HTTP
- pkg: orders-service/adapters
desc: ядро не зависит от адаптеров
adapters:
files: ["**/adapters/**"]
deny:
- pkg: orders-service/adapters
desc: адаптеры не видят друг друга, только ядро
Раскладка на отдельные модули с go.work даёт то же самое сборкой: у core/go.mod нет зависимостей вовсе, persistence/go.mod требует core, а bootstrap собирает всё. Цена — версии и директивы replace на каждый модуль, поэтому чаще берут один модуль с internal/ и линтером. Проверка, что граница держится, та же: go build ./core/... && go test ./core/... проходят без базы и без адаптеров.
В Node границу держат пакеты рабочей области и правила импортов. В package.json у packages/core пустой dependencies, у packages/persistence в зависимостях только @orders/core и pg, у packages/bootstrap все остальные. Но пакет в монорепозитории всё равно можно импортировать относительным путём в обход зависимостей, поэтому правило проверяют линтером графа:
// .dependency-cruiser.cjs — ядро не импортирует адаптеры и инфраструктуру
module.exports = {
forbidden: [
{
name: 'core-is-pure',
severity: 'error',
from: { path: '^packages/core' },
to: { path: '^packages/(persistence|payment-out|rest-api|bootstrap)|^node_modules/(pg|fastify|@prisma)' },
},
{
name: 'adapters-do-not-see-each-other',
severity: 'error',
from: { path: '^packages/(persistence|payment-out|rest-api)' },
to: { path: '^packages/(persistence|payment-out|rest-api)', pathNot: '^packages/persistence.*persistence' },
},
],
};
Проверка, что граница держится: npm test --workspace @orders/core проходит без базы и без адаптеров, а npx depcruise packages зелёный в конвейере.
В Python границу держат пакеты рабочей области uv и контракты импортов. У packages/core/pyproject.toml пустой список зависимостей, packages/persistence зависит от orders-core и sqlalchemy, bootstrap от всех. Импортировать что угодно внутри одного окружения всё равно можно, поэтому правило проверяют import-linter в конвейере:
# .importlinter — ядро не импортирует адаптеры и инфраструктуру
[importlinter]
root_packages =
orders_core
orders_persistence
orders_rest
orders_bootstrap
[importlinter:contract:core-is-pure]
name = ядро без инфраструктуры
type = forbidden
source_modules = orders_core
forbidden_modules =
orders_persistence
orders_rest
orders_bootstrap
sqlalchemy
fastapi
[importlinter:contract:adapters-independent]
name = адаптеры не видят друг друга
type = independence
modules =
orders_persistence
orders_rest
Проверка, что граница держится: uv run --package orders-core pytest проходит без базы и без адаптеров, а lint-imports зелёный в конвейере.
Главный выигрыш: тестирование
Изоляция домена напрямую влияет на скорость и простоту тестов.
Тест доменной модели запускается за миллисекунды, без базы данных:
class OrderTest {
@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 calculatesTotalAmount() {
Order order = testOrderWithItems(Money.of(300), Money.of(200));
assertThat(order.calculateTotalAmount().amount())
.isEqualByComparingTo("500.00");
}
}
func TestOrder_PreventsDoublePayment(t *testing.T) {
order := testOrder()
err := order.CreatePayment("ORD-1", MoneyOf(500), uuid.New(), PaymentRegistered, "https://pay.example.com")
require.NoError(t, err)
err = order.CreatePayment("ORD-2", MoneyOf(500), uuid.New(), PaymentRegistered, "https://pay.example.com")
require.ErrorIs(t, err, ErrPaymentAlreadyExists)
}
func TestOrder_CalculatesTotalAmount(t *testing.T) {
order := testOrderWithItems(MoneyOf(300), MoneyOf(200))
require.Equal(t, MoneyOf(500), order.TotalAmount())
}
describe('Order', () => {
it('prevents double payment', () => {
const order = testOrder();
order.createPayment('ORD-1', Money.of(500), randomUUID(), PaymentStatus.REGISTERED, 'https://pay.example.com');
expect(() => order.createPayment('ORD-2', Money.of(500), randomUUID(), PaymentStatus.REGISTERED, 'https://pay.example.com'))
.toThrow(PaymentAlreadyExists);
});
it('calculates total amount', () => {
const order = testOrderWithItems(Money.of(300), Money.of(200));
expect(order.calculateTotalAmount()).toEqual(Money.of(500));
});
});
def test_prevents_double_payment():
order = test_order()
order.create_payment("ORD-1", Money.of(500), uuid4(), PaymentStatus.REGISTERED, "https://pay.example.com")
with pytest.raises(PaymentAlreadyExists):
order.create_payment("ORD-2", Money.of(500), uuid4(), PaymentStatus.REGISTERED, "https://pay.example.com")
def test_calculates_total_amount():
order = test_order_with_items(Money.of(300), Money.of(200))
assert order.calculate_total_amount() == Money.of(500)
Тест use case мокирует только порты:
class CreateOrderCommandHandlerTest {
private final InventoryPort inventory = mock(InventoryPort.class);
private final OrderRepository orders = mock(OrderRepository.class);
private final CreateOrderCommandHandler handler =
new CreateOrderCommandHandler(inventory, orders);
@Test
void createsOrderFromReservation() {
when(inventory.reserveItems(any())).thenReturn(testReservation());
when(orders.save(any())).thenAnswer(inv -> inv.getArgument(0));
CreateOrderCommand command =
new CreateOrderCommand(new CustomerId(42L), List.of(testItem()));
Order result = handler.handle(command);
assertThat(result.getStatus()).isEqualTo(OrderStatus.NEW);
verify(orders).save(any());
}
}
В Go порты подменяют не библиотекой моков, а заглушкой в десять строк рядом с тестом: она компилируется как обычный тип, и по ней видно, что именно вернул порт:
type inventoryStub struct{ reservation InventoryReservation }
func (s inventoryStub) ReserveItems(context.Context, []OrderItemRequest) (InventoryReservation, error) {
return s.reservation, nil
}
type ordersSpy struct{ saved []*Order }
func (s *ordersSpy) FindByID(context.Context, OrderID) (*Order, error) { return nil, ErrOrderNotFound }
func (s *ordersSpy) Save(_ context.Context, o *Order) error {
s.saved = append(s.saved, o)
return nil
}
func TestCreateOrder_FromReservation(t *testing.T) {
orders := &ordersSpy{}
handler := &CreateOrderCommandHandler{inventory: inventoryStub{testReservation()}, orders: orders, tx: noTx{}}
result, err := handler.Handle(context.Background(), CreateOrderCommand{CustomerID: 42, Items: []OrderItemRequest{testItem()}})
require.NoError(t, err)
require.Equal(t, OrderNew, result.Status())
require.Len(t, orders.saved, 1)
}
describe('CreateOrderCommandHandler', () => {
const inventory: InventoryPort = { reserveItems: vi.fn().mockResolvedValue(testReservation()) };
const orders: OrderRepository = { findById: vi.fn(), save: vi.fn(async (o) => o) };
const handler = new CreateOrderCommandHandler(inventory, orders, noTx);
it('creates order from reservation', async () => {
const result = await handler.handle({ customerId: 42, items: [testItem()] });
expect(result.status).toBe(OrderStatus.NEW);
expect(orders.save).toHaveBeenCalledTimes(1);
});
});
def test_creates_order_from_reservation():
inventory = Mock(spec=InventoryPort)
inventory.reserve_items.return_value = test_reservation()
uow = FakeUnitOfWork() # заглушка: репозиторий в памяти и флаг commit
handler = CreateOrderCommandHandler(inventory, uow)
result = handler.handle(CreateOrderCommand(customer_id=42, items=[test_item()]))
assert result.status is OrderStatus.NEW
assert uow.orders.saved == [result] and uow.committed
Порты — интерфейсы, поэтому подменяются стандартными средствами: библиотекой моков или заглушкой в десять строк. Адаптеры тестируются отдельно: репозиторий — с реальной БД через Testcontainers (есть для всех четырёх языков), HTTP-адаптер — через заглушку (WireMock, httptest.Server в Go, nock или msw в Node, respx в Python).
Частые ошибки
Анемичная модель. Когда Order — просто набор полей с геттерами/сеттерами, а вся логика вынесена в сервис — это нарушение идеи. Бизнес-правила должны жить внутри агрегата:
// Плохо: логика снаружи, модель — контейнер данных
order.setStatus(OrderStatus.CANCELLED); // кто угодно, без проверок
// Хорошо: модель защищает инварианты
order.cancel(reservation); // внутри — проверки, побочные эффекты
// Плохо: логика снаружи, модель — контейнер данных
order.Status = OrderCancelled // кто угодно, без проверок
// Хорошо: модель защищает инварианты
order.Cancel(reservation) // внутри — проверки, побочные эффекты
// Плохо: логика снаружи, модель — контейнер данных
order.status = OrderStatus.CANCELLED; // кто угодно, без проверок
// Хорошо: модель защищает инварианты
order.cancel(reservation); // внутри — проверки, побочные эффекты
# Плохо: логика снаружи, модель — контейнер данных
order.status = OrderStatus.CANCELLED # кто угодно, без проверок
# Хорошо: модель защищает инварианты
order.cancel(reservation) # внутри — проверки, побочные эффекты
Инфраструктурные пометки в домене. Если видите @Entity, @Table, @JsonProperty в доменном классе, теги db и json на полях доменной структуры Go, модель ORM или декораторы вместо доменного типа — граница нарушена. Разметка хранения и JSON живёт в адаптерах, не в домене.
Доменные исключения наследуют инфраструктурные классы. OrderNotFoundException extends ResponseStatusException или доменная ошибка с полем «HTTP-статус» внутри — плохо: домен узнал про HTTP. Доменные исключения чистые, маппинг в HTTP-коды происходит в адаптере:
// Домен — чистое исключение
public static final class NotFound extends OrderException {
public NotFound(String message) { super(message); }
}
// Адаптер — маппинг в HTTP
@ExceptionHandler(OrderException.NotFound.class)
public ResponseEntity<ErrorResponse> handle(OrderException.NotFound ex) {
return ResponseEntity.status(404).body(new ErrorResponse(ex.getMessage()));
}
// Домен — чистая ошибка
var ErrOrderNotFound = errors.New("order not found")
// Адаптер — перевод в HTTP
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrOrderNotFound):
writeJSON(w, http.StatusNotFound, ErrorResponse{Message: err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Message: "internal error"})
}
}
// Домен — чистая ошибка
export class OrderNotFound extends OrderError {}
// Адаптер — перевод в HTTP
app.setErrorHandler((error, request, reply) => {
if (error instanceof OrderNotFound) return reply.status(404).send({ message: error.message });
return reply.status(500).send({ message: 'internal error' });
});
# Домен — чистое исключение
class OrderNotFound(OrderError):
pass
# Адаптер — перевод в HTTP
@app.exception_handler(OrderNotFound)
def order_not_found(request: Request, exc: OrderNotFound) -> JSONResponse:
return JSONResponse(status_code=404, content={"message": str(exc)})
Когда это нужно, а когда избыточно
Гексагональная архитектура хорошо подходит, когда:
- есть сложная бизнес-логика с правилами и инвариантами;
- несколько входных каналов (REST + Admin API + планировщик);
- интеграции с внешними системами, которые могут меняться;
- команда больше трёх-четырёх разработчиков и проект рассчитан на годы.
Избыточна для:
- простых CRUD-сервисов без бизнес-логики;
- прототипов и одноразовых скриптов;
- микросервисов-прокси, которые только пересылают данные.
Чем платят: счёт в файлах
Общие слова про «лишний слой интерфейсов» стоит превратить в счёт, потому что решение принимают по нему. Добавляем одно поле («комментарий к заказу») и считаем, что придётся тронуть.
В трёхслойном сервисе — 3 файла: сущность, структура ответа, миграция. Иногда четыре, если есть отдельная структура запроса.
В гексагональной раскладке — 7–9 файлов:
- доменный объект в ядре;
- команда (структура входа) в ядре;
- обработчик, если поле участвует в правилах;
- структура ответа чтения в ядре;
- структура запроса или ответа входящего адаптера;
- преобразователь входящего адаптера;
- запись базы и преобразователь в модуле хранения (плюс сгенерированные классы, если генерируете);
- миграция;
- тесты преобразователей, если они у вас есть.
Разница — в два-три раза по числу файлов на простое изменение. Это и есть настоящая цена, и она платится на каждой задаче, а не один раз при заводе проекта.
Что покупается за эти деньги: тесты правил без базы и контекста (секунды вместо минут на прогон), возможность заменить хранилище или внешнего поставщика, не трогая правила, невозможность случайно смешать слои (граница не собирается), и понятное место для каждого вида кода при работе нескольких команд.
Когда сделка выгодна: правил много и они меняются; прогон тестов измеряется минутами и это мешает; входов больше одного; внешние системы реально меняются; проект живёт годами. Когда нет: правил мало, входов один, хранилище не изменится, проект на квартал — тогда два-три лишних файла на каждое поле не возвращаются ничем.
И промежуточный вариант, который стоит знать: раскладка пакетами без отдельных модулей сборки. Те же границы, тот же порядок зависимостей, но проверяется не сборкой, а тестом архитектуры (Java, Go, Node, Python). Цена падает почти до трёхслойной (нет лишних преобразователей между модулями), и большая часть пользы остаётся. Подробный разбор выбора между раскладками — в статье о том, когда брать гексагональную архитектуру (Java, Go, Node, Python).
Глубже: входящий порт: где он и нужен лирасширенное
Схема рисует порты по обе стороны шестиугольника, а в примере контроллер зовёт обработчик команды напрямую — то есть конкретный класс. Это не недосмотр примера, а реальная развилка, и её стоит проговорить.
Что такое входящий порт. Интерфейс, который объявляет ядро для входа: «меня можно попросить оформить заказ». Адаптер зависит от интерфейса, а не от класса:
// core/port/in/PlaceOrderUseCase.java — объявлено ядром
public interface PlaceOrderUseCase {
OrderId place(PlaceOrderCommand command);
}
// core/application/PlaceOrderService.java — реализация внутри ядра
class PlaceOrderService implements PlaceOrderUseCase { ... }
// rest-api/OrderController.java — адаптер знает только интерфейс
@RestController
class OrderController {
private final PlaceOrderUseCase placeOrder; // не PlaceOrderService
}
// core/port/in — объявлено ядром
type PlaceOrderUseCase interface {
Place(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error)
}
// core/application — реализация внутри ядра
type placeOrderService struct{ /* ... */ }
func (s *placeOrderService) Place(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error) { /* ... */ }
// adapters/rest — адаптер знает только интерфейс
type OrderHandler struct {
placeOrder PlaceOrderUseCase // не *placeOrderService
}
// core/port/in — объявлено ядром
export interface PlaceOrderUseCase {
place(command: PlaceOrderCommand): Promise<OrderId>;
}
// core/application — реализация внутри ядра
class PlaceOrderService implements PlaceOrderUseCase { /* ... */ }
// adapters/rest — адаптер знает только интерфейс
export function orderRoutes(app: FastifyInstance, placeOrder: PlaceOrderUseCase /* не PlaceOrderService */) { /* ... */ }
# core/port/in — объявлено ядром
class PlaceOrderUseCase(Protocol):
def place(self, command: PlaceOrderCommand) -> OrderId: ...
# core/application — реализация внутри ядра
class PlaceOrderService: # структурно совпадает с протоколом
def place(self, command: PlaceOrderCommand) -> OrderId: ...
# adapters/rest — адаптер знает только протокол
def order_routes(place_order: PlaceOrderUseCase) -> APIRouter: ... # не PlaceOrderService
Что это даёт по сравнению с прямым вызовом обработчика. Честно: немного, и в этом всё дело.
- Симметрия правила зависимостей. Адаптер зависит от интерфейса ядра, а не от его реализации; ядро не показывает наружу внутреннее устройство. Это то, ради чего архитектуру и называют симметричной.
- Список входов читается в одном месте. Пакет
port/in— это перечень всего, что сервис умеет делать, без чтения реализаций. - Подмена в тестах адаптера. Тест контроллера подставляет заглушку интерфейса — но это работает и с классом, если у него нет лишних зависимостей.
Чего это не даёт, хотя часто ожидают: второй реализации (её почти никогда не бывает — один вариант «оформить заказ» на систему) и изоляции от изменений (изменился смысл операции — меняются оба).
Цена. Интерфейс на каждую операцию: тридцать операций — тридцать лишних файлов, каждый из одного метода, и переход по коду теперь идёт через интерфейс.
Практическая раскладка, которую выбирают команды. Три варианта, все осмысленные:
- Интерфейс на каждую операцию — каноническая форма. Берут, когда операций немного, или когда правило зависимостей проверяется машинно и «адаптер видит только
port.in» — часть проверки. - Общий маркерный интерфейс (
UseCaseHandler<C, R>) и обобщённый диспетчер: адаптер зависит от диспетчера, а не от каждого обработчика. Так устроен подход в разделе про CQRS: типы команд и запросов и есть входные порты, а отдельные интерфейсы не нужны. - Прямой вызов класса обработчика, как в примере выше. Правило зависимостей формально соблюдено (адаптер зависит от ядра, ядро от адаптера — нет), а лишних файлов нет. Это то, что на практике встречается чаще всего, и называть это ошибкой неверно — но стоит знать, что вы выбрали.
Что действительно не годится ни в одном варианте: адаптер, который лезет в доменные объекты мимо обработчика, и обработчик, который знает про адаптер.
Глубже: транзакция: ядро без фреймворка и без аннотации транзакциирасширенное
Внимательный читатель уже заметил противоречие: на обработчике в ядре стоит @Transactional, а ядро «не знает о Spring». Оба утверждения не могут быть верны одновременно, и вот как это разрешают — три варианта по возрастанию чистоты.
1. Аннотация в ядре, и это осознанный компромисс. Ядро зависит от одной аннотации управления транзакциями — и больше ни от чего. Аннотация не тянет за собой контекст, тесты ядра её просто игнорируют, а читаемость выигрывает: видно, где граница транзакции. Большинство проектов живёт так, и это нормальный выбор, если он назван. В тесте архитектуры тогда делают явное исключение для одного импорта.
2. Транзакция снаружи ядра. Обработчик чистый, а границу транзакции ставит тонкая обёртка в модуле сборки:
// bootstrap: обёртка вокруг обработчика
@Bean
PlaceOrderUseCase placeOrderUseCase(OrderRepository orders, OutboxPort outbox) {
var service = new PlaceOrderService(orders, outbox);
return transactional(service::place); // граница транзакции здесь
}
Ядро идеально чистое, цена — фабрики бинов и то, что граница транзакции больше не видна в коде операции. Реальный выбор для команд, у которых чистота ядра проверяется жёстко.
3. Транзакция как порт. Ядро объявляет TransactionPort с методом «выполни это в транзакции», адаптер реализует. Самый «правильный» и самый шумный вариант: каждая операция обёрнута лямбдой. Берут редко — обычно когда транзакции нужны в необычной форме (несколько разных менеджеров, ручные точки сохранения).
Чего делать не стоит ни в одном варианте: ставить транзакцию на адаптере (контроллер открывает транзакцию, а операция выполняется внутри) — тогда транзакция живёт дольше, чем нужно, и включает в себя преобразование ответа и сериализацию. Граница транзакции — это граница операции ядра, и она должна совпадать с обработчиком.
В Go этого противоречия нет: аннотаций нет, транзакция всегда явная, и вопрос звучит иначе — кто её открывает и как соединение доезжает до репозиториев. Два рабочих варианта.
1. Транзакция как порт. Ядро объявляет TxRunner с одним методом «выполни это в транзакции», обработчик оборачивает им сценарий (так сделано в CreateOrderCommandHandler выше), а адаптер на pgx реализует его через pgx.BeginFunc и кладёт pgx.Tx в контекст, откуда её достают репозитории того же адаптера:
// core: порт
type TxRunner interface {
RunInTx(ctx context.Context, fn func(ctx context.Context) error) error
}
// persistence: реализация на pgx
func (r PgxTxRunner) RunInTx(ctx context.Context, fn func(context.Context) error) error {
return pgx.BeginFunc(ctx, r.pool, func(tx pgx.Tx) error {
return fn(context.WithValue(ctx, txKey{}, tx))
})
}
Это самый частый вариант в Go, и его цена та же, что у «транзакции как порта» в других языках: каждая операция обёрнута замыканием, зато граница видна в коде обработчика, а тест ядра подставляет noTx, который просто вызывает функцию.
2. Обёртка в main. Обработчик чистый и о транзакции не знает, а границу ставит декоратор вокруг входящего порта, собранный в точке входа:
// main: обёртка вокруг обработчика
func newPlaceOrderUseCase(pool *pgxpool.Pool, orders OrderRepository, outbox OutboxPort) PlaceOrderUseCase {
service := &placeOrderService{orders: orders, outbox: outbox}
return transactional{pool: pool, next: service} // граница транзакции здесь
}
func (t transactional) Place(ctx context.Context, cmd PlaceOrderCommand) (id OrderID, err error) {
err = pgx.BeginFunc(ctx, t.pool, func(tx pgx.Tx) error {
id, err = t.next.Place(context.WithValue(ctx, txKey{}, tx), cmd)
return err
})
return id, err
}
Ядро идеально чистое, цена — по тексту обработчика больше нельзя сказать, атомарен ли он, и обёртку легко забыть у нового входа.
Чего делать не стоит ни в одном варианте: ставить транзакцию на адаптере (контроллер открывает транзакцию, а операция выполняется внутри) — тогда транзакция живёт дольше, чем нужно, и включает в себя преобразование ответа и сериализацию. Граница транзакции — это граница операции ядра, и она должна совпадать с обработчиком.
В Node декораторов по умолчанию нет; в NestJS с TypeORM есть @Transactional из сторонней библиотеки, и тогда компромисс ровно тот же, что в Java: одна аннотация в ядре, названная явно. Без декораторов два рабочих варианта.
1. Транзакция как порт. Ядро объявляет TxRunner с методом runInTx(fn), обработчик оборачивает им сценарий (так сделано в CreateOrderCommandHandler выше), а адаптер на pg открывает транзакцию и кладёт клиента в AsyncLocalStorage, откуда его берут репозитории того же адаптера:
// core: порт
export interface TxRunner {
runInTx<T>(fn: () => Promise<T>): Promise<T>;
}
// persistence: реализация на pg и AsyncLocalStorage
export class PgTxRunner implements TxRunner {
constructor(private readonly pool: Pool, private readonly storage: AsyncLocalStorage<PoolClient>) {}
async runInTx<T>(fn: () => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await this.storage.run(client, fn); // репозитории берут client из storage
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
}
Prisma даёт $transaction(async (tx) => ...), и это тот же порт, только клиента транзакции репозиториям передают аргументом, а не через AsyncLocalStorage.
2. Обёртка в точке сборки. Обработчик чистый, а границу ставит декоратор вокруг входящего порта, собранный там, где создаются объекты: placeOrder = transactional(txRunner, new PlaceOrderService(orders, outbox)). Ядро идеально чистое, цена — по тексту обработчика больше нельзя сказать, атомарен ли он.
Чего делать не стоит ни в одном варианте: ставить транзакцию на адаптере (контроллер открывает транзакцию, а операция выполняется внутри) — тогда транзакция живёт дольше, чем нужно, и включает в себя преобразование ответа и сериализацию. Граница транзакции — это граница операции ядра, и она должна совпадать с обработчиком.
В Python роль аннотации играет with session.begin():, и если он стоит в обработчике, ядро знает про SQLAlchemy — это вариант «осознанный компромисс», и его надо назвать. Чище два других.
1. Единица работы как порт. Ядро объявляет протокол UnitOfWork с __enter__, __exit__ и commit, репозитории берутся из него же, а адаптер реализует его на сессии SQLAlchemy (так сделано в CreateOrderCommandHandler выше):
# core: порт
class UnitOfWork(Protocol):
orders: OrderRepository
def __enter__(self) -> "UnitOfWork": ...
def __exit__(self, *exc: object) -> None: ...
def commit(self) -> None: ...
# core: обработчик
def place_order(cmd: PlaceOrderCommand, uow: UnitOfWork) -> OrderId:
with uow:
order = Order.place(cmd.lines)
uow.orders.save(order)
uow.commit() # граница транзакции видна в операции
return order.id
Выход из with без commit откатывает транзакцию, поэтому забытый commit виден в первом же тесте. В тесте ядра вместо сессии подставляют FakeUnitOfWork с репозиторием в памяти.
2. Обёртка в точке сборки. Обработчик чистый, а границу ставит декоратор, который открывает сессию вокруг вызова и собирается там, где создаются объекты. Ядро идеально чистое, цена — по тексту обработчика больше нельзя сказать, атомарен ли он.
Чего делать не стоит ни в одном варианте: ставить транзакцию на адаптере (контроллер открывает транзакцию, а операция выполняется внутри) — тогда транзакция живёт дольше, чем нужно, и включает в себя преобразование ответа и сериализацию. Граница транзакции — это граница операции ядра, и она должна совпадать с обработчиком.
Глубже: исключение прилетело из адаптерарасширенное
Про исключения обычно говорят одно: доменные не должны наследовать инфраструктурные. Обратное направление — что делать, когда из адаптера летит техническое исключение, — встречается в работе чаще, а разобрано реже.
Сценарий: репозиторий вставляет заказ, база отвечает нарушением уникального ограничения, и наружу летит исключение библиотеки доступа к данным. Оно пролетает через ядро (которое про эту библиотеку знать не должно) и доезжает до контроллера, где превращается в «500 Внутренняя ошибка» — хотя по смыслу это «такой заказ уже есть» и код 409.
Правило: техническое исключение не покидает адаптер. Адаптер — единственное место, где известно, что означает конкретный код ошибки базы или конкретный ответ платёжного шлюза. Он и переводит:
// persistence: адаптер переводит техническое в доменное
@Override
public void save(Order order) {
try {
dsl.insertInto(ORDERS)...execute();
} catch (DuplicateKeyException e) { // техническое, знает только адаптер
throw new OrderAlreadyExistsException(order.id()); // доменное, объявлено в ядре
} catch (DataAccessException e) {
throw new StorageUnavailableException(e); // тоже доменное, но про инфраструктуру
}
}
// persistence: адаптер переводит техническое в доменное
func (r *PgxOrderRepository) Save(ctx context.Context, order *Order) error {
_, err := r.pool.Exec(ctx, `INSERT INTO orders ...`, order.ID())
var pgErr *pgconn.PgError
switch {
case errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation: // техническое, знает только адаптер
return &OrderAlreadyExistsError{ID: order.ID()} // доменное, объявлено в ядре
case err != nil:
return fmt.Errorf("%w: %w", ErrStorageUnavailable, err) // тоже доменное, но про инфраструктуру
}
return nil
}
// persistence: адаптер переводит техническое в доменное
async save(order: Order): Promise<void> {
try {
await this.pool.query('INSERT INTO orders ...', [/* ... */]);
} catch (e) {
if (isDatabaseError(e) && e.code === '23505') { // техническое, знает только адаптер
throw new OrderAlreadyExistsError(order.id); // доменное, объявлено в ядре
}
throw new StorageUnavailableError(e); // тоже доменное, но про инфраструктуру
}
}
# persistence: адаптер переводит техническое в доменное
def save(self, order: Order) -> None:
try:
self._session.execute(insert(orders).values(...))
self._session.flush()
except IntegrityError as e: # техническое, знает только адаптер
if isinstance(e.orig, errors.UniqueViolation):
raise OrderAlreadyExistsError(order.id) from e # доменное, объявлено в ядре
raise
except OperationalError as e:
raise StorageUnavailableError() from e # тоже доменное, но про инфраструктуру
Три категории, на которые стоит разложить всё, что летит из адаптеров:
- Нарушение бизнес-правила, выраженное базой (уникальность, внешний ключ, проверка) — переводится в доменное исключение с понятным смыслом. Дальше его обрабатывает ядро или контроллер отдаёт
409/422. - Временная недоступность (сеть, таймаут, отказ соединения, блокировка) — переводится в исключение вида «хранилище недоступно»: ядро может решить повторить или отдать понятный отказ, контроллер отдаёт
503. - Наша ошибка (неверный запрос, отсутствующая таблица, ошибка преобразования) — это дефект, и его не надо переводить в доменное: пусть летит как есть и попадает в общий обработчик как
500. Заворачивать дефекты в красивые доменные исключения вредно — они прячут поломку.
Где исключение превращается в код ответа. Не в ядре и не в адаптере, а в отдельном обработчике на входе (в Spring это класс с @RestControllerAdvice, в Go функция writeError или промежуточный обработчик во входящем адаптере, в Fastify setErrorHandler, в FastAPI exception_handler), который живёт во входящем адаптере: он знает про HTTP, ядро — нет. Отсюда и запрет, о котором спрашивают чаще всего: доменное исключение не наследует классы с HTTP-кодами и не несёт статус в себе — иначе ядро начинает знать про HTTP, и то же исключение нельзя использовать в обработчике очереди, где HTTP нет вовсе. Разбор — в статье про обработку ошибок.
И практическая мелочь, которая экономит часы: в переведённом исключении сохраняют причину (исходное исключение как причина), иначе в журнале останется «заказ уже существует» без всякого следа того, какое именно ограничение сработало.
Коротко
- Гексагональная архитектура = домен в центре, инфраструктура снаружи через адаптеры. Порт — интерфейс, который домен определяет сам («мне нужно сохранить заказ»).
- Адаптер — реализация порта для конкретной технологии (PostgreSQL, Stripe, Kafka). Правило зависимостей: адаптеры зависят от домена, домен не зависит ни от чего.
- Домен не импортирует фреймворк, ORM и разметку JSON (Spring, JPA, Jackson в Java; pgx, chi и теги
jsonв Go; Fastify и Prisma в Node; FastAPI и SQLAlchemy в Python). Входящие адаптеры (контроллеры, планировщики) инициируют вызовы в домен. - Исходящие адаптеры (репозитории, HTTP-клиенты) домен вызывает через порты. Главный выигрыш — тесты бизнес-логики без поднятия базы данных.
- Анемичная модель, аннотации ORM в домене и исключения с HTTP-кодами — самые частые ошибки.
- Входящий порт даёт симметрию и список входов в одном месте, но не даёт второй реализации; три рабочих варианта — интерфейс на операцию, общий маркер с диспетчером или прямой вызов обработчика, и выбор надо назвать.
- Противоречие «ядро без фреймворка, но с аннотацией транзакции» разрешают одним из трёх способов: осознанный компромисс на одной аннотации, обёртка в модуле сборки или транзакция как порт; на адаптере транзакцию не открывают.
- Техническое исключение не покидает адаптер: нарушение правила переводится в доменное, недоступность — в исключение про инфраструктуру, дефект летит как есть; в код ответа это превращает обработчик во входящем адаптере, а не ядро.
- Границу держат настройки сборки или правила импортов: у ядра нет зависимостей, у адаптеров только ядро (в Gradle
implementationвместоapi, в Gointernal/и depguard, в Node dependency-cruiser, в Python import-linter); проверка — ядро собирается и тестируется само. - Цена в файлах: одно новое поле — три файла в трёхслойном против семи-девяти в модульной раскладке, и платится это на каждой задаче; промежуточный вариант — те же границы пакетами с проверкой тестом архитектуры.
Что пощупать
Сервис заказов практикума remodov/marketplace-system собран в гексагональной раскладке модулями Gradle: ядро без Spring, входящие адаптеры REST и Kafka, исходящие адаптеры к PostgreSQL, каталогу, платежам и Kafka, точка сборки отдельным модулем. Порты объявлены в ядре, реализованы в адаптерах, а направление зависимостей проверяет тест ArchUnit над всем сервисом.
Код: services/order, ядро, тест архитектуры.
Код на Go: services/order, ядро, тест архитектуры на go/packages: ядро не импортирует chi, pgx и gobreaker, входные адаптеры не знают про выходные.
Сделаем сами
Ветка step-07-usecase-and-handler — смена цены переносится во взрослый каталог: контракт, UseCase, Handler, порт и адаптер, тест из шести проверок красный, условие по ссылке в TASK.md.
Что почитать дальше
- Когда брать гексагональную архитектуру (Java, Go, Node, Python) — признаки «пора» и «рано», цена и три раскладки.
- Раскладка модулей (Java, Go, Node, Python) — зачем модули, а не папки, и как они настраиваются.
- Слой ядра (Java, Go, Node, Python) — что входит в ядро и что в нём запрещено.
- Порты (Java, Go, Node, Python) — где живут интерфейсы, как называются и какого размера.
- Входящие адаптеры (Java, Go, Node, Python) — контроллеры, слушатели очереди, преобразование на входе.
- Исходящие адаптеры (Java, Go, Node, Python) — хранилище и внешние системы за портами.
- Модуль сборки (Java, Go, Node, Python) — где всё связывается вместе.
- Тесты архитектуры (Java, Go, Node, Python) — как границы проверяются машиной.
- CQRS: разделение чтения и записи — как разделить команды и запросы внутри гексагональной структуры.
- Тактические паттерны DDD — Aggregate, Value Object, Domain Event.
- Стандарт гексагональной архитектуры — правила именования, структура модулей, типичные ошибки.