Библиотека usecase-pattern

Готовая Java-библиотека: UseCase / UseCaseHandler / UseCaseDispatcher, Spring Boot auto-configuration и метрики Micrometer на каждый use case.

Статья внедрена в скилл AI-агента ucp-pattern-review / ucp-pattern-design Эталонная библиотека к статье usecase-pattern usecase-pattern библиотека

Чтобы не писать каркас руками, есть библиотека-стартер с готовыми интерфейсами UseCase / UseCaseHandler / UseCaseDispatcher и Spring Boot auto-configuration:

github.com/remodov/usecase-pattern

Что внутри

  • usecase-pattern — чистая Java, без Spring. Интерфейсы UseCase<R>, UseCaseHandler<U, R>, UseCaseDispatcher, UseCaseStep<I, O>, маркеры UseCaseCommand / UseCaseQuery для CQRS, EmptyResult-ы для команд без возврата.
  • usecase-pattern-starter — Spring Boot auto-configuration: при старте регистрирует UseCaseDispatcher, автоматически находит все @Component-handler-ы по типу.
  • Метрики Micrometer из коробки на каждый UseCase: usecase_success_total, usecase_failure_total, usecase_duration_seconds с тегами usecase_name и application. Готовы к экспорту в Prometheus.

Подключение

// build.gradle.kts
repositories {
    mavenCentral()
    maven {
        url = uri("https://maven.pkg.github.com/remodov/usecase-pattern")
        credentials {
            username = System.getenv("GITHUB_ACTOR")
            password = System.getenv("GITHUB_TOKEN")
        }
    }
}

dependencies {
    implementation("ru.vikulinva:usecase-pattern-starter:1.1.0")
    // для экспорта метрик в Prometheus
    implementation("io.micrometer:micrometer-registry-prometheus")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
}

Stack-агностичность

Библиотека не привязана к конкретному ORM или хранилищу. Подключайте jOOQ, JPA, Hibernate, MyBatis — UseCaseHandler об этом не знает: он работает с доменными интерфейсами репозиториев и портов.

Расширение CQRS

В пакете ru.vikulinva.usecase.cqrs есть два маркер-интерфейса, наследующих UseCase<R>:

package ru.vikulinva.usecase.cqrs;

public interface UseCaseCommand<R> extends UseCase<R> {}
public interface UseCaseQuery<R>   extends UseCase<R> {}

Сами по себе они не меняют поведение диспетчера — он по-прежнему ищет handler по конкретному классу UseCase. Разделение даёт три практических эффекта.

1. Явный контракт операции

UseCase, помеченный UseCaseCommand, меняет состояние (создание, обновление, удаление). UseCase с UseCaseQueryтолько читает. Это выражено в типе, его проверяет компилятор и читает любой инструмент (IDE, ревью, скилл AI-агента).

public record CreateOrderUseCase(CreateOrderRequestJsonBean request)
    implements UseCaseCommand<OrderJsonBean> {}

public record FindOrderByIdUseCase(UUID orderId)
    implements UseCaseQuery<OrderJsonBean> {}

2. Разные политики транзакций и кэширования

На handler команды вешается @Transactional (запись), на handler запроса — @Transactional(readOnly = true) или @Cacheable. Можно настроить isolation/propagation отдельно для каждой ветки.

@Component
@RequiredArgsConstructor
public class FindOrderByIdHandler
        implements UseCaseHandler<FindOrderByIdUseCase, OrderJsonBean> {

    private final OrderViewRepository views;

    @Override
    @Transactional(readOnly = true)
    @Cacheable(cacheNames = "orders", key = "#useCase.orderId()")
    public OrderJsonBean handle(FindOrderByIdUseCase useCase) {
        return views.findById(useCase.orderId())
            .orElseThrow(() -> new OrderException.NotFound(useCase.orderId()));
    }

    @Override
    public Class<FindOrderByIdUseCase> useCaseType() {
        return FindOrderByIdUseCase.class;
    }
}

3. Разные модели данных

Команды работают с записывающей моделью (OrdersPojo, агрегат Order), запросы — со своей Read Model (OrderView, материализованное представление, отдельная denormalized-таблица). Это позволяет масштабировать чтения и записи независимо: реплика для запросов, master для команд, разные индексы.

public interface OrderRepository {                // ← только команды
    void save(Order order);
}

public interface OrderViewRepository {            // ← только запросы
    Optional<OrderView> findById(UUID id);
    Page<OrderView> search(OrderSearchFilter filter, Pageable pageable);
}

Когда не вводить CQRS

Если у вас CRUD без значимой разницы между «писать» и «читать», и одна и та же таблица обслуживает оба — оставайтесь на Уровне 1, на простом UseCase<R> без маркеров. Преждевременное разделение на Command/Query — лишний слой, не дающий пользы.

См. Четыре уровня внедрения на главной странице методологии — Уровни 1 и 2 различаются именно этим.


Подробный референс Use Case Pattern

Архитектура слоёв моделей

Идея проста: каждый слой отвечает за свою задачу, у каждого слоя — своя модель данных. Никаких сквозных «общих DTO», которые тащат через всё приложение.

REST API Layer (Controller)
       ↓
JsonBean (Request/Response DTOs)
       ↓
UseCase (Command / Query)
       ↓
UseCaseHandler (Business Logic)
       ↓
Repository (Data Access)
       ↓
JOOQ Pojo / View (Data Models)

Слои моделей

1. JsonBean Layer (API Layer)

  • RequestJsonBean — входящие данные от REST API
  • ResponseJsonBean — исходящие данные для REST API
  • Генерируются автоматически из OpenAPI-спецификации
  • Пример: CreateOrderRequestJsonBean, OrderJsonBean

2. UseCase Layer (Business Layer)

  • UseCase содержит JsonBean-объекты или необходимые поля для передачи данных в Handler
  • UseCaseHandler содержит бизнес-логику
  • Аналог Service в луковой архитектуре, но без God Object
  • Если Handler слишком большой — сервисные методы выносятся в отдельные функциональные классы (UseCaseStep)

3. Repository Layer (Data Access Layer)

  • JOOQ Pojo — простые объекты данных одной таблицы
  • View — составные объекты (JOIN нескольких таблиц) из Pojo
  • Возвращает Pojo для простых операций
  • Возвращает View для сложных запросов с JOIN

Примеры реализации

UseCase (вход / выход — JsonBean)

public record CreateOrderUseCase(
    CreateOrderRequestJsonBean request
) implements UseCase<OrderView> {}

UseCaseHandler (бизнес-логика)

@Component
@RequiredArgsConstructor
public class FindOrderByIdUseCaseHandler
        implements UseCaseHandler<FindOrderByIdUseCase, OrderJsonBean> {

    private final OrderRepository orderRepository;
    private final OrderJsonBeanMapper mapper;

    @Transactional(readOnly = true)
    @Override
    public OrderJsonBean handle(FindOrderByIdUseCase useCase) {
        UUID id = useCase.orderId();
        return mapper.map(
            orderRepository.findViewById(id)
                .orElseThrow(() -> new OrderException.NotFound(
                    "Order id=%s not found".formatted(id)))
        );
    }

    @Override
    public Class<FindOrderByIdUseCase> useCaseType() {
        return FindOrderByIdUseCase.class;
    }
}

Controller (вызов через диспетчер)

@RestController
@RequiredArgsConstructor
public class OrderController implements OrdersApi {

    private final UseCaseDispatcher useCaseDispatcher;

    @Override
    public ResponseEntity<OrderJsonBean> getOrderById(UUID id) {
        OrderJsonBean order =
            useCaseDispatcher.dispatch(new FindOrderByIdUseCase(id));
        return ResponseEntity.ok(order);
    }

    @Override
    public ResponseEntity<OrderJsonBean> createOrder(CreateOrderRequestJsonBean request) {
        OrderJsonBean order =
            useCaseDispatcher.dispatch(new CreateOrderUseCase(request));
        return ResponseEntity.ok(order);
    }
}

Repository (работа с JOOQ Pojo / View)

public interface OrderRepository {
    void save(OrdersPojo order,
              Set<OrderItemsPojo> items,
              List<OrderEventsPojo> events);

    Optional<OrdersPojo> findOne(OrderFilter filter, Boolean forUpdate);

    Optional<OrderView> findViewById(UUID id, Boolean forUpdate);
    Optional<OrderView> findOneView(OrderFilter filter, Boolean forUpdate);
}

JOOQ Repository Implementation

@Repository
@RequiredArgsConstructor
public class JooqOrderRepository implements OrderRepository {

    private final DSLContext dsl;
    private final OrderViewMapper orderViewMapper;

    @Override
    public Optional<OrderView> findViewById(UUID id, Boolean forUpdate) {
        Record record = dsl
            .select(
                ORDERS.asterisk(),
                CUSTOMERS.asterisk(),
                USERS_CREATED_BY.asterisk(),
                USERS_UPDATED_BY.asterisk()
            )
            .from(ORDERS)
            .innerJoin(CUSTOMERS).on(CUSTOMERS.ID.eq(ORDERS.CUSTOMER_ID))
            .innerJoin(USERS_CREATED_BY).on(USERS_CREATED_BY.ID.eq(ORDERS.CREATED_BY))
            .innerJoin(USERS_UPDATED_BY).on(USERS_UPDATED_BY.ID.eq(ORDERS.UPDATED_BY))
            .where(ORDERS.ID.eq(id))
            .fetchOne();

        return record == null ? Optional.empty() : Optional.of(orderViewMapper.map(record));
    }

    @Override
    public Optional<OrdersPojo> findOne(OrderFilter filter, Boolean forUpdate) {
        return dsl
            .selectFrom(ORDERS)
            .where(composeOrderFilter(filter))
            .fetchOptional()
            .map(r -> r.into(OrdersPojo.class));
    }
}

View (составные модели)

public record OrderView(
    OrdersPojo order,
    CustomersPojo customer,
    UsersPojo createdBy,
    UsersPojo updatedBy,
    List<OrderItemsPojo> items,
    List<OrderEventsPojo> events
) {}

Маппинг между слоями

JsonBean ↔ JOOQ Pojo (через MapStruct)

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface CreateOrderRequestJsonBeanMapper {

    @Mapping(target = "id", source = "id")
    @Mapping(target = "customerId", source = "request.customerId")
    @Mapping(target = "currency", expression = "java(\"RUB\")")
    @Mapping(target = "createdBy", source = "userId")
    @Mapping(target = "updatedBy", source = "userId")
    @Mapping(target = "createdAt", source = "now")
    @Mapping(target = "updatedAt", source = "now")
    OrdersPojo map(UUID id,
                   CreateOrderRequestJsonBean request,
                   UUID userId,
                   OffsetDateTime now);
}

JOOQ Pojo ↔ View

@Component
public class OrderViewMapper implements RecordMapper<Record, OrderView> {

    @Override
    public OrderView map(Record record) {
        return new OrderView(
            record.into(ORDERS).into(OrdersPojo.class),
            record.into(CUSTOMERS).into(CustomersPojo.class),
            record.into(USERS_CREATED_BY).into(UsersPojo.class),
            record.into(USERS_UPDATED_BY).into(UsersPojo.class),
            record.get(ORDER_ITEMS_MULTISET),
            record.get(ORDER_EVENTS_MULTISET)
        );
    }
}

Принципы работы с моделями

  • Вход UseCase: только JsonBean-объекты
  • Выход UseCase: JsonBean
  • Repository-методы:
    • save / update / delete — принимают JOOQ Pojo
    • findOne — возвращает JOOQ Pojo (простые запросы)
    • findViewById / findOneView — возвращают View (сложные JOIN)
  • Маппинг: MapStruct для автоматической генерации мапперов
  • Транзакции: @Transactional на уровне UseCaseHandler
  • События: публикация EntityEvent после успешного сохранения через Transactional Outbox

Диспетчер UseCase

UseCaseDispatcher — центральный компонент маршрутизации. Контроллер не знает, какой именно handler будет вызван — он отдаёт команду диспетчеру.

@Component
@RequiredArgsConstructor
@Slf4j
public class UseCaseDispatcher {

    private final Map<Type, UseCaseHandler<? extends UseCase<?>, ?>> handlers;

    public <R> R dispatch(UseCase<R> useCase) {
        log.info("Dispatching use case {}", useCase);
        try {
            R result = findHandler(useCase).handle(useCase);
            log.info("Use case {} dispatched successfully", useCase);
            return result;
        } catch (Throwable e) {
            log.error("Failed to dispatch use case {}", useCase, e);
            throw e;
        }
    }

    @SuppressWarnings("unchecked")
    private <R> UseCaseHandler<UseCase<R>, R> findHandler(UseCase<R> useCase) {
        return Optional.ofNullable(handlers.get(useCase.getClass()))
            .map(handler -> (UseCaseHandler<UseCase<R>, R>) handler)
            .orElseThrow(() -> new UseCaseNotSupportedException(
                "Use case \"%s\" not supported".formatted(useCase)));
    }
}

Пустое значение для UseCase

public class EmptyUseCaseResult {
    public static final EmptyUseCaseResult INSTANCE = new EmptyUseCaseResult();
    private EmptyUseCaseResult() {}
}

UseCaseStep — выделение переиспользуемой логики

Когда Handler начинает разрастаться, общую логику выносим в отдельные классы — UseCaseStep.

@FunctionalInterface
public interface UseCaseStep<I, O> {
    O execute(I input);
}

Правила:

  • Step нужен для вынесения переиспользуемой логики из UseCase в отдельный класс.
  • Нельзя вкладывать Step-ы друг в друга. Если хочется — значит логика должна быть в Handler-е.
@Slf4j
@Component
@RequiredArgsConstructor
public class ArchiveOldOrderItemsUseCaseStep
        implements UseCaseStep<Long, UseCaseStepEmptyResult> {

    private final OrderItemsRepository orderItemsRepository;

    @Override
    public UseCaseStepEmptyResult execute(Long orderId) {
        // Логика архивации
        return UseCaseStepEmptyResult.INSTANCE;
    }
}

Преимущества архитектуры

Чёткое разделение ответственности

  • Controller — HTTP, валидация, идемпотентность.
  • UseCase — контракт между слоями (data carrier).
  • UseCaseHandler — бизнес-логика и транзакции.
  • Repository — доступ к данным.
  • JsonBean — API-контракт.
  • JOOQ Pojo / View — модели данных.

Тестируемость

  • Каждый слой можно тестировать независимо.
  • UseCaseHandler легко мокать для unit-тестов.
  • Repository можно тестировать с in-memory БД или Testcontainers.

Переиспользование

  • Один UseCase может использоваться разными контроллерами (REST, GraphQL, async-обработчик Kafka).
  • Repository-методы переиспользуются в разных Handler-ах.

Типобезопасность

  • jOOQ генерирует типизированные классы из схемы БД.
  • MapStruct обеспечивает безопасный маппинг.
  • Generic-типы в UseCase<R> дают compile-time проверки результата.

Производительность

  • jOOQ оптимизирует SQL-запросы и не имеет n+1-проблем JPA.
  • View-объекты позволяют делать сложные JOIN в одном запросе.
  • MapStruct генерирует эффективный код маппинга (без рефлексии).

Поддержка транзакций

  • @Transactional на уровне UseCaseHandler.
  • Возможность настройки propagation и isolation на уровне use case.

Дальше