В гексагональной архитектуре есть одно ключевое правило: ядро (core) не должно знать про инфраструктуру. Оно не знает, PostgreSQL у нас или MySQL, Sber или какая-то другая платёжка, Kafka или RabbitMQ. Это позволяет менять инфраструктуру, не трогая бизнес-логику.
Но core всё равно должен куда-то ходить — читать и писать данные, вызывать платёжный шлюз, публиковать события. Как это сделать, не зная про инфраструктуру?
Ответ — ports. Core описывает интерфейс «мне нужно вот это», а конкретная реализация появляется только в адаптере. Зависимости текут от адаптеров к ядру, а не наоборот.
Стрелки смотрят в разные стороны: зовёт ядро, а зависит адаптер, и в этом состоит инверсия зависимостей.
Где живёт port и как называется
Outbound port — это интерфейс в core/<bc>/port/out/. Именно в core/, не в адаптере. Core объявляет, что ему нужно, адаптер это реализует.
core/src/main/java/<pkg>/
└── domain/
└── orders/ # bounded context
├── aggregate/Order.java
├── exception/ # доменные исключения, в том числе для портов
│ ├── PaymentPortException.java
│ └── PaymentNotFoundException.java
└── port/out/ # ← port-интерфейсы здесь
├── OrderRepository.java # работа с агрегатом в БД
├── OrderViewRepository.java # read-проекция для CQRS
├── PaymentPort.java # внешний платёжный шлюз
├── NotificationPort.java # SMS / email
└── OrderEventPublisher.java # исходящие события
Соглашение по именам простое:
| Что делает port | Как называть | Пример |
|---|---|---|
| Сохраняет и читает агрегат | <X>Repository | OrderRepository |
| Возвращает read-проекции (CQRS) | <X>ViewRepository | OrderViewRepository |
| Ходит во внешнюю HTTP-систему | <Y>Port | PaymentPort, SmsPort |
| Публикует события напрямую | <Z>EventPublisher | OrderEventPublisher |
Repository без суффикса Port — историческое соглашение из DDD, имя говорит само за себя. Для всего остального — суффикс Port: сразу видно, что это контракт к внешней системе.
Почему у витрины чтения отдельный порт, а не метод в OrderRepository — вопрос, который возникает сразу и на который есть три причины, а не вкусовщина.
Разные типы на выходе. OrderRepository возвращает агрегат — объект с правилами, который можно менять. Витрина возвращает плоскую запись под конкретный ответ. Смешав их в одном интерфейсе, вы получаете интерфейс, у которого половина методов отдаёт домен, половина — структуры чтения; читающий его не понимает, что это за штука.
Разный размер и разная судьба. У репозитория агрегата методов мало и они стабильны: найти по идентификатору, сохранить, иногда найти по естественному ключу. У витрины методов столько, сколько классов запросов, и они меняются с каждым новым экраном. Соединив их, вы получаете интерфейс, растущий вместе с интерфейсом пользователя, — и это первое, что ломает границу: репозиторий агрегата перестаёт быть маленьким и понятным.
Разные реализации. Витрина может читать другую таблицу, другую базу или поисковый движок; репозиторий агрегата — нет. Один интерфейс с двумя реализациями в разных модулях невозможен.
И практическое следствие для команд: метод чтения, добавленный в репозиторий агрегата «на минутку» (findAllByStatusAndDateBetween), — самое частое начало размывания. Он тянет за собой параметры фильтрации, постраничную навигацию и структуры ответа, и через полгода репозиторий записи обслуживает интерфейс пользователя. Разбор того же с другой стороны — в статье про сторону запросов.
Две реализации одного порта
«Завтра заменить один адаптер другим — обработчик не меняется» верно, пока реализация одна. Как только их две, контейнер внедрения зависимостей перестаёт понимать, какую подставлять, и приложение падает при старте с ошибкой о неоднозначности бина. Разбираем случаи, потому что они разные.
Случай 1: одна реализация в проде, другая в другом окружении. Заглушка платёжного шлюза на тестовом контуре, реализация для отладки без сети. Правильный способ — условная регистрация по настройке: в контейнере оказывается ровно один бин, выбранный профилем или свойством. Никакой неоднозначности нет, потому что второй бин не создаётся.
Случай 2: две реализации нужны одновременно, и выбор делается по данным. Два платёжных провайдера, и провайдер зависит от способа оплаты; два перевозчика; отправка через почту или мессенджер. Здесь неправильно просить контейнер выбрать: выбор — часть предметной области, и он должен быть виден в коде.
Рабочая форма — порт выбора плюс реализации, отвечающие на вопрос «это моё?»:
// core: порт с признаком применимости
public interface PaymentGateway {
boolean supports(PaymentMethod method);
PaymentResult charge(ChargeRequest request);
}
// core: сценарий получает ВСЕ реализации и выбирает сам
public class ChargeService {
private final List<PaymentGateway> gateways;
public PaymentResult charge(Order order) {
return gateways.stream()
.filter(g -> g.supports(order.paymentMethod()))
.findFirst()
.orElseThrow(() -> new NoGatewayForMethod(order.paymentMethod()))
.charge(ChargeRequest.of(order));
}
}
Что здесь важно: выбор живёт в ядре и читается как правило домена, а не спрятан в настройках контейнера; добавление третьего провайдера не меняет сценарий; и отсутствие подходящей реализации — понятное доменное исключение, а не загадочная ошибка внедрения.
Случай 3: две реализации одного и того же — признак двух разных портов. Если реализации отвечают на разные потребности («сохранить заказ в базу» и «сохранить заказ в файл для выгрузки»), это не один порт с двумя реализациями, а два порта с разными именами. Проверка: если в обработчике приходится думать, какую реализацию взять, и ответ не зависит от данных — вы соединили два разных намерения.
Чего лучше избегать. Приоритет реализации по умолчанию (@Primary) — работает, но делает выбор невидимым: через полгода никто не помнит, почему подставляется эта. Выбор по имени бина в точке внедрения — то же самое, только с опечаткой в строке. Оба приёма годятся для временного состояния при переходе с одной реализации на другую, и в этой роли они полезны: старая остаётся в коде, новая помечена как основная, откат — одна строка.
Методы port'а работают с доменными типами
Это ключевой момент. Интерфейс port'а должен выглядеть как часть домена — никаких структур из внешних SDK, никаких сущностей из слоя базы данных.
// Правильно — domain-типы
public interface PaymentPort {
RegisterResult register(RegisterCommand cmd); // RegisterCommand — доменный объект
void cancel(PaymentId paymentId); // PaymentId — доменный Value Object
}
// Неправильно — Sber-specific структуры в core
public interface PaymentPort {
SberRegisterResponse register(SberRegisterRequest req);
}
Что плохо в варианте с SberRegisterRequest:
- Core теперь знает про Sber. Если завтра меняем платёжку, приходится переписывать не только адаптер, но и всё, что использует этот интерфейс.
- Тесты на handler'ах вынуждены создавать
SberRegisterRequest— а это детали инфраструктуры в чистых unit-тестах. - В core просочились JSON-аннотации, snake_case-поля и прочие детали Sber API.
PaymentPort принимает доменный RegisterCommand (поля: amount, orderId, description) и возвращает доменный RegisterResult (paymentId, redirectUrl). Маппинг в структуры Sber-API живёт в SberClientAdapter внутри отдельного модуля адаптера.
Размер порта: потребность, а не возможности
Самая частая ошибка новичка — толстый порт, повторяющий возможности внешней системы: двадцать методов, потому что «у базы есть двадцать запросов» или «у платёжного шлюза двадцать ручек». Так получается интерфейс, который ничего не изолирует: ядро зависит от всей чужой поверхности, и замена адаптера означает реализацию двадцати методов, из которых используются четыре.
Правило: порт описывает потребность ядра, а не возможности системы. Пишется он от сценариев: какие вопросы ядро задаёт и какие действия просит выполнить. Если метод не вызывается ни одним сценарием, его в порту нет.
Как выглядит толстый порт и что с ним делать:
// Плохо: порт повторяет возможности платёжного шлюза
public interface PaymentPort {
PaymentResult charge(ChargeRequest r);
PaymentResult refund(RefundRequest r);
PaymentStatus status(PaymentId id);
List<Payment> search(PaymentFilter f); // используется только отчётом
void updateCustomerCard(CustomerId c, Card card); // вообще другой сценарий
Receipt receipt(PaymentId id); // нужно только уведомлениям
// ещё четырнадцать методов «на всякий случай»
}
// Хорошо: три порта по трём потребностям, у каждого свой вызывающий
public interface ChargePort { // нужен сценарию оплаты
PaymentResult charge(ChargeRequest request);
}
public interface RefundPort { // нужен сценарию возврата
RefundResult refund(RefundRequest request);
}
public interface PaymentStatusPort { // нужен сверке и поддержке
PaymentStatus status(PaymentId id);
}
Обратите внимание: реализация у всех трёх может быть одна и та же — один класс адаптера реализует три интерфейса, если все три ходят в один шлюз. Разделение не про число классов адаптера, а про то, что каждый сценарий видит только то, что ему нужно.
Признаки, что порт пора разрезать:
- Методов больше пяти-шести, и они относятся к разным сценариям.
- Ни один вызывающий не использует больше двух методов — верный признак, что это несколько портов в одном.
- Тест сценария вынужден заглушать методы, которые сценарий не вызывает (потому что интерфейс требует их реализовать) — это та самая цена толстого порта, которую замечают первой.
- Порт растёт при каждой новой задаче. Порт, который меняется чаще ядра, не изолирует ничего.
И обратная крайность, тоже встречается: порт на один метод для каждой мелочи. Десять интерфейсов по одному методу, все реализованные одним классом, — это шум. Разумная гранулярность: порт на связную потребность, обычно два-четыре метода («найти и сохранить заказ», «списать и вернуть деньги»). Ориентир простой: если два метода почти всегда вызываются одним и тем же сценарием, им место в одном порту.
Когда порт не нужен
Заводить порт к каждой зависимости — типичный перегиб, после которого в ядре появляются ClockPort, UuidPort, LoggerPort и ConfigPort. Граница есть, и она проходит по трём вопросам.
Вопрос 1: это часть языка платформы или внешняя система? Интерфейсы и типы из стандартной библиотеки (время, коллекции, математика) — часть языка, и оборачивать их не надо. Подменяемость времени решается тем, что стандартный Clock уже интерфейс: ядро принимает его в конструктор, тест подставляет фиксированный, и порт не нужен. Разбор — в разделе «Глубже» ниже.
Вопрос 2: нужна ли подменяемость в тестах или в проде? Порт заводят, чтобы подменить реализацию: в тесте на заглушку, в проде на другого поставщика. Если подменять нечего и незачем (математика, форматирование, преобразование внутри ядра), порт — лишний уровень.
Вопрос 3: пересекает ли вызов границу процесса или хранит состояние? База, брокер, чужой сервис, файловая система, отправка почты — да, нужен порт. Вычисление внутри памяти — нет.
Практический список, что порт не заслуживает:
- Журналирование. Ядро может писать в журнал через стандартный фасад: он не тянет инфраструктуру, а заворачивать его в порт означает создать свой мини-фасад над фасадом. Спорный случай — если журнал считается частью наблюдаемого поведения (аудит), но тогда это уже не журнал, а порт аудита с доменным смыслом.
- Настройки. Значение настройки передают в конструктор при сборке, а не читают из ядра через порт.
- Генерация идентификаторов и время — см. вопрос 1: это не порты, а зависимости-интерфейсы из платформы. Порт заводят, только если генерация особая (идентификатор от внешней системы, номер из последовательности базы).
- Преобразование данных, сериализация внутри ядра, работа с текстом.
- То, у чего одна реализация и она чистая (без сети, файлов и состояния) — это просто класс ядра, а не адаптер за портом.
И признак перегиба, который легко проверить: если реализация порта лежит в том же модуле, что интерфейс, и никуда не ходит, — порт не нужен, это класс. Порт имеет смысл ровно там, где реализация живёт снаружи.
Порт в тестах: заглушка, поддельная реализация или мок
То, ради чего порт и вводят, — и то, о чём чаще всего не договариваются. Три способа, и у каждого своё место.
Поддельная реализация (в памяти) — предпочтительный способ для репозиториев. Полноценная маленькая реализация порта на коллекции в памяти: она ведёт себя как настоящая на уровне контракта, её пишут один раз и переиспользуют во всех тестах.
// core/src/test/java/.../InMemoryOrderRepository.java
public class InMemoryOrderRepository implements OrderRepository {
private final Map<OrderId, Order> store = new HashMap<>();
@Override public Optional<Order> byId(OrderId id) { return Optional.ofNullable(store.get(id)); }
@Override public void save(Order order) { store.put(order.id(), order); }
}
Почему это лучше мока: тест читается как сценарий («сохранили заказ, выполнили команду, проверили состояние»), а не как список настроенных вызовов; тесты не ломаются при изменении способа сохранения; и одна поддельная реализация обслуживает десятки тестов.
Заглушка с заданным ответом — для внешних систем. Порт платёжного шлюза в тесте отвечает «одобрено» или «отказано»: это ровно то, что нужно проверить в сценарии, и сложная реализация не требуется. Пишут её как маленький класс или как мок — здесь разница несущественна.
Мок с проверкой вызова — только когда сам вызов и есть результат. «Письмо отправлено», «событие опубликовано» — наблюдаемое поведение, и его проверяют фактом вызова. Во всех остальных случаях проверка вызовов привязывает тест к реализации сценария.
Где живут эти реализации. Поддельные реализации портов — в тестовых исходниках ядра (core/src/test), рядом с тестами, которые их используют. Это важно: они не должны попадать в основной код (иначе однажды окажутся в проде) и не должны лежать в модуле адаптера (тогда тесты ядра начнут зависеть от адаптера, и вся идея сломается).
Если поддельные реализации нужны и в тестах других модулей, их выносят в отдельный тестовый артефакт ядра (test-fixtures в терминах сборки) — тогда адаптеры и модуль сборки могут их подключить, а основной код — нет.
Один тест, который стоит написать на поддельную реализацию: проверка, что она ведёт себя как настоящая на контракте порта. Один набор тестов, запускаемый и против поддельной, и против настоящей реализации (с контейнером), гарантирует, что тесты ядра не врут. Это называется контрактным тестом порта, и он стоит десяти отдельных тестов адаптера.
Иерархия исключений
Port — это контракт, и исключения тоже часть контракта. Делятся они по тому, на каком языке описывают отказ. В core/ живут базовый класс и те отказы, которые понятны в терминах домена: «платёж не найден», «банк отказал». В адаптерах — подклассы, привязанные к конкретной технологии: «Sber вернул 500», «таймаут HTTP-клиента».
Лежат эти классы не в port/out/, а рядом — в core/<bc>/exception/. В пакете порта остаются только интерфейсы, и это потом легко проверить архитектурным тестом.
// core/domain/orders/exception/PaymentPortException.java
public abstract class PaymentPortException extends RuntimeException {
protected PaymentPortException(String msg, Throwable cause) {
super(msg, cause);
}
}
// core/domain/orders/exception/PaymentNotFoundException.java
public class PaymentNotFoundException extends PaymentPortException {
public PaymentNotFoundException(PaymentId id) {
super("Payment not found: " + id, null);
}
}
// core/domain/orders/exception/PaymentDeclinedException.java
public class PaymentDeclinedException extends PaymentPortException {
private final String reason;
public PaymentDeclinedException(String reason) {
super("Payment declined: " + reason, null);
this.reason = reason;
}
public String reason() { return reason; }
}
В адаптере — конкретный тип исключения, привязанный к реализации:
// sber-out-adapter/.../SberException.java
public class SberException extends PaymentPortException {
public SberException(String msg, Throwable cause) { super(msg, cause); }
}
// sber-out-adapter/.../SberClientAdapter.java
@Component
public class SberClientAdapter implements PaymentPort {
@Override
public RegisterResult register(RegisterCommand cmd) {
try {
return /* ... */;
} catch (FeignException e) {
throw new SberException("Failed to register payment", e);
}
}
}
Handler в core ловит доменное исключение, не SberException — и не потому, что так договорились, а потому что не может: у core нет зависимости на модуль адаптера, и класс SberException ему просто не виден:
// core/.../CreatePaymentHandler.java — Spring-аннотаций в ядре нет
@RequiredArgsConstructor
class CreatePaymentHandler implements UseCaseHandler<CreatePaymentCommand, RegisterResult> {
private final PaymentPort paymentPort;
private final PaymentCommandMapper mapper;
@Override
public RegisterResult handle(CreatePaymentCommand cmd) {
try {
return paymentPort.register(mapper.toRegisterCommand(cmd));
} catch (PaymentDeclinedException e) {
throw new OrderCannotBePaidException(cmd.orderId(), e.reason());
} catch (PaymentPortException e) {
throw new PaymentSystemUnavailableException(e);
}
}
}
Порядок веток важен: PaymentDeclinedException — подкласс PaymentPortException, поэтому его ветка обязана стоять выше, иначе она никогда не сработает. И обратите внимание на типы: port объявлен как RegisterResult register(RegisterCommand cmd) — значит handler и принимает, и отдаёт ровно то, что записано в контракте порта, а команду из контроллера приводит к доменной через маппер.
Если завтра заменить SberClientAdapter на адаптер другой платёжки — handler не меняется. Он знает только о доменных исключениях.
Куда девается доменное исключение
«Обработчик ловит доменное исключение» — половина ответа; вторая половина про то, что с ним происходит дальше, и она важна, потому что здесь легко нарушить границу.
Путь исключения. Адаптер переводит техническую ошибку в доменную (или домен бросает своё правило) → исключение летит через обработчик, который его либо обрабатывает, либо пропускает → на входе его перехватывает обработчик ошибок входящего адаптера и превращает в ответ нужного формата.
Где это происходит. В коде входящего адаптера — там, где знают про формат ответа: для HTTP это класс-обработчик исключений (@RestControllerAdvice), для слушателя очереди — своя обработка, для задачи по расписанию — запись в журнал и метрика. Одно и то же доменное исключение в каждом случае обрабатывается по-своему, и именно поэтому оно не должно знать про HTTP.
// rest-api: единственное место, где домен встречается с кодами ответа
@RestControllerAdvice
class ErrorHandler {
@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ProblemDetail> notFound(OrderNotFoundException e) {
return problem(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", e.getMessage());
}
@ExceptionHandler(OrderAlreadyConfirmedException.class)
ResponseEntity<ProblemDetail> conflict(OrderAlreadyConfirmedException e) {
return problem(HttpStatus.CONFLICT, "ORDER_ALREADY_CONFIRMED", e.getMessage());
}
@ExceptionHandler(StorageUnavailableException.class)
ResponseEntity<ProblemDetail> unavailable(StorageUnavailableException e) {
return problem(HttpStatus.SERVICE_UNAVAILABLE, "TEMPORARY_FAILURE", "Повторите позже");
}
}
Почему доменное исключение не наследует классы с кодами ответа. Соблазн понятный: написать class OrderNotFoundException extends ResponseStatusException(NOT_FOUND) — и обработчик ошибок не нужен. Три причины, по которым так не делают:
- Ядро начинает знать про HTTP. Импорт класса веб-фреймворка в ядре — нарушение правила зависимостей, и тест архитектуры это поймает.
- То же исключение нельзя использовать в другом входе. Слушатель очереди получит исключение с кодом
404, который там ничего не значит; задача по расписанию — тем более. - Соответствие «исключение → код» перестаёт быть видимым. Разбросанное по классам исключений, оно не читается списком, и ответ на вопрос «какие коды отдаёт этот сервис» требует обхода всего кода.
Что несёт доменное исключение вместо кода: идентификатор объекта, текущее состояние, машинный код ошибки на языке предметной области (ORDER_ALREADY_CONFIRMED) и человекочитаемое сообщение. Кода ответа в нём нет — сопоставление живёт на входе. Подробный разбор формата ответа об ошибке и соответствия кодов — в разделе про обработку ошибок.
Inbound port — это UseCase
Вход в ядро тоже надо как-то описать: контроллер должен вызывать ядро, не зная его внутренностей. Классический ответ — ещё один интерфейс, inbound port. В UCP отдельный интерфейс не нужен: роль inbound-port'а играет связка UseCase + UseCaseHandler, а точкой входа служит UseCaseDispatcher.
// REST-контроллер (in-adapter)
@RestController
public class OrderController implements OrdersApi {
private final UseCaseDispatcher dispatcher;
@Override
public ResponseEntity<OrderJson> createOrder(@Valid CreateOrderRequest req) {
var cmd = mapper.toCommand(req);
var order = dispatcher.dispatch(cmd); // ← вход в core
return ResponseEntity.created(...).body(mapper.toJson(order));
}
}
Контроллер не зависит от конкретного handler'а. Он знает только UseCaseDispatcher, который сам маршрутизирует команду к нужному handler'у по типу. Это убирает лишние зависимости и оставляет контроллер тонким.
Подробнее о роли UseCase и handler'ов — в статье Use Case Pattern.
Частые ошибки
Port лежит в модуле адаптера. Port — это контракт от core к инфраструктуре, он должен быть в core/. Если положить его в адаптер, core не увидит интерфейс для внедрения зависимости — стрелка зависимостей развернётся в неправильную сторону.
Optional там, где отсутствие — это ошибка. Если handler ожидает, что объект обязательно существует, лучше бросить доменное исключение сразу, чем возвращать Optional и разворачивать его в каждом вызове:
// Когда отсутствие — нормальный кейс (query)
Optional<Order> findById(OrderId id);
// Когда отсутствие — ошибка (command)
Order findRequired(OrderId id); // бросает OrderNotFoundException
Или handler сам решает:
Order order = orderRepository.findById(cmd.id())
.orElseThrow(() -> new OrderNotFoundException(cmd.id()));
У <X>Repository есть ещё одна деталь контракта, про которую легко забыть: командная часть читает агрегат под блокировкой, поэтому в сигнатуре появляется второй параметр — findById(OrderId id, SelectMode mode). Запросу хватит обычного чтения, команде нужен SelectMode.FOR_UPDATE, иначе две одновременные транзакции затрут работу друг друга. Разбор — в статье Command side в CQRS.
Port как абстрактный класс, а не интерфейс. Port — это контракт, не структура. Абстрактный класс мешает подменять порт в тестах. Mockito и для интерфейса, и для класса генерирует подставной тип через ByteBuddy — механизм один и тот же, но с классом добавляются оговорки: final-класс и final-методы без отдельного inline mock maker не подменяются вообще, конструктор приходится обходить через Objenesis, а статическая инициализация родителя всё равно отработает. А ещё в Java нет множественного наследования классов: если адаптер уже наследует что-то другое, он не сможет реализовать port-класс.
// Правильно
public interface PaymentPort { ... }
// Неправильно
public abstract class PaymentPort { ... }
Коротко
- Port — это интерфейс в
core/<bc>/port/out/. Core описывает, что ему нужно; адаптер реализует. Имена:<X>Repository(агрегат),<X>ViewRepository(CQRS),<Y>Port(внешние системы),<Z>EventPublisher(события). - Методы port'а принимают и возвращают доменные типы, не структуры из SDK внешних систем. Исключения: базовый класс и доменные отказы — в
core/<bc>/exception/, технологические подклассы — в адаптерах. Handler ловит доменное исключение. - Inbound port = UseCase + UseCaseDispatcher. Отдельный интерфейс не нужен.
- Port всегда interface, не класс: легче подменять в тестах, нет ограничений на множественное наследование.
- У витрины чтения отдельный порт, потому что у неё другие типы на выходе, другой размер и другая судьба: метод чтения в репозитории агрегата — начало размывания границы.
- Две реализации одного порта: по настройке для разных окружений, по признаку применимости с выбором в ядре, или это два разных порта; приоритет по умолчанию — только на время перехода.
- Порт описывает потребность ядра, а не возможности системы: два-четыре метода на связную потребность, реализация может быть одна на несколько портов, а признаки для разреза — больше шести методов и лишние заглушки в тестах.
- Порт не нужен для времени, идентификаторов, журнала, настроек и чистых вычислений; признак перегиба — реализация лежит в том же модуле, что интерфейс, и никуда не ходит.
- В тестах порт закрывают поддельной реализацией в памяти (для репозиториев), заглушкой с ответом (для внешних систем) и моком только там, где вызов и есть результат; живут они в тестовых исходниках ядра.
- Доменное исключение не несёт кода ответа: соответствие «исключение → код» живёт в обработчике входящего адаптера, потому что у очереди и планировщика коды не значат ничего.
Что почитать дальше
- Adapters out — кто реализует port-интерфейс и как устроен out-адаптер.
- Adapters in — как REST-контроллер использует
UseCaseDispatcherкак inbound-вход. - Use Case Pattern — про
UseCaseDispatcherи роль handler'ов. - Repository pattern в jOOQ — конкретная реализация
<X>Repository-port'а через jOOQ.