Создать Spring-сервис просто: существует масса статей и отличная документация. Однако среди всего этого многообразия материалов зачастую сложно разобраться, какой именно набор технологий лучше выбрать и каким образом эти технологии должным образом интегрировать друг с другом. После перехода на новые версии библиотек многое начинает функционировать иначе, появляются совершенно другие подходы. В данной статье я хочу продемонстрировать один из возможных способов разработки микросервиса в 2026 году, а также рассмотреть несколько инструментов автоматической генерации кода: OpenApiGenerator, JooqCodegen, GigaChat, Liquibase — и объяснить, как они работают вместе в рамках единого проекта.
Точкой отсчета для построения микросервиса сделаем описание REST API. Допустим, в нашем фантастическом мире аналитик заранее подготовил спецификацию API с помощью формата OpenAPI, хотя подобное встречается и в реальной практике.
Схема процесса создания микросервиса

Кроме API сервиса, мы еще имеем нефункциональные требования, которые принимаются в команде. Одной из целей данной статьи показать определенный набор, формирующий стек разработки: JDK 21, Spring, WebFlux, Postgres, Liquibase, JOOQ. В данной статье не рассматривается преимущество данного стека по сравнению с hibernate, rust, vert.x, micronaut и др. компоненты, которые могут быть использованы для реализации rest API.
Таким образом, статья постулирует, что данный набор технологий может быть успешно совместно использован, если нет других требований.
Создание описания rest‑api
Для разработки openAPI REST-интерфейса аналитику рекомендуется использовать специализированный инструмент swagger-editor. Существует онлайн-ресурс для работы с данным редактором:
Также возможно локальное развертывание сервиса swagger-editor через Docker-контейнер для обеспечения конфиденциальности разрабатываемых файлов:
https://hub.docker.com/r/swaggerapi/swagger-editor
На выходе мы получим файл, содержащий описание планируемых REST API-сервисов.
openapi: 3.0.0 info: description: | This is simple client API version: "1.0.0" title: User Service contact: email: vasiliev.maxim@gmail.com servers: - description: VM User Service url: http://vm-user-service.maximserver/ tags: - name: user description: Operations about user paths: /user/{userId}: parameters: - name: userId in: path description: ID of user required: true schema: type: integer format: int64 post: tags: - user summary: Create user description: If user is not found, create new user operationId: createUser requestBody: content: application/json: schema: $ref: '#/components/schemas/UpdateUser' examples: sample-user: summary: Example value: username: johndoe589 firstName: John lastName: Doe email: bestjohn@doe.com phone: '+71002003040' description: Created user object required: true responses: '204': description: User Created '409': description: User already exists get: tags: - user description: Returns a user based on a single ID, if the user does not have access to the user operationId: find user by id responses: '200': description: user response content: application/json: schema: $ref: '#/components/schemas/User' '404': description: user not found delete: tags: - user description: deletes a single user based on the ID supplied operationId: deleteUser responses: '204': description: user deleted '404': description: user not found put: tags: - user description: Update user with User ID supplied operationId: updateUser requestBody: content: application/json: schema: $ref: '#/components/schemas/UpdateUser' examples: sample-user: summary: Example value: firstName: Julie lastName: Doe email: bestjohn@doe.com phone: '+71004242424' responses: '200': description: User updated '404': description: User not found default: description: Unexpected error content: application/json: schema: $ref: '#/components/schemas/Error' components: schemas: User: type: object properties: id: type: integer format: int64 example: 123 username: type: string maxLength: 256 example: johndoe589 firstName: type: string example: John lastName: type: string example: Doe avatar: type: string example: https://example.com/avatar.jpg sex: type: number example: 1 verified: type: boolean example: false birthday: type: string pattern: '^(0[1-9]|[1-2][0-9]|3[0-1])\\.(0[1-9]|1[0-2])\\.\\d{4}$' example: 19.03.1982 email: type: string format: email example: bestjohn@doe.com phone: type: string format: phone example: '+71002003040' UpdateUser: type: object properties: username: type: string maxLength: 256 example: johndoe589 firstName: type: string example: John lastName: type: string example: Doe avatar: type: string example: https://example.com/avatar.jpg sex: type: number example: 1 verified: type: boolean example: false birthday: type: string pattern: '^(0[1-9]|[1-2][0-9]|3[0-1])\\.(0[1-9]|1[0-2])\\.\\d{4}$' example: 19.03.1982 email: type: string format: email example: bestjohn@doe.com phone: type: string format: phone example: '+71002003040' Error: type: object required: - code - message properties: code: type: integer format: int32 message: type: string
В файле представлено API post, get, update, delete для данных пользователя. Причем для post, update используется структура UpdateUser, а для get возвращается User. Данная бизнес логика и использование идентификаторов, а также наличие определенных полей безотносительно задачи нашего рассмотрения и служит примером использования генераторов и их взаимодействия.
Создание java проекта
Создание java проекта или gradle-проекта в нашем случае и несколько файлов кода.
Для создания проекта воспользуемся генератором проекта spring initializer.
Мы решили, что хотим использовать JDK 21 и реактивный стек, кроме того для доступа к БД будем использовать JOOQ, как будто у нас сложные запросы с фильтрами и соединениями таблиц.
Для упрощения генерации POJO добавим Lombok.
Spring Reactive Web, реактивный контроллер на основе Netty сервера.
JOOQ Access Layer - для доступа к БД. Хорошо, что JOOQ умеет использовать реактивный драйвер. (Альтернативно могли бы использовать Spring Data R2DBC, это описано в документации Spring, для Reactor, но для нас важен JOOQ, как более удобный инструмент для составления сложных запросов)
PostgresSQL Driver – для подключения к БД Postgres. Подмечаем, что он умеет делать R2DBC.
Liquibase Migration – для создания структуры БД, тут нам не нужна реактивность.
Spring Boot Actuator – для поддержки работы в кластере
Prometheus – для мониторинга сервиса
Testcontainers - для создания автотестов в сервисе с использованием БД Postgres, расположенной в контейнере
Еще мы хотим
swagger-uiдля тестировщиков, но его нет в initalizer, поэтому добавим его сами позднее.

Мы нажимаем кнопку Generate и скачиваем zip с проектом, который размещаем в локальной папке, для репозиториев.
В созданный проект добавляем ранее созданный openApi файл:
src/main/resources/specification/openapi-users.yaml
Создание git репозитория
Создаем git репозиторий и сохраняем в нем новый проект vm-user-service
Проект можно посмотреть здесь:
https://github.com/maxmiracle/vm-user-service
Генерируем rest api из описания openAPI.yaml
Добавляем плагин id 'org.openapi.generator' version '7.14.0' Настраиваем генерацию файлов с помощью gradle task, которая будет запускаться перед compileJava.
compileJava.dependsOn "openApiGenerate" openApiGenerate { Directory outputGenerated = layout.buildDirectory.dir("generated").get() generatorName.set("spring") inputSpec.set("$rootDir/src/main/resources/specification/openapi-users.yaml") outputDir.set("$outputGenerated/openapi") apiPackage.set("ru.maximserver.vmuserservice.api") modelPackage.set("ru.maximserver.vmuserservice.model") configOptions.set([library : "spring-boot", useOptional : "true", openApiNullable : "false", interfaceOnly : "true", generatedConstructorWithRequiredArgs: "false", useTags : "true", basePackage : "ru.maximserver.vmuserservice", useJakartaEe : "true", reactive : "true"]) }
Теперь при запуске сборки build или отдельного запуска task openApiGenerate будут созадваться исходные коды в папке build/generated/openapi. Добавим эту папку в список исходников:
sourceSets { main { java { srcDirs += ["$project.buildDir/generated/openapi/src/main/java"] } } }
Запуск openApiGenerate выполняется перед этапом компиляции. Результат будет формировать временные файлы. Таким образом, классы DTO, такие как UpdateUser, User будут формироваться на лету из openAPI файла. В рассматриваемом проекте эти файлы не хранятся в git. Поэтому, если вы загрузите git-репозиторий, то запустите gradle task 'compileJava', чтобы увидеть сгенерированный код.
Сделаем заготовки файлов с помощью ИИ, чтобы меньше набирать кода.
Воспользуемся сервисом GIGA CODE.
Скормим AI следующий promt + openapi-users.yaml
Сгенерируй, используя описание REST API из файла @openapi-users.yaml: - Интерфейс rest контроллера - Классы dto объектов rest - Реализация rest контроллера - Скрипт Liquibase для Data Layer этого контроллера - Data Layer с использованием JOOQ Особенности реализации: - Классы разместить в пакете ru.maximserver.vmuserservice. - Для dto объектов использовать Lombok annotations. - Интерфейс аннатационного rest контроллера необходимо сделать с использованием WebFlux с добавлением аннотаций для swagger io.swagger.v3.oas.annotations.* - Использованы **Swagger 3 (OpenAPI 3)** аннотации: `@Operation`, `@ApiResponse`, `@Parameter`, `@Tag`, `@Schema`, `@Content`, `@ExampleObject`. - Все примеры из OpenAPI (например, `sample-user`) перенесены в аннотации. - Поддержка **WebFlux** через `Mono<ResponseEntity<T>>`. - DTO-классы содержат `@Schema` с описаниями и примерами — это улучшает документацию в Swagger UI. - Поля с примерами и ограничениями (например, `maxLength`, `pattern`) аннотированы соответствующим образом. - Добавить Конфигурацию OpenAPI (в `application.yml`) - Добавить валидацию (`@Valid`, `@NotBlank`, `@Email`, `@Pattern`)
Однако, большинство полученных файлов будет использовано с редакцией. Важно понимать и помнить, что ИИ может пропустить важный аспект реализации.
На основе сгенерированного gigacode создаем liquibase скрипт.
Скрипт размещается в ресурсах проекта в папке src/main/resources/db/changelog Скрипт состоит из основного файла, который указывает, какие файлы входят в состав главного. Библиотека liquibase позволяет создавать структуру БД и управлять изменениями этой структуры. По сути это DDL, который может быть представлен xml, yaml или непосредственно sql скриптами. Также liquibase используется в тестовых сценариях вместе с библиотекой testcontainers.
Теперь тест сгенерированный spring initializer, который запускает приложение, отрабатывает без ошибок, так как liquibase теперь генерирует БД и сервис может к ней подключиться.
На основе сгенерированного скрипта Liquibase генерируем JOOQ metadata classes.
Подключаем плагин gradle plugin
plugins { ... id 'org.jooq.jooq-codegen-gradle' version '3.19.17'
Добавляем dependencies
jooqCodegen "org.jooq:jooq-meta-extensions-liquibase:3.19.17"
Добавляем настройки gradle task, ставим зависимость для компиляции (compileJava) от этого нового task.
compileJava.dependsOn "jooqCodegen" jooq { configuration { generator { database { name = "org.jooq.meta.extensions.liquibase.LiquibaseDatabase" properties { property { key = "rootPath" value = "$rootDir/src/main/resources" } property { key = "scripts" value = "/db/changelog/db.changelog-master.yaml" } property { key = "includeLiquibaseTables" value = false } property { key = "liquibaseSchemaName" value = "public" } property { key = "changeLogParameters.contexts" value = "!test" } } } target { packageName = "ru.maximserver.vmuserservice.jooq.gen" } } } }
При запуске task jooqCodegen сгенерируется код с метаданными базы данных для библиотеки JOOQ. JOOQ также способен сгенерировать метаданные из БД, однако потребуется поднять инстанс базы данных, например, в Docker. Подобные проекты и сценарии существуют и поддерживаются некоторыми компаниями / командами / проектами. Генерация JOOQ из скриптов Liquibase выглядит изящно, но накладывает определённые ограничения. Один из возможных подходов — зафиксировать метафайлы JOOQ в репозитории проекта (Git), а не генерировать их динамически через Gradle-задачу, как демонстрировалось ранее. Сейчас перед этапом compileJava запускаются обе задачи: openApiGenerate и jooqCodegen.
Реализация rest-контроллера
Интерфейс контроллера уже сгенерирован, нужно лишь определить реализацию, в которой основным действием будет вызов сервисного слоя. Интерфейс генерируется в файле build/generated/openapi/src/main/java/ru/maximserver/vmuserservice/api/UserApi.java.
Реализуем сгенерированный OpenApiGenerator-ом интерфейс UserApi в новом классе:
package ru.maximserver.vmuserservice.controller; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import ru.maximserver.vmuserservice.api.UserApi; import ru.maximserver.vmuserservice.model.UpdateUser; import ru.maximserver.vmuserservice.model.User; import ru.maximserver.vmuserservice.service.UserService; @RestController @RequestMapping("${openapi.user-service.base-path:/}") @RequiredArgsConstructor public class UserApiController implements UserApi { private final UserService userService; @Override public Mono<@NonNull ResponseEntity<Void>> createUser( final Long userId, final Mono<@NonNull UpdateUser> updateUser, final ServerWebExchange exchange ){ return userService.createUser(updateUser, userId) .then(Mono.just(ResponseEntity.status(HttpStatus.CREATED).build())); } @Override public Mono<@NonNull ResponseEntity<Void>> deleteUser( final Long userId, final ServerWebExchange exchange){ return userService.deleteUser(userId) .thenReturn(ResponseEntity.noContent().build()); } @Override public Mono<@NonNull ResponseEntity<@NonNull User>> findUserById( final Long userId, final ServerWebExchange exchange) { return userService.findUserById(userId).map(ResponseEntity::ok); } @Override public Mono<@NonNull ResponseEntity<Void>> updateUser( final Long userId, final Mono<@NonNull UpdateUser> user, final ServerWebExchange exchange ){ return userService.updateUser(userId, user) .thenReturn(ResponseEntity.noContent().build()); } }
По сути реализация сводится к вызову сервисного слоя.
Реализация сервисного слоя
Сервис работы с сущностью Пользователь реализуем в новом классе UserService.
package ru.maximserver.vmuserservice.service; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; import ru.maximserver.vmuserservice.exception.ResourceNotFoundException; import ru.maximserver.vmuserservice.mapper.UserMapper; import ru.maximserver.vmuserservice.model.UpdateUser; import ru.maximserver.vmuserservice.model.User; import static ru.maximserver.vmuserservice.jooq.gen.tables.UserAccount.USER_ACCOUNT; @Slf4j @Service @RequiredArgsConstructor public class UserService { private final UserMapper userMapper; private final DSLContext dslContext; public Mono<Void> createUser(Mono<@NonNull UpdateUser> user, @NonNull final Long userId) { return user.map(userObject -> userMapper.toUserAccountRecord(userObject, userId)) .flatMap(userAccountRecord -> Mono.from(dslContext.insertInto(USER_ACCOUNT).set(userAccountRecord).returning())) .doOnNext(savedResult -> log.info("Inserted Record:\n{}", savedResult)) .then(); } public Mono<Void> deleteUser(Long userId) { return Mono.from(dslContext.delete(USER_ACCOUNT) .where(USER_ACCOUNT.ID.eq(userId)).returning()) .switchIfEmpty(Mono.error(userNotFound(userId))) .doOnNext(savedResult -> log.info("Deleted Record:\n{}", savedResult)) .then(); } public Mono<@NonNull User> findUserById(Long userId) { return Mono.from(dslContext.selectFrom(USER_ACCOUNT) .where(USER_ACCOUNT.ID.eq(userId))) .switchIfEmpty(Mono.error(userNotFound(userId))) .doOnNext(result -> log.info("Found Record:\n{}", result)) .map(userMapper::toUser); } public Mono<Void> updateUser(Long userId, Mono<@NonNull UpdateUser> user) { return user.map(userObject -> userMapper.toUserAccountRecord(userObject, userId)) .flatMap(userAccountRecord -> Mono.from(dslContext.update(USER_ACCOUNT).set(userAccountRecord) .where(USER_ACCOUNT.ID.eq(userId)).returning())) .switchIfEmpty(Mono.error(userNotFound(userId))) .doOnNext(savedResult -> log.info("Updated Record:\n{}", savedResult)) .then(); } private ResourceNotFoundException userNotFound(Long userId) { log.info("User {} not found", userId); return new ResourceNotFoundException("User not found"); } }
Создание маппера
При реализации сервисного слоя нам понадобился маппер, который связывает сущности базы данных JOOQ с API-сущностями, сгенерированными на предыдущих этапах.
Объявление маппера через интерфейс довольно простое. Вся работа ложится на плечи MapStruct. Препроцессор обработает аннотации и сгенерирует реализацию.
package ru.maximserver.vmuserservice.mapper; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import ru.maximserver.vmuserservice.jooq.gen.tables.records.UserAccountRecord; import ru.maximserver.vmuserservice.model.UpdateUser; import ru.maximserver.vmuserservice.model.User; @Mapper(componentModel = "spring") public interface UserMapper { @Mapping(target="id", source="userId") @Mapping(target=".", source="user") UserAccountRecord toUserAccountRecord(UpdateUser user, Long userId); User toUser(UserAccountRecord userAccountRecord); }
Теперь наше приложение готово. За рамками описания остались конфигурации JOOQ
В случае вопросов обратитесь к репозиторию:
https://github.com/maxmiracle/vm-user-service/blob/master/src/main/java/ru/maximserver/vmuserservice/config/JooqConfig.java
Для эксплуатации сервиса необходимо доработать security сервиса.
Интеграционные тесты
Важная часть проекта — это интеграционные тесты с использованием TestContainers, реализованные как автотесты, то есть тесты, которые можно легко выполнить без трудоёмкого настройки окружения. Благодаря таким тестам при должном уровне покрытия разработчик может спокойно проводить рефакторинг, обновлять библиотеки, не опасаясь, что код может сломаться.
Приведём пример одного такого теста.
Сначала создадим базовый класс для теста, который будет запускать Docker-контейнер с PostgreSQL и конфигурировать сервис значениями, соответствующими тестовой базе данных.
package ru.maximserver.vmuserservice; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; @SpringBootTest @Testcontainers public class BaseIntegrationTest { @Container static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")) .waitingFor(Wait.forListeningPort()) .withCommand("postgres", "-c", "max_connections=500"); @DynamicPropertySource static void props(DynamicPropertyRegistry registry) { registry.add("spring.r2dbc.url", () -> postgreSQLContainer.getJdbcUrl().replaceFirst("jdbc:", "r2dbc:")); registry.add("spring.r2dbc.username", postgreSQLContainer::getUsername); registry.add("spring.r2dbc.password", postgreSQLContainer::getPassword); registry.add("spring.r2dbc.properties.schema", () -> "public"); registry.add("spring.r2dbc.properties.database", postgreSQLContainer::getDatabaseName); registry.add("spring.r2dbc.properties.host", postgreSQLContainer::getHost); registry.add("spring.r2dbc.properties.port", postgreSQLContainer::getFirstMappedPort); registry.add("spring.liquibase.url", postgreSQLContainer::getJdbcUrl); registry.add("spring.liquibase.user", postgreSQLContainer::getUsername); registry.add("spring.liquibase.password", postgreSQLContainer::getPassword); registry.add("spring.liquibase.clear-checksums", () -> "false"); } }
Теперь на основе этого класса создадим тест:
package ru.maximserver.vmuserservice.controller; import org.jooq.DSLContext; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import ru.maximserver.vmuserservice.BaseIntegrationTest; import ru.maximserver.vmuserservice.model.UpdateUser; import static org.assertj.core.api.Assertions.assertThat; import static ru.maximserver.vmuserservice.jooq.gen.Tables.USER_ACCOUNT; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureWebTestClient public class UserApiControllerTest extends BaseIntegrationTest { @Autowired private WebTestClient webTestClient; @Autowired private DSLContext dslContext; @Test public void createUser() { UpdateUser updateUser = new UpdateUser(); updateUser.setUsername("johndoe"); updateUser.setFirstName("John"); updateUser.setLastName("Doe"); updateUser.setEmail("johndoe@mail.com"); updateUser.setPhone("89000000000"); webTestClient.post() .uri("/user/1") .body(Mono.just(updateUser), UpdateUser.class) .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus() .isCreated(); StepVerifier.create(dslContext.selectFrom(USER_ACCOUNT).where(USER_ACCOUNT.USERNAME.eq("johndoe"))) .assertNext(userAccountRecord -> { assertThat(userAccountRecord).isNotNull(); assertThat(userAccountRecord.getId()).isEqualTo(1); assertThat(userAccountRecord.getFirstName()).isEqualTo("John"); assertThat(userAccountRecord.getLastName()).isEqualTo("Doe"); assertThat(userAccountRecord.getEmail()).isEqualTo("johndoe@mail.com"); assertThat(userAccountRecord.getPhone()).isEqualTo("89000000000"); }) .expectComplete() .verify(); } }
Выводы
JDK21, Postgres, Spring, WebFlux, JOOQ - один из возможных стеков для реализации микросервисов.
Использование рассмотренных генераторов может значительно сэкономить время разработки.
Генераторы имеют ограничения и могут не обладать необходимыми настройками или параметрами.
Использование генераторов целесообразно для микросервисных проектов ввиду ограниченого числа факторов при создании таких проектов.
