Библиотека usecase-pattern
Готовая Java-библиотека: UseCase / UseCaseHandler / UseCaseDispatcher, Spring Boot auto-configuration и метрики Micrometer на каждый use case.
Чтобы не писать каркас руками, есть библиотека-стартер с готовыми интерфейсами 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 APIResponseJsonBean— исходящие данные для REST API- Генерируются автоматически из OpenAPI-спецификации
- Пример:
CreateOrderRequestJsonBean,OrderJsonBean
2. UseCase Layer (Business Layer)
UseCaseсодержит JsonBean-объекты или необходимые поля для передачи данных в HandlerUseCaseHandlerсодержит бизнес-логику- Аналог
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 PojofindOne— возвращает 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.
Дальше
- Use Case Pattern — методология — как библиотека встраивается в общий хребет.
- Тактические паттерны DDD — что происходит внутри
UseCaseHandlerна Уровне 3. - Гексагональная архитектура — как из
UseCaseHandlerсделать Core, а контроллеры и репозитории — адаптерами (Уровень 4). - Распределённые паттерны — UseCase превращается в Saga, Outbox встраивается в
@TransactionalHandler-а.