В данной статье рассматривается архитектура проекта, позволяющая модульным образом интегрировать инфраструктурные фреймворки, такие как Spring, Quarkus и Micronaut, без необходимости модификации ядра предметной области (domain) или внешних API.
Ссылка на исходный код: https://github.com/bifrurcated/hexagonal
Содержание:
Введение
Начнём с того что идея гексогональной/чистой архитектуры это про то, что нужно писать и организовывать код так, как создают библиотеки и фреймворки. То есть у вас есть некая core часть, которая написана с минимальным использованием зависимостей и с помощью неё создаются различные дополнительные модули расширяющие этот функционал. Для примера возьмите spring-core и того сколько вокруг этого написано различных расширений.
В нашей практике core часть мы будем именовать как domain, так как это ближе к практическому использованию, когда используется микрос��рвисная архитектура с делением по доменам. А любой подключаемый фреймворк с его зависимостями будет называться - infrastructure. Но как показывает практика можно не обходится только двумя этими модулями, а добавить и другие для дальнейшего переиспользования. Например, отдельный модуль для внешнего api или модуль для взаимодействия с БД через какой-нибудь ORM фреймворк.
Пишите ваш domain/core функционал с минимальным количеством зависимостей без использования фреймворков.
При таком подходе в первую очередь нужно приступать к реализации domain модуля, чем мы и займёмся в следующем шаге.
Реализация domain модуля
Допустим команде в каком-то банке достался домен кредитов. Я создам проект с использованием maven и сразу добавлю в него дочерний модуль credit-domain.
parent pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.github.bifrurcated</groupId>
<artifactId>hexagonal</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>credit-domain</module>
</modules>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jspecify.version>1.0.0</jspecify.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-domain</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
В pom.xml модуля credit-domain мы добавим плагин maven-enforcer-plugin, с помощью которого запретим использовать какие-либо сторонние зависимости кроме тех, что мы добавили в исключение. А это зависимости для тестирования и библиотека jspecify.
JSpecify - это стандартизированная библиотека аннотаций для того, чтобы явно пометить какой параметр может быть null, а какой нет.
credit-domain pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.github.bifrurcated</groupId>
<artifactId>hexagonal</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>credit-domain</artifactId>
<description>Модуль бизнес логики приложения</description>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit-jupiter.version>5.10.1</junit-jupiter.version>
<assertj-core.version>3.25.2</assertj-core.version>
</properties>
<dependencies>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>${jspecify.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit-jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj-core.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<bannedDependencies>
<excludes>
<exclude>*</exclude> <!-- запрещает зависимости, не относящиеся к домену -->
</excludes>
<includes>
<include>org.jspecify:jspecify</include>
<include>*:*:*:*:test</include>
</includes>
</bannedDependencies>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Одной из немаловажных этапов для структурирования кода является наименование и создание пакетов. Так для домена основное название будет выглядеть так: com.github.bifrurcated.credit.domain. Раз по DDD команде уже достался домен, правильнее строить название как <имя_организации>.<название_домена>.<domain>, чтобы далее можно было выделить другие модули, относящиеся к домену кредита.
Внутри пакета domain я выделю три основных пакета:
com.github.bifrurcated.credit.domain
├── model/
├── service/
├── spi/
model -содержит бизнес-объекты (сущности, агрегаты, объекты-значения) предметной области.
service - включает реализацию бизнес-логики предметной области. Ключевые компоненты помечаются пользовательской аннотацией
@DomainService.spi - определяет интерфейсы инфраструктурного уровня, которые служат API для взаимодействия с внешними системами. Реализация данных интерфейсов обеспечивается модулем
infrastructure.
Для описания бизнес объектов нам понадобятся так называемые Value Object. Это своего рода кастомные типы данных, использование которых сразу вносит какие-то ограничения. Их мы будем размещать в пакете model.valueobject.
Подробно описывать структуру кредитов мы не будем, а обойдёмся парой классов и ограниченным количеством полей.
public record Credit(@NonNull UUID id,
@NonNull UUID userId,
@NonNull CreditStatus status,
@NonNull CreditAmount amount,
@NonNull String number,
@NonNull List<Operation> operations,
@NonNull CreditExpirationDate expirationDate) {
public Credit(@NonNull UUID creditId, @NonNull UUID userId, @NonNull CreditAmount money, @NonNull String number, @NonNull CreditExpirationDate expirationDate) {
this(creditId, userId, CreditStatus.ACTIVE, money, number, new ArrayList<>(), expirationDate);
}
}
Как можно заметить мы добавили два Value Object: CreditAmount и CreditExpirationDate. Первый имеет ограничение на то, что он не может быть отрицательным. А второй проверку на то, что дата окончания не должна быть меньше текущей даты.
public record CreditAmount(@NonNull BigDecimal value) {
public CreditAmount {
if (BigDecimal.ZERO.compareTo(value) > 0) {
throw new CreditAmountNegativeException();
}
}
}
Лучше всего исключение, которое выбрасывается из объектов пакета valueobject, размешать прямо в том же пакете или создать подпакет exception, чтобы при просмотре структуры кода ты сразу мог увидеть все используемые исключения. Это собственно и было сделано с исключением valueobject.exception.CreditAmountNegativeException.
Для реализации "типа" CreditExpirationDate пришлось извратиться, пометив конструктор как private и реализовав класс фабрики CreditExpirationDateFactory. Почему был выбран такой дизайн? Потому что при создании объекта через конструктор нам бы пришлось постоянно передавать spi интерфейс для получения текущего времени, чтобы реализовать проверку дат. В java можно вызвать private конструктор только если это inner class, поэтому прямо в фабрике создаём CreditExpirationDate .
public class CreditExpirationDateFactory {
private final @NonNull TimeProvider timeProvider;
public CreditExpirationDateFactory(@NonNull TimeProvider timeProvider) {
this.timeProvider = timeProvider;
}
public @NonNull CreditExpirationDate create(@NonNull LocalDate expirationDate) {
if (expirationDate.isBefore(timeProvider.currentDate())) {
throw new CreditExpirationDateIsBeforeCurrentDateException();
}
return new CreditExpirationDate(expirationDate);
}
public static final class CreditExpirationDate {
private final @NonNull LocalDate value;
private CreditExpirationDate(@NonNull LocalDate value) {
this.value = value;
}
public @NonNull LocalDate value() {
return value;
}
}
}
С обзором основных концептов пакета model закончим и перейдём к бизнес логике. Создадим логику по открытию кредита. Для этого создадим класс CreditOpeningService с одним методом open. Придерживаясь принципа YAGNI интерфейс создавать не будем.
На реальном проекте количество передаваемых параметров для открытие кредита может исчисляться несколькими десятками. Естественно никто не будет перечислять это в аргументах метода, а создадут какой-то объект контейнер/dto. В нашем случае это будет некая команда для открытия кредита CreditOpenCommand. Располагаться она будет в пакете service.command.
public record CreditOpenCommand(@NonNull UUID userId, @NonNull BigDecimal amount, @NonNull LocalDate expirationDate) {
}
Возможно у некоторых возникнет вопрос, а почему нельзя в параметрах указывать объекты из пакета model? Это является плохим решением, так как вы отдаёте заботу о создании model объектов на откуп инфраструктурному слою. Это вас связывает, когда вы захотите изменить поля или логику в model объектах, например, вынесите логику проверок на прямую в метод open вместо valueobject, то у инфраструктурного слоя сломаться какой-то тест или проверка.
Поэтому входящие параметры вашего API должно быть максимально тупым контейнером данных.
В упрощённом методе открытия кредита у нас сначала будет производится валидация данных с помощью создания value object, затем вызывается внешний API для получения номера счёта, далее получаем идентификатор кредита и сохраняем всю полученную информацию по кредиту в БД.
public @NonNull Credit open(@NonNull CreditOpenCommand creditOpenCommand) {
var creditAmount = new CreditAmount(creditOpenCommand.amount());
var creditExpirationDate = creditExpirationDateFactory.create(creditOpenCommand.expirationDate());
var number = creditAccountPool.getAccountNumber();
var creditId = idGenerator.generateUUID();
var credit = new Credit(creditId, creditOpenCommand.userId(), creditAmount, number, creditExpirationDate);
creditRepository.save(credit);
return credit;
}
Для этого метода открытия кредита было выделено 4 spi интерфейса.
TimeProvider- получения текущего времениIdGenerator- генерация идентификатораCreditAccountPool- взаимодействие с внешним API по счетамCreditRepository- репозиторий для работы с БД
Если с CreditAccountPool и CreditRepository понятно, что это какой-нибудь rest клиент и jpa репозиторий, но зачем создавать TimeProvider и IdGenerator, а не вызвать готовый статический метод? Главная проблема это трудности тестирования таких статических методов и гибкость изменения реализации. Например, сегодня вы захотите получать UUIDv4, а завтра узнаете что это плохо индексируется и нужно переходить на UUIDv7.
Не используйте на прямую статические методы, оборачивайте их в spi интерфейс, подкрепляя простотой написания теста.
Код у нас готов, но нам нужно покрыть его тестами. Напишем функциональный тест, который будет проверять функцию открытия кредита. Для начала создадим внутри spi пакета - пакет stub, там будут находиться заглушки. Почему мы создаём стабы не в тестах? Это удобно для тестирования не только внутри сервиса, но и при интеграционном тестировании на тестовом стенде, когда ещё не готова какая-то интеграция. Так делать не обязательно, в каких-то случаях можно развернуть и использовать тот же wiremock.
@Test
void should_user_open_credit() {
var userId = UUID.randomUUID();
var amount = new BigDecimal(1_000_000);
var expirationDate = LocalDate.of(2026, Month.JANUARY, 27);
var timeProvider = new TimeProviderStub();
var creditExpirationDateFactory = new CreditExpirationDateFactory(timeProvider);
CreditOpeningService creditOpeningService = new CreditOpeningService(
timeProvider,
new CreditAccountPoolStub(),
new CreditRepositoryStub(),
new IdGeneratorStub()
);
Credit credit = creditOpeningService.open(new CreditOpenCommand(userId, amount, expirationDate));
assertThat(credit)
.returns(UUID.fromString("c76ab6cf-d892-4ca8-97ba-6ce55965ada9"), Credit::id)
.returns(new CreditAmount(amount), Credit::amount)
.returns(userId, Credit::userId)
.returns(CreditStatus.ACTIVE, Credit::status)
.returns(List.of(), Credit::operations)
.returns(creditExpirationDateFactory.create(expirationDate), Credit::expirationDate)
.returns("1234567890", Credit::number);
}
Реализация api-rest модуля
Тут я сделаю отступление. Если domain модуль это внутренняя часть сервиса, реализацией которой занимается инфраструктура, то чтобы инициировать вызов из вне нам нужен внешний API. В качестве примера у нас будет торчать REST ручка. К реализация API и его описание можно подходить с разных сторон, можно описать его как отдельный файл openapi.json и сгенерировать java классы через специальный плагин, а можно вручную написать кодом. Пойдём по ручному пути. Добавим ещё один модуль: credit-api-rest. Плюсом данного выделения в отдельный модуль служит то, что мы можем опубликовать его в качестве зависимости для вызывающей системы. В качестве зависимостей добавим jakarta валидацию и swagger аннотации для описания схемы.
credit-api-rest pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.github.bifrurcated</groupId>
<artifactId>hexagonal</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>credit-api-rest</artifactId>
<description>Модуль REST API для взаимодействия с приложением</description>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-core-jakarta</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
</dependencies>
</project>
Основной путь пакета будет следующим: com.github.bifrurcated.credit.api.rest.
Для API важно так же предусмотреть версионирование, потому что это часть кода подвержена частому изменению. Сделать это можно по разному, создать после rest пакет v1 и создавать дубликаты интерфейсов или метода, либо использовать на уровне http заголовка смену версий. Тут скорее будет зависеть от принятых у вас стандартов и практик.
В нашем примере мы пока не будем явно реализовывать управление версиями. Создадим интерфейс CreditRestApi и в пакете dto два record класса: запрос и ответ.
public interface CreditRestApi {
CreditOpenResponse open(CreditOpenRequest request);
}
CreditOpenRequest
package com.github.bifrurcated.credit.api.rest.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import static io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE;
@Schema(description = "Запрос на открытие кредитного продукта", additionalProperties = FALSE)
public record CreditOpenRequest(
@NotNull
@Schema(description = "Идентификатор пользователя")
UUID userId,
@DecimalMin("10.00")
@Positive
@Digits(integer = 11, fraction = 2)
@NotNull
@Schema(description = "Сумма кредита")
BigDecimal amount,
@NotNull
@Schema(description = "Дата истичения срока действия кредита")
LocalDate expirationDate
) {
}
CreditOpenResponse
package com.github.bifrurcated.credit.api.rest.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
import static io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE;
@Schema(description = "Ответ на запрос по открытию кредитного продукта", additionalProperties = FALSE)
public record CreditOpenResponse(
@Schema(description = "Идентификатор кредита")
UUID id,
@Schema(description = "Статус кредита")
String status,
@Schema(description = "Кредитный номер счета")
String number
) {
}
Помимо API модуля можно выделить отдельный модуль для работы с БД.
Реализация infrastructure-persistence-jpa модуля
Cоздадим credit-infrastructure-persistence-jpa. В этом модуле мы будем с помощью java классов описывать структуру таблиц в БД, достаточно добавить зависимость jakarta.persistence-api. Помимо сущностей у нас есть слой репозитория и в Jakarta EE присутствует соответствующая спецификация jakarta.data-api. Однако не всё так хорошо работает, последняя стабильная версия вышла 30 сентября 2024 года, а новая ещё находится на стадии тестирования с 9 октября 2025 года. Я всё же добавил эту зависимость просто для демонстрации и для генерации репозиториев воспользуюсь hibernate-processor. В версии 1.0.1 отсутствует прямой вызов событий для аннотаций @PrePersist, @PreUpdate и тому прочее. В новой версии это добавили, но там добавили через зависимость на интерфейс jakarta.enterprise.event.Event, прямой реализации которой пока нет в spring и quarkus.
credit-infrastructure-persistence-jpa pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.github.bifrurcated</groupId>
<artifactId>hexagonal</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>credit-infrastructure-persistence-jpa</artifactId>
<description>Модуль для взаимодействия с БД</description>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<hibernate.version>7.2.0.Final</hibernate.version>
</properties>
<dependencies>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.data</groupId>
<artifactId>jakarta.data-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.enterprise</groupId>
<artifactId>jakarta.enterprise.cdi-api</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>${hibernate.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>${jspecify.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-processor</artifactId>
<version>${hibernate.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
Чтобы максимально сделать сущности переносимыми я не стал использовать hibernate аннотации для добавления какого-то дополнительного функционала, а воспользовался аннотациями из Jakarta. Например, я не хочу вручную каждый раз заполнять дату создания и дату обновления, а хочу чтобы это делалось автоматически. Для реализации я сначала выделю базовый класс, где создам два поля createTime и updateTime и навешу на класс аннотацию jakarta.persistence.EntityListeners передав класс реализацию по обновлению этих полей с использованием аннотаций @PrePersist и @PreUpdate. Вообще эти аннотации можно было и на прямую добавить в сущность, но я не буду на прямую вызывать статический метод, а сделаю вызов через spi интерфейс.
@EntityListeners(DateTimeEntityListener.class)
@MappedSuperclass
public abstract class BaseEntity {
@Column(nullable = false, updatable = false)
private LocalDateTime createTime;
@Column(nullable = false)
private LocalDateTime updateTime;
...getters and setters
}
@Singleton
public class DateTimeEntityListener {
private final EntityDateTimeProvider entityDateTimeProvider;
@Inject
public DateTimeEntityListener(EntityDateTimeProvider entityDateTimeProvider) {
this.entityDateTimeProvider = entityDateTimeProvider;
}
@PrePersist
public void onPrePersist(BaseEntity entity) {
var now = entityDateTimeProvider.currentDateTime();
entity.setCreateTime(now);
entity.setUpdateTime(now);
}
@PreUpdate
public void onPreUpdate(BaseEntity entity) {
entity.setUpdateTime(entityDateTimeProvider.currentDateTime());
}
}
public interface EntityDateTimeProvider {
@NonNull
LocalDateTime currentDateTime();
}
Можно заметить, что нет аннотаций lombok, но это потому что в MapStruct пока нет поддержки новой версии lombok с Java 25. У меня просто не компилировался код.
Да и вообще, если рассматривать lombok, то его лучше не использовать. Потому что на практике от него больше минусов, чем плюсов. Он часто скрывает плохой дизайн кода. Например, в проектах я часто вижу большое количество зависимостей, внедренных в класс, которые превышают quality gate Sonar'а, но это скрывается с помощью аннотации @RequiredArgsConstructor.
Для тех же сущностей можно спокойно использовать public поля и скрыть разве что технические поля от возможности их изменить. Я так и хотел сделать, но компиляция почему-то полностью ломается для micronaut.
Откажитесь от использования lombok.
Покажу ещё описание сущности кредитов:
@Entity
@Table(name = "credit")
public class CreditEntity extends BaseEntity {
@Id
private UUID id;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false, length = 50)
private String status;
@Column(nullable = false)
private BigDecimal amount;
@Column(nullable = false, length = 20)
private String number;
@Column(nullable = false)
private LocalDate expirationDate;
@OneToMany(mappedBy = "credit", fetch = FetchType.LAZY, orphanRemoval = true)
private Set<OperationEntity> operations = new HashSet<>();
@Version
@Column(nullable = false)
private Long version;
... getters and setters
}
Репозитории смысла показывать нет, потому что далее я просто использовал репозитории из spring-data-jpa или их аналоги в других фреймворках. Да хотелось бы иметь что-то стандартизированное и переносимое, но пока что так.
Структура пакетов получилось чем-то схожей с domain модулем:
com.github.bifrurcated.credit.infrastructure.persistence.jpa
├── entity/
├── repository/
├── spi/
И наконец перейдём к реализации инфраструктурных модулей с использованием фреймворков.
Реализация infrastructure-spring модуля
Создадим модуль credit-infrastructure-spring. Если в domain, api-rest и persistence-jpa модулях мы старались использовать минимальное количество зависимостей, то при использовании фреймворка нас ничего не ограничивает. Возьмём последнюю версию spring-boot, на момент написания статьи это 4.0.1. Главное отличие которое я заметил относительно 3 версии это то, что они разделили многие зависимости, которые находились в одном стартере, на отдельные стартеры. По стартерам всё стандартно для веб сервиса, но для вызова внешнего сервиса я захотел посмотреть на их новый restclient и поэтому подключил зависимость spring-boot-starter-restclient.
credit-infrastructure-spring pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.github.bifrurcated</groupId>
<artifactId>hexagonal</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>credit-infrastructure-spring</artifactId>
<description>Модуль инфраструктуры фреймворка SPRING</description>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>4.0.1</spring-boot.version>
<spring-cloud.version>2025.1.0</spring-cloud.version>
<mapstruct.version>1.6.3</mapstruct.version>
<springdoc-openapi-bom.version>3.0.0</springdoc-openapi-bom.version>
</properties>
<dependencies>
<!-- Internal modules -->
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-domain</artifactId>
</dependency>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-api-rest</artifactId>
</dependency>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-account-pool-api</artifactId>
</dependency>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-infrastructure-persistence-jpa</artifactId>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!--Dependencies for db-->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-bom</artifactId>
<version>${springdoc-openapi-bom.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<!-- Spring plugin -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Maven plugins -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<compilerArg>
-Amapstruct.unmappedTargetPolicy=ERROR
</compilerArg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>
Для всех инфраструктурных модулей определим основную структуру пакета:
com.github.bifrurcated.credit.infrastructure.spring
├── config/
├── in/
├── out/
config - конфигурационные файлы специфичные для используемого фреймворка.
in - Всё, что принимает запросы извне:
REST контроллеры
GraphQL resolvers
Message listeners (если это входящие сообщения)
Scheduled tasks (так как они инициируют процессы)
out - Всё, что обращается к внешним системам:
Базы данных (JPA repositories)
Внешние REST API (RestTemplate, Feign клиенты)
Message brokers (Kafka, RabbitMQ)
Файловые системы
Кэши (Redis и т.д.)
Начнём с конфигурации domain сервисов, чтобы добавить их в контекст спринга. Самый простой вариант - это создать @Bean метод. Такой ручной подход хорош, когда классов мало. Но для динамического добавления мы воспользуемся @ComponentScan с указанием на кастомную аннотацию @DomainService. Для spi интерфейсов у нас есть stub реализации и чтобы написать тест без мокирования или локально запустить проект можно так же пометить такие реализации кастомной аннотацией @Stub и добавить их в сканирование.
Как видно в конфигурации я не стал добавлятьCreditAccountPoolStub в исключение, чтобы можно было локально протестировать проект без использования wiremock или реального сервиса.
@Configuration
@ComponentScan(
basePackages = "com.github.bifrurcated.credit.domain",
includeFilters = @Filter(type = FilterType.ANNOTATION, classes = {DomainService.class, Stub.class}),
excludeFilters = @Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = {CreditRepositoryStub.class, IdGeneratorStub.class, TimeProviderStub.class}
)
)
public class DomainConfig {
}
Для включения сущностей в контекст воспользуемся аннотацией @EntityScan, указав путь до этих классов.
@EntityScan(basePackages = "com.github.bifrurcated.credit.infrastructure.persistence.jpa.entity")
@Configuration
public class JpaConfig {
}
Далее реализуем интерфейс CreditRestApi в in пакете, как CreditRestController. Чтобы сделать маппинг из запроса в команду для открытия я воспользуюсь MapStruct, так же и для результата будет сделан маппинг в dto ответа.
@PostMapping("/open")
public CreditOpenResponse open(@RequestBody @Valid CreditOpenRequest request) {
var creditOpenCommand = creditOpenCommandMapper.toCreditOpenCommand(request);
var credit = creditOpeningService.open(creditOpenCommand);
return creditOpenResponseMapper.toCreditOpenResponse(credit);
}
И наконец реализации spi интерфейсов. Для реализации rest клиента я дополнительно создал отдельный модуль credit-account-pool-api, где будут dto и api интерфейс. Дополнительно для restclient с @HttpExchange нужно создать конфигурационный класс с аннотацией @ImportHttpServices.
@HttpExchange(url = "http://account-pool/accounts", contentType = MediaType.APPLICATION_JSON_VALUE)
public interface AccountPoolRestClient extends AccountPoolApi {
@Retryable(maxRetries = 3, timeout = 1000L)
@PostExchange("/next")
@Override
NextAccountPoolResponse next(@RequestBody NextAccountPoolRequest request);
}
@ImportHttpServices(
group = "account-pool",
value = AccountPoolRestClient.class,
clientType = HttpServiceGroup.ClientType.REST_CLIENT
)
@Configuration(proxyBeanMethods = false)
public class HttpClientConfig {
}
Далее заинжектим rest клиент в реализацию CreditAccountPoolRestClient интерфейса CreditAccountPool. Тут будет небольшая логика, что мы запрашиваем один счёт, если список пуст, то кидаем исключение CreditAccountPoolEmptyException, которое находится в credit-account-pool-api модуле, так как является частью api. В конце просто возвращаем номер счёта.
@NonNull
@Override
public String getAccountNumber() {
var nextAccountPoolRequest = new NextAccountPoolRequest(AccountType.CREDIT, 1);
var nextAccountPoolResponse = accountPoolRestClient.next(nextAccountPoolRequest);
if (nextAccountPoolResponse.accounts().isEmpty()) {
throw new CreditAccountPoolEmptyException();
}
var accountPoolInfo = nextAccountPoolResponse.accounts().getFirst();
return accountPoolInfo.number();
}
Так как репозитории jakarta.data-apiпока "сырые", создадим репозиторий из spring-data-jpa наследуясь от JpaRepository. Заинжектим его в реализацию JpaCreditRepository, где просто сделаем маппинг из доменного объекта в сущность и вызовем метод save из репозитория.
@Transactional
@Override
public void save(@NonNull Credit credit) {
var creditEntity = creditEntityMapper.toCreditEntity(credit);
creditEntityRepository.save(creditEntity);
}
Для реализации idGenerator вызовем просто статический метод UUID.randomUUID(), а для TimeProvider и EntityDateTimeProvider вызовем соответствующие классы из Java Time API, использовав абстрактный класс Clock для возможности контролировать время.
@Service
public class UuidGenerator implements IdGenerator {
@Override
public @NonNull UUID generateUUID() {
return UUID.randomUUID();
}
}
@Service
public class SystemTimeProvider implements TimeProvider {
private final Clock clock;
public SystemTimeProvider(Clock clock) {
this.clock = clock;
}
@NonNull
@Override
public LocalDate currentDate() {
return LocalDate.now(clock);
}
}
@Service
public class SystemEntityDateTimeProvider implements EntityDateTimeProvider {
private final Clock clock;
public SystemEntityDateTimeProvider(Clock clock) {
this.clock = clock;
}
@Override
public @NonNull LocalDateTime currentDateTime() {
return LocalDateTime.now(clock);
}
}
Были добавлены интеграционные тесты для проверки всей системы и модульные тесты для отдельных компонентов. Более подробно можно ознакомиться в исходном коде.
Инфраструктурный spring модуль готов.
Реализация infrastructure-quarkus модуля
О quarkus я только слышал, как альтернативу spring, но никогда с ней не работал. Чтобы его опробовать я и решился воспользоваться этой архитектурой. Создадим ещё один дочерний модуль и назовём его credit-infrastructure-quarkus. В quarkus аналогом стартеров из spring являются так называемые extensions (расширения), те же зависимости. И главное отличие это то что quarkus, как фреймворк, не использует создание чего-то динамически, тут нет прямого аналога сканирование пакетов через аннотацию @ComponentScan. В нём все зависимости это уже скомпилированный и готовый код. Такой дизайн даёт соответственно более быстрый старт и он заточен для создания нативного образа с помощью GraalVM.
Ещё одной особенностью quarkus является его dev режим, который запускается при помощи команды mvn quarkus:dev, где перейдя на http://localhost:8080/q/dev-ui/ вас встретит панель управления, на которой можно просматривать подключённые зависимости и при выборе Agroal - DB connection pool увидеть, что сохранено в таблицах БД.
credit-infrastructure-quarkus pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.github.bifrurcated</groupId>
<artifactId>hexagonal</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>credit-infrastructure-quarkus</artifactId>
<description>Модуль инфраструктуры фреймворка QUARKUS</description>
<properties>
<compiler-plugin.version>3.14.1</compiler-plugin.version>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.version>3.30.5</quarkus.platform.version>
<skipITs>true</skipITs>
<surefire-plugin.version>3.5.4</surefire-plugin.version>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Internal modules -->
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-domain</artifactId>
</dependency>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-api-rest</artifactId>
</dependency>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-account-pool-api</artifactId>
</dependency>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-infrastructure-persistence-jpa</artifactId>
</dependency>
<!-- Quarkus -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-info</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-micrometer</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
<!-- Quarkus Databse -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-agroal</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-narayana-jta</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-spring-data-jpa</artifactId>
</dependency>
<!-- Quarkus Test -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
<goal>native-image-agent</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<compilerArg>
-Amapstruct.unmappedTargetPolicy=ERROR
</compilerArg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<argLine>--add-opens java.base/java.lang=ALL-UNNAMED</argLine>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
<configuration>
<argLine>--add-opens java.base/java.lang=ALL-UNNAMED</argLine>
<systemPropertyVariables>
<native.image.path>${project.build.directory}/${project.build.finalName}-runner
</native.image.path>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<activation>
<property>
<name>native</name>
</property>
</activation>
<properties>
<quarkus.package.jar.enabled>false</quarkus.package.jar.enabled>
<skipITs>false</skipITs>
<quarkus.native.enabled>true</quarkus.native.enabled>
</properties>
</profile>
</profiles>
</project>
Структура пакета quarkus модуля:
com.github.bifrurcated.credit.infrastructure.quarkus
├── config/
├── in/
├── out/
В quarkus есть расширения для spring, то есть можно использовать spring аннотации, что поможет сделать миграцию быстрее. Но я всё же буду использовать jakarta CDI аннотации. Первая задача - это понять, каким образом можно просканировать domain модуль для регистрации классов, помеченных аннотацией @DomainService. Так как тут нет динамической регистрации нужно это дело заранее скомпилировать, а это значит создать свой extension. Про то, как это сделать, можно прочитать в официальной документации. Коротко это создаётся отдельный модуль (credit-domain-quarkus-extension), добавляете зависимость на domain и через специальный плагин и настроенную конфигурацию компилируете и получаете соответствующие бины. Но делать я так не стал, а просто вручную зарегистрировал класс.
@ApplicationScoped
public class DomainConfig {
@Produces
@ApplicationScoped
public CreditOpeningService creditOpeningService(
TimeProvider timeProvider,
CreditRepository creditRepository,
IdGenerator idGenerator) {
return new CreditOpeningService(timeProvider, new CreditAccountPoolStub(), creditRepository, idGenerator);
}
}
А вот для регистрации сущностей я воспользовался конфигурацией в application.properties где для quarkus.hibernate-orm.packages= нужно указать путь до сущностей.
Реализация контроллера не меняется, разве что некоторые аннотации другие:
@POST
@Path("/open")
@Override
public CreditOpenResponse open(@Valid CreditOpenRequest request) {
var creditOpenCommand = creditOpenCommandMapper.toCreditOpenCommand(request);
var credit = creditOpeningService.open(creditOpenCommand);
return creditOpenResponseMapper.toCreditOpenResponse(credit);
}
Полный код класса CreditRestController
package com.github.bifrurcated.credit.infrastructure.quarkus.in;
import com.github.bifrurcated.credit.api.rest.CreditRestApi;
import com.github.bifrurcated.credit.api.rest.dto.request.CreditOpenRequest;
import com.github.bifrurcated.credit.api.rest.dto.response.CreditOpenResponse;
import com.github.bifrurcated.credit.domain.service.CreditOpeningService;
import com.github.bifrurcated.credit.infrastructure.quarkus.in.mapper.CreditOpenCommandMapper;
import com.github.bifrurcated.credit.infrastructure.quarkus.in.mapper.CreditOpenResponseMapper;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/credit")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CreditRestController implements CreditRestApi {
private final CreditOpeningService creditOpeningService;
private final CreditOpenCommandMapper creditOpenCommandMapper;
private final CreditOpenResponseMapper creditOpenResponseMapper;
public CreditRestController(CreditOpeningService creditOpeningService,
CreditOpenCommandMapper creditOpenCommandMapper,
CreditOpenResponseMapper creditOpenResponseMapper) {
this.creditOpeningService = creditOpeningService;
this.creditOpenCommandMapper = creditOpenCommandMapper;
this.creditOpenResponseMapper = creditOpenResponseMapper;
}
@POST
@Path("/open")
@Override
public CreditOpenResponse open(@Valid CreditOpenRequest request) {
var creditOpenCommand = creditOpenCommandMapper.toCreditOpenCommand(request);
var credit = creditOpeningService.open(creditOpenCommand);
return creditOpenResponseMapper.toCreditOpenResponse(credit);
}
}
Так же есть своя реализация restclient от eclipse microprofile:
@RegisterRestClient(configKey = "account-pool-api")
@Path("/accounts")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public interface AccountPoolRestClient extends AccountPoolApi {
@POST
@Path("/next")
@Override
NextAccountPoolResponse next(NextAccountPoolRequest request);
}
Создавать имплементацию CreditAccountPool я не стал, а воспользовался stub заглушкой.
С репозиториями jakarta.data-api, которые казалось заточены для такого фреймворка всё так же работают плохо. Во первых @PrePersist и @PreUpdate аннотации не работают, можно конечно и вручную указывать время создания и обновления или обойтись аннотацией hibernate, но я хотел обойтись только jakarta аннотациями. Во вторых, у меня почему-то после первого запроса на вставку данных, закрывалось соединение с БД и больше не восстанавливалось. Погуглив я увидел что были схожие проблемы с каким-то типом транзакции и что там из-за порядка срабатывания связанного с hibernate возникала такая ошибка, ту проблему они вроде как пофиксили, но там есть другой баг, который возможно связан с этим.
Я не стал больше изучать эту проблему и добавил зависимость на quarkus-spring-data-jpa, создал репозиторий наследующий интерфейс от JpaRepository и всё заработало без ошибок.
@Repository
public interface CreditEntityRepository extends JpaRepository<CreditEntity, UUID> {
}
Реализация CreditRepository в quarkus аналогичная:
@Transactional
@Override
public void save(@NonNull Credit credit) {
var creditEntity = creditEntityMapper.toCreditEntity(credit);
creditEntityRepository.save(creditEntity);
}
Для реализации генератора ID и получения времени мы просто меняем аннотацию с @Service на @ApplicationScoped.
Тесты я не стал добавлять, а только проверил работу через вызов ручки в swagger-ui.
На этом добавление quarkus модуля я закончу, далее добавим micronaut.
Реализация infrastructure-micronaut модуля
О micronaut я так же только слышал и не использовал на практике. Этот фреймворк из той же категории, что и quarkus. Поэтому сравнивая с quarkus можно выделить, что тут нет схожего dev режима. Поддерживаются аннотации пакета jakarta.inject это JSR330, а для CDI поддержки нет. Помимо jakarta аннотацией есть свои собственные аннотации для создания бинов, rest контроллеров и т. п.
Добавим модуль credit-infrastructure-micronaut и укажем необходимые зависимости:
credit-infrastructure-micronaut
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.github.bifrurcated</groupId>
<artifactId>hexagonal</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>credit-infrastructure-micronaut</artifactId>
<description>Модуль инфраструктуры фреймворка MICRONAUT</description>
<properties>
<jdk.version>25</jdk.version>
<release.version>25</release.version>
<micronaut.version>4.10.6</micronaut.version>
<micronaut.runtime>netty</micronaut.runtime>
<micronaut.test.resources.enabled>true</micronaut.test.resources.enabled>
<micronaut.aot.enabled>false</micronaut.aot.enabled>
<micronaut.aot.packageName>com.github.bifrurcated.aot.generated</micronaut.aot.packageName>
<exec.mainClass>com.github.bifrurcated.credit.infrastructure.micronaut.Application</exec.mainClass>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.micronaut.platform</groupId>
<artifactId>micronaut-platform</artifactId>
<version>${micronaut.version}</version> <!-- Replace with the desired version -->
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Internal modules -->
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-domain</artifactId>
</dependency>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-api-rest</artifactId>
</dependency>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-account-pool-api</artifactId>
</dependency>
<dependency>
<groupId>com.github.bifrurcated</groupId>
<artifactId>credit-infrastructure-persistence-jpa</artifactId>
</dependency>
<!-- Micronaut Core -->
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-http-client</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-http-server-netty</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-jackson-databind</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-management</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut.beanvalidation</groupId>
<artifactId>micronaut-hibernate-validator</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut.validation</groupId>
<artifactId>micronaut-validation</artifactId>
</dependency>
<!-- Micronaut Data with Spring JPA -->
<dependency>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-hibernate-jpa</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut.sql</groupId>
<artifactId>micronaut-jdbc-hikari</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.data</groupId>
<artifactId>jakarta.data-api</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JSpecify annotations -->
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>${jspecify.version}</version>
<scope>compile</scope>
</dependency>
<!-- MapStruct with Micronaut support -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<scope>runtime</scope>
</dependency>
<!-- OpenAPI (Micronaut OpenAPI) -->
<dependency>
<groupId>io.micronaut.openapi</groupId>
<artifactId>micronaut-openapi</artifactId>
</dependency>
<dependency>
<groupId>io.micronaut.openapi</groupId>
<artifactId>micronaut-openapi-annotations</artifactId>
</dependency>
<dependency>
<groupId>io.micronaut.testresources</groupId>
<artifactId>micronaut-test-resources-client</artifactId>
<scope>provided</scope>
</dependency>
<!-- Tests -->
<dependency>
<groupId>io.micronaut.test</groupId>
<artifactId>micronaut-test-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.micronaut.maven</groupId>
<artifactId>micronaut-maven-plugin</artifactId>
<configuration>
<configFile>aot-jar.properties</configFile>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- Uncomment to enable incremental compilation -->
<!-- <useIncrementalCompilation>false</useIncrementalCompilation> -->
<annotationProcessorPaths combine.self="override">
<path>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-inject-java</artifactId>
</path>
<path>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-processor</artifactId>
<exclusions>
<exclusion>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-inject</artifactId>
</exclusion>
</exclusions>
</path>
<path>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-graal</artifactId>
</path>
<path>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-http-validation</artifactId>
</path>
<path>
<groupId>io.micronaut.openapi</groupId>
<artifactId>micronaut-openapi</artifactId>
<exclusions>
<exclusion>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-inject</artifactId>
</exclusion>
</exclusions>
</path>
<path>
<groupId>io.micronaut.spring</groupId>
<artifactId>micronaut-spring-annotation</artifactId>
<exclusions>
<exclusion>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-inject</artifactId>
</exclusion>
</exclusions>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Amicronaut.processing.group=com.github.bifrurcated</arg>
<arg>-Amicronaut.processing.module=credit-infrastructure-micronaut</arg>
<arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>
Стандартная структура пакетов:
com.github.bifrurcated.credit.infrastructure.micronaut
├── config/
├── in/
├── out/
Как и в quarkus я зарегистрирую бин вручную, а что касается аналога @ComponentScan в micronaut я ничего конкретного не нашёл, есть аннотация @Introspected, но с её помощью у меня получилось зарегистрировать только jpa сущности.
@Factory
public class DomainConfig {
@Bean
public CreditOpeningService creditOpeningService(
TimeProvider timeProvider,
CreditRepository creditRepository,
IdGenerator idGenerator) {
return new CreditOpeningService(timeProvider, new CreditAccountPoolStub(), creditRepository, idGenerator);
}
}
Для регистрации сущностей воспользуемся аннотацией @Introspected, которую разместим в основном Application классе:
@Introspected(
packages = {
"com.github.bifrurcated.credit.infrastructure.persistence.jpa.entity"
},
includedAnnotations = {Entity.class}
)
public class Application {
static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}
Логика rest контроллера не изменилась, только аннотации поменялись на те, что используются в micronaut.
CreditRestController
package com.github.bifrurcated.credit.infrastructure.micronaut.in;
import com.github.bifrurcated.credit.api.rest.CreditRestApi;
import com.github.bifrurcated.credit.api.rest.dto.request.CreditOpenRequest;
import com.github.bifrurcated.credit.api.rest.dto.response.CreditOpenResponse;
import com.github.bifrurcated.credit.domain.service.CreditOpeningService;
import com.github.bifrurcated.credit.infrastructure.micronaut.in.mapper.CreditOpenCommandMapper;
import com.github.bifrurcated.credit.infrastructure.micronaut.in.mapper.CreditOpenResponseMapper;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
import io.micronaut.validation.Validated;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
@Validated
@Controller("/credit")
public class CreditRestController implements CreditRestApi {
private final CreditOpeningService creditOpeningService;
private final CreditOpenCommandMapper creditOpenCommandMapper;
private final CreditOpenResponseMapper creditOpenResponseMapper;
@Inject
public CreditRestController(CreditOpeningService creditOpeningService,
CreditOpenCommandMapper creditOpenCommandMapper,
CreditOpenResponseMapper creditOpenResponseMapper) {
this.creditOpeningService = creditOpeningService;
this.creditOpenCommandMapper = creditOpenCommandMapper;
this.creditOpenResponseMapper = creditOpenResponseMapper;
}
@Post("/open")
@Override
public CreditOpenResponse open(@Body @Valid CreditOpenRequest request) {
var creditOpenCommand = creditOpenCommandMapper.toCreditOpenCommand(request);
var credit = creditOpeningService.open(creditOpenCommand);
return creditOpenResponseMapper.toCreditOpenResponse(credit);
}
}
Для CreditAccountPool так же воспользуемся stub реализацией. Что же касается репозитория то тут есть свой аналог spring-data-jpa это зависимость micronaut-data-hibernate-jpa.
package com.github.bifrurcated.credit.infrastructure.micronaut.out;
import com.github.bifrurcated.credit.infrastructure.persistence.jpa.entity.CreditEntity;
import io.micronaut.data.annotation.Repository;
import io.micronaut.data.jpa.repository.JpaRepository;
import java.util.UUID;
@Repository
public interface CreditEntityJpaRepository extends JpaRepository<CreditEntity, UUID> {
}
По остальным реализациям spi меняем всё на аннотацию @Singleton.
Дополнительно я хотел добавить фреймворк helidon от Oracle, но так и не разобрался с их зависимостями, чтобы просто запустить проект.
Заключение
Все эти архитектуры, как бы они не назывались, стремятся к одному и тому же - к такой структуре кода, которая будет легко расширяться и быть слабосвязанной. Те же самые принципы SOLID.
В этой статье я продемонстрировал на практике, что создание такого расширяемого кода возможно не только для библиотеки или фреймворка, но и для бизнес-сервиса. Вы можете и должны учиться писать код, используя различные технологии, а не быть заложниками узкого технологического стека.
На практике плохую реализацию всегда можно заменить, но не плохой дизайн кода.
В общем виде получилась такая структура связи модулей:

Используемые материалы: