Создать 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. Существует онлайн-ресурс для работы с данным редактором:

https://editor.swagger.io

Также возможно локальное развертывание сервиса 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. 

https://start.spring.io

Мы решили, что хотим использовать JDK 21 и реактивный стек, кроме того для доступа к БД будем использовать JOOQ, как будто у нас сложные запросы с фильтрами и соединениями таблиц.

  1. Для упрощения генерации POJO добавим Lombok. 

  2. Spring Reactive Web, реактивный контроллер на основе Netty сервера. 

  3. JOOQ Access Layer - для доступа к БД. Хорошо, что JOOQ умеет использовать реактивный драйвер. (Альтернативно могли бы использовать Spring Data R2DBC, это описано в документации Spring, для Reactor, но для нас важен JOOQ, как более удобный инструмент для составления сложных запросов) 

  4. PostgresSQL Driver – для подключения к БД Postgres. Подмечаем, что он умеет делать R2DBC. 

  5. Liquibase Migration – для создания структуры БД, тут нам не нужна реактивность.  

  6. Spring Boot Actuator – для поддержки работы в кластере 

  7. Prometheus – для мониторинга сервиса 

  8. Testcontainers - для создания автотестов в сервисе с использованием БД Postgres, расположенной в контейнере 

  9. Еще мы хотимswagger-ui для тестировщиков, но его нет в initalizer, поэтому добавим его сами позднее.

spring initializr  Project  Language  Dependencies  ADD DEPENDENCIES ... CTRL + B  Gradle - Groovy  O Gradle - Kotlin  Java  Kotlin  Groovy  Maven  Lombok  DEVELOPER TOOLS  Spring Boot  Java annotation library which helps to reduce boilerplate code.  O 4.0.2 (SNAPSHOT)  4.0.1  3.5.10 (SNAPSHOT)  3.5.9  Spring Reactive Web  WEB  Project Metadata  Build reactive web applications with Spring WebFlux and Netty.  Group  ru.maximserver  Artifact  vm-user-service  Spring Data R2DBC  SOL  Provides Reactive Relational Database Connectivity to persist data in SQL stores using Spring  Name  vm-user-service  Data in reactive applications.  Description  User information service  JOOQ Access Layer  SQL  Generate Java code from your database and build type safe SQL queries through a fluent API.  Package name  ru.maximserver.vmuserservice  PostgreSQL Driver  SOL  Packaging  Jar  O War  A JDBC and R2DBC driver that allows Java programs to connect to a PostgreSQL database  using standard, database independent Java code.  Configuration  O Properties  YAML  Liquibase Migration  SQL  Java  25  21  O 17  Liquibase database migration and source control library.  Spring Boot Actuator  OPS  Supports built in (or custom) endpoints that let you monitor and manage your application - such  as application health, metrics, sessions, etc.  Prometheus  OBSERVABILITY  Expose Micrometer metrics in Prometheus format, an in-memory dimensional time series  database with a simple built-in UI, a custom query language, and math operations.  Testcontainers  TESTING  Provide lightweight, throwaway instances of common databases, Selenium web browsers, or  anything else that can run in a Docker container.

 Мы нажимаем кнопку 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();
    }
}

Выводы

  1. JDK21, Postgres, Spring, WebFlux, JOOQ - один из возможных стеков для реализации микросервисов.

  2. Использование рассмотренных генераторов может значительно сэкономить время разработки.

  3. Генераторы имеют ограничения и могут не обладать необходимыми настройками или параметрами.

  4. Использование генераторов целесообразно для микросервисных проектов ввиду ограниченого числа факторов при создании таких проектов.

Комментарии (9)


  1. maximvasilievmaxim Автор
    04.01.2026 13:42

    Проект можно посмотреть здесь:

    https://github.com/maxmiracle/vm-user-service


  1. Snaret
    04.01.2026 13:42

    Спасибо за статью, позволю себе несколько замечаний по коду (так сказать ревью):

    • Mono<@NonNull Void> - крайне спорно и по-моему бессмысленно

    • в UserService отсутствует обработка ошибок. Вообще.

    В целом подозреваю, что все это сделано из-за быстроты и генерации части кода OpenAPI Generator. Советую поставить openApiNullable: "true".

    Есть непонятный мне конфликт:
    useOptional: "true", //Использовать Optional
    openApiNullable: "false", // Но ничего не может быть null?!

    В тесте вообще не пойму откуда User user = new User();

    Ведь если это DTO из OpenAPI то как минимум в коде вижу UpdateUser. Да и что UpdateUser делает в createUser методе?)

    В github вообще не нашел папки по пути ru.maximserver.vmuserservice.model указанные в сервисном слое.

    Очень много загадочного в проекте на мой взгляд))

    Прошу простить, что надушнил, но кто-то(например я) захочет что-то изучить по вашему коду и столкнется с множеством проблем.


    1. maximvasilievmaxim Автор
      04.01.2026 13:42

      Добрый день! В проекте нет пакета model - верно . Классы пакета генерируются openApiGenerator. Чтобы они появились в папке build нужно запустить сборку проекта.


    1. maximvasilievmaxim Автор
      04.01.2026 13:42

      Поправил тест, сделал фикс, поменял User на UpdateUser. Спасибо за замечание.
      https://github.com/maxmiracle/vm-user-service/commit/d27e55ed816cf45f91e33ddac7db5352b23c1f34
      Действительно, UpdateUser используется и для post (create) и для update. А User для get. Согласен, что это не очень хорошее допущение для примера.
      Эти классы генерируются из OpenApi на лету. По легенде, это придумал Системный Аналитик:)


      1. Snaret
        04.01.2026 13:42

        Я бы вам порекомендовал в качестве дто использовать не объекты из OpenApi, а из JOOQ. Там и record'ы есть для спокойной передачи данных


  1. LeshaRB
    04.01.2026 13:42

    createUser параметр передается update user
    createUser передается user ID, а если такой id уже занят?


    1. maximvasilievmaxim Автор
      04.01.2026 13:42

      С точки зрения бизнес логики это было сделано в контексте хранения пользователей авторизовавшихся через vk. vk_id не должен повторятся по требованиям. Эта бизнес логика безотносительнп темы статьи. Прошу меня простить.


  1. Neuronix
    04.01.2026 13:42

    Почему выбран реактивный стек? (особенно учитывая вводные данные в виде java 21 в которой уже есть virtual threads) Стильно, модно, молодежно? Тогда еще напишите, если есть конечно опыт, во что он превратится через несколько лет активной разработки разными людьми, когда там будет что то сложнее этого hello world. И как его прекрасно будет дебажить. Статья явно рассчитана на начинающих, может не стоит вот так вот сразу испытывать устойчивость их психики реактивщиной?


    1. maximvasilievmaxim Автор
      04.01.2026 13:42

      Добрый день!

      Реактивный движок актуален, когда важно быстро обработать запрос. Из своей практики приведу несколько примеров проектов: в одном случае это был платёжный шлюз, в другом — кредитный конвейер. Конечно, проблемы с отладкой возможны, однако при грамотной организации автотестов это уже не столь критично, поскольку достигается значительное преимущество. Если заказчик внимательно следит за производительностью через JMeter и учитывает каждую миллисекунду, выбор реактивного движка становится вполне обоснованным. По virtual threads надо проводить тесты и смотреть, будет ли это сопоставимо по эффекту. Кстати, отличная тема для изучения и написания статьи!