Гексагональная архитектура

Ports & Adapters простыми словами: как отделить бизнес-логику от базы, фреймворка и внешних API. Порты, адаптеры, правило зависимостей и выигрыш в тестах.

Эталонная библиотека к статье hexagonal-architecture (annotations + ArchUnit)

← назад к разделу

Когда проект маленький, достаточно трёх слоёв: контроллер → сервис → репозиторий. Но с ростом проекта одна проблема начинает повторяться снова и снова: бизнес-логика оказывается перемешана с инфраструктурой. Сервис знает про 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-клиента;
  • замена платёжного провайдера затрагивает сам сервис с бизнес-логикой;
  • бизнес-правила читаются между строк с вызовами инфраструктуры.

Ключевая идея: домен в центре

Гексагональная архитектура разделяет всё на три зоны:

домен заказов порт REST-контроллер браузер порт потребитель Kafka события порт планировщик расписание порт репозиторий PostgreSQL порт платёжный адаптер шлюз оплаты порт отправка событий Kafka снаружи адаптеры, внутри домен

Домен внутри, всё чужое снаружи, а на границе — порты: домен объявляет, что ему нужно, а как это устроено на самом деле, знает только адаптер. Поменять базу или платёжный шлюз значит переписать один адаптер, а не бизнес-правила.

  • Домен — сущности, бизнес-правила, логика. Не знает ни про базу данных, ни про 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 rest-api контроллеры persistence jOOQ и база payment-out шлюз оплаты bootstrap и все адаптеры

Стрелка читается «зависит от»: адаптеры видят только ядро, модуль сборки видит всех, а из ядра не выходит ни одной стрелки.

Чем граница держится физически

Дерево модулей выше — это картинка, а сила гексагональной раскладки в том, что границу нельзя обойти, и держится это настройками сборки. Вот они, по одной строке на модуль:

// 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 файлов:

  1. доменный объект в ядре;
  2. команда (структура входа) в ядре;
  3. обработчик, если поле участвует в правилах;
  4. структура ответа чтения в ядре;
  5. структура запроса или ответа входящего адаптера;
  6. преобразователь входящего адаптера;
  7. запись базы и преобразователь в модуле хранения (плюс сгенерированные классы, если генерируете);
  8. миграция;
  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 — это перечень всего, что сервис умеет делать, без чтения реализаций.
  • Подмена в тестах адаптера. Тест контроллера подставляет заглушку интерфейса — но это работает и с классом, если у него нет лишних зависимостей.

Чего это не даёт, хотя часто ожидают: второй реализации (её почти никогда не бывает — один вариант «оформить заказ» на систему) и изоляции от изменений (изменился смысл операции — меняются оба).

Цена. Интерфейс на каждую операцию: тридцать операций — тридцать лишних файлов, каждый из одного метода, и переход по коду теперь идёт через интерфейс.

Практическая раскладка, которую выбирают команды. Три варианта, все осмысленные:

  1. Интерфейс на каждую операцию — каноническая форма. Берут, когда операций немного, или когда правило зависимостей проверяется машинно и «адаптер видит только port.in» — часть проверки.
  2. Общий маркерный интерфейс (UseCaseHandler<C, R>) и обобщённый диспетчер: адаптер зависит от диспетчера, а не от каждого обработчика. Так устроен подход в разделе про CQRS: типы команд и запросов и есть входные порты, а отдельные интерфейсы не нужны.
  3. Прямой вызов класса обработчика, как в примере выше. Правило зависимостей формально соблюдено (адаптер зависит от ядра, ядро от адаптера — нет), а лишних файлов нет. Это то, что на практике встречается чаще всего, и называть это ошибкой неверно — но стоит знать, что вы выбрали.

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

Глубже: транзакция: ядро без фреймворка и без аннотации транзакциирасширенное

Внимательный читатель уже заметил противоречие: на обработчике в ядре стоит @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, в Go internal/ и 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.
  • Стандарт гексагональной архитектуры — правила именования, структура модулей, типичные ошибки.