Catalog Service: пошаговая генерация от бизнес-описания до кода

Полный сеанс работы с Use Case Pattern на примере Catalog Service: конкретные промпты, выдержки из ответов AI, последовательность команд от бизнес-описания до работающего кода. Tier B за 3 часа.

Статья внедрена в скилл AI-агента usecase-pattern-skills Use Case Pattern walkthrough

Полный сеанс работы с методологией на конкретном сервисе. У нас есть спецификация Catalog Service и бизнес-описание маркетплейса. Здесь показана последовательность команд — как из бизнес-описания через серию вызовов AI-скиллов получаются именно эти артефакты.

Это не литературная фантазия и не записанный лог одной сессии — это репрезентация типичного сеанса: какие промпты подавать, какие выдержки ждать в ответе, какая команда идёт следующей. Реальная сессия будет отличаться в деталях (модель формулирует свободно), но общий контур — ровно такой.

Сервис выбран намеренно: Catalog — Tier B, средняя сложность. Не такой простой как Notification (Tier A, без агрегатов), не такой большой как Order (Tier C, с сагами). Хороший образец для понимания, как методология приземляется на реальный модуль.

Стартовая точка: бизнес-описание

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

Товар — конкретное изделие у конкретного продавца. Один и тот же iPhone у двух разных продавцов — это два разных товара.

Размещает товары. Создаёт карточку, заполняет описание, ставит цену и остаток, выбирает категорию. Карточка идёт на модерацию...

Order Service берёт у Catalog цену по productId для оформления заказа.

Только Россия и только рубли.

Этого достаточно, чтобы запустить методологический pipeline.

Шаг 1. Спецификация из бизнес-описания

Открываем Claude Code в проекте. Первая команда:

/ucp-spec-design Сервис каталога маркетплейса. Продавец публикует и
скрывает товары. Order Service берёт у Catalog цену по productId для
оформления заказа. Никаких категорий, поиска, фото — только базовая
карточка товара (title, description, price, seller_id, status).
Tier B — UseCase Pattern с CQRS, без DDD-агрегатов. ABAC: продавец
меняет только свои товары.

Скилл за 1-2 минуты возвращает структуру:

✓ Создан docs/spec/catalog-service.md (консолидированная версия)
✓ Создан docs/spec/01-context.md
✓ Создан docs/spec/02-language.md
... (всего 14 файлов под Tier B — без §8 events и §12 sagas)

Выдержки из созданных артефактов:

docs/spec/02-language.md — Ubiquitous Language

| Термин | Определение |
|---|---|
| **Product** | Карточка товара конкретного продавца. Один и тот же iPhone у двух
| | продавцов — два разных Product. Никакого «склеивания SKU». |
| **Seller** | Продавец маркетплейса. У продукта ровно один owner-seller. |
| **Status** | Состояние карточки: DRAFT, PUBLISHED, HIDDEN. Перевод между
| | состояниями — отдельный UseCase. |

docs/spec/04-lifecycle.md — State Machine

Скилл сам построил цикл из бизнес-описания:

DRAFT --PublishProduct--> PUBLISHED
PUBLISHED --HideProduct--> HIDDEN
HIDDEN --PublishProduct--> PUBLISHED

docs/spec/06-rules.md — Бизнес-правила

Шесть правил, выведенных из брифа автоматически:

BR-C1  Цена обязательна, > 0. Валидация в record-constructor CreateProductUseCase.
BR-C2  Валюта — на старте только RUB.
BR-C3  Уникальность ID — UUID на сервере, никогда от клиента.
BR-C4  Только владелец публикует/скрывает. seller_id из JWT vs product.seller_id.
BR-C5  Допустимые переходы: Publish из DRAFT|HIDDEN, Hide из PUBLISHED.
BR-C6  GET /products/{id} отдаёт только PUBLISHED. На DRAFT|HIDDEN — 404.

docs/spec/07-commands.md — UseCase'ы

| UseCase | Кто | Из какого статуса | Что делает |
|---|---|---|---|
| CreateProductUseCase | seller | — | создаёт DRAFT |
| PublishProductUseCase | seller | DRAFT | HIDDEN | переводит в PUBLISHED |
| HideProductUseCase | seller | PUBLISHED | переводит в HIDDEN |

docs/spec/15-acceptance.md — Acceptance criteria

Девять AC-правил, каждое — конкретное проверяемое условие:

AC-C1  POST /products от seller'а создаёт продукт DRAFT с автогенерённым UUID.
AC-C2  POST /products/{id}/publish переводит DRAFT|HIDDEN → PUBLISHED.
AC-C3  POST /products/{id}/hide переводит PUBLISHED → HIDDEN.
AC-C4  Чужой seller получает 404 (OWN_PRODUCT_REQUIRED).
... (и т.д.)

В консолидированном catalog-service.md всё то же, объединённое в один читаемый markdown — для шаринга с бизнесом и для ингеста в другие AI-сессии.

Что важно: скилл сам определил Tier (B), сам пропустил §8 events и §12 sagas (для Tier B это лишнее), сам вывел state machine из бизнес-описания. Ничего из этого мы вручную не указывали.

Шаг 2. План реализации из спеки

Теперь нужен оркестратор — план шагов реализации. Подключается superpowers:

/superpowers:writing-plans

Скилл читает docs/spec/ и производит план:

Plan для Catalog Service (Tier B)

Phase 1: Bootstrap
  1.1 Скаффолд Gradle multi-module (никаких — одно-модульный для Tier B)
  1.2 application.yml + application-local.yml + application-integration-test.yml
  1.3 Liquibase changeset v-1.0/initial-schema.yaml — таблица products
  1.4 jOOQ codegen из applied schema → ru.vikulinva.catalog.generated
  1.5 SecurityConfig (3 профиля) с OAuth2 Resource Server
  1.6 ServiceBeansConfig — DateTimeService, UuidGenerator под @ConditionalOnMissingBean

Phase 2: UseCase'ы
  2.1 CreateProductUseCase + Handler + контроллер + тесты
  2.2 PublishProductUseCase + Handler + контроллер + тесты
  2.3 HideProductUseCase + Handler + контроллер + тесты

Phase 3: Queries
  3.1 GetProductQuery + Handler + контроллер + тесты
  3.2 ListMyProductsQuery + Handler + контроллер + тесты

Phase 4: Cross-cutting
  4.1 ProblemDetails handler — RFC 9457 ошибки
  4.2 ArchUnit тесты на инварианты слоёв
  4.3 Финальный прогон скиллов методологии для review

Acceptance:
  каждый шаг → gradle compileJava + gradle test зелёные
  финал → все 9 AC из docs/spec/15-acceptance.md проверены тестом

План в контексте сессии. Дальше executing-plans будет идти по нему.

Шаг 3. Исполнение

/superpowers:executing-plans

Дальше скилл идёт по плану. На каждом шаге вызывает соответствующий ucp-*-design. Покажу выдержки трёх ключевых шагов.

Шаг 3.1. Bootstrap (Phase 1)

Скилл вызывает:

/ucp-bootstrap-design Catalog Service Tier B. Один модуль, Spring Boot 3.4,
PostgreSQL, jOOQ, Liquibase. OAuth2 Resource Server. Профили: prod / local /
integration-test. Generated-only persistence (BS-17/18) — никакого
JdbcTemplate/JPA, никаких handcrafted POJO для строк БД.

Что создаётся:

build.gradle.kts                 — Spring Boot 3.4 + jOOQ codegen + Liquibase
src/main/resources/
  application.yml                — production defaults
  application-local.yml          — postgres из docker-compose, security permitAll
  application-integration-test.yml — WireMock-stubs, listener auto-startup off
src/main/java/.../config/
  SecurityConfig.java            — @Profile("!local & !integration-test"), OAuth2
  LocalSecurityConfig.java       — @Profile("local"), permitAll
  TestJwtConfiguration.java      — @Profile("integration-test"), permitAll
  ServiceBeansConfig.java        — Clock, DateTimeService, UuidGenerator
migrations/db/
  changelog-master.yaml
  changelog/v-1.0/initial-schema.yaml — CREATE TABLE products
docker-compose.yml               — postgres:16-alpine

После запуска ./gradlew update && ./gradlew generateJooq появляются generated POJO в build/generated/jooq/main/ru/vikulinva/catalog/generated/. Все правила BS-17/18 (jOOQ-only, generated-only) соблюдены.

Шаг 3.2. CreateProductUseCase (Phase 2.1)

Скилл вызывает:

/ucp-pattern-design Команда «продавец создаёт DRAFT-карточку товара».
Вход: SellerId, title, description, price (BigDecimal, > 0), currency.
Валидация в record-constructor (BR-C1, BR-C2). Возвращает Product
(generated POJO от jOOQ).

Что создаётся:

// 1. UseCase — record с валидацией в compact-constructor
public record CreateProductUseCase(
    SellerId sellerId,
    String title,
    String description,
    BigDecimal price,
    String currency
) implements UseCaseCommand<Product> {
    public CreateProductUseCase {
        Objects.requireNonNull(sellerId, "sellerId");
        if (title == null || title.isBlank())
            throw new IllegalArgumentException("title required");
        if (price == null || price.signum() <= 0)
            throw new IllegalArgumentException("price > 0 required");
        if (!"RUB".equals(currency))
            throw new IllegalArgumentException("only RUB supported");
    }
}

// 2. Handler — Spring-bean с @Transactional
@Component
@RequiredArgsConstructor
class CreateProductHandler implements UseCaseHandler<CreateProductUseCase, Product> {

    private final ProductRepository productRepository;
    private final UuidGenerator uuidGenerator;
    private final DateTimeService dateTimeService;

    @Override
    @Transactional
    public Product handle(CreateProductUseCase uc) {
        var product = new Product()
            .setId(uuidGenerator.generate())
            .setSellerId(uc.sellerId().value())
            .setTitle(uc.title())
            .setDescription(uc.description())
            .setPrice(uc.price())
            .setCurrency(uc.currency())
            .setStatus(ProductStatus.DRAFT)
            .setCreatedAt(dateTimeService.now())
            .setUpdatedAt(dateTimeService.now());
        return productRepository.save(product);
    }

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

// 3. Controller — реализует ProductsApi (OpenAPI-generated)
@RestController
@RequiredArgsConstructor
class ProductsController implements ProductsApi {

    private final UseCaseDispatcher dispatcher;
    private final ProductMapper mapper;
    private final AuthenticatedSeller authenticated;

    @Override
    @PreAuthorize("hasRole('seller')")
    public ResponseEntity<ProductDto> createProduct(CreateProductRequest request) {
        var useCase = new CreateProductUseCase(
            authenticated.sellerId(),
            request.getTitle(),
            request.getDescription(),
            request.getPrice(),
            request.getCurrency()
        );
        var product = dispatcher.dispatch(useCase);
        return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toDto(product));
    }
}

Плюс тесты:

@SpringBootTest
@Transactional
class CreateProductHandlerIntegrationTest extends BaseIntegrationTest {

    @Autowired UseCaseDispatcher dispatcher;
    @MockitoBean UuidGenerator uuidGenerator;
    @MockitoBean DateTimeService dateTimeService;

    @Test
    void shouldCreateProductWithDraftStatus() {
        when(uuidGenerator.generate()).thenReturn(UUID.fromString("..."));
        when(dateTimeService.now()).thenReturn(Instant.parse("2026-04-29T10:00:00Z"));

        var product = dispatcher.dispatch(new CreateProductUseCase(
            new SellerId(UUID.fromString("...")),
            "iPhone 15 Pro 256GB",
            "Состояние новое",
            new BigDecimal("89990.00"),
            "RUB"
        ));

        assertThat(product.getStatus()).isEqualTo(ProductStatus.DRAFT);
        assertThat(product.getTitle()).isEqualTo("iPhone 15 Pro 256GB");
        // ... и т.д.
    }

    @Test
    void shouldRejectNonPositivePrice() {
        assertThatThrownBy(() ->
            new CreateProductUseCase(
                new SellerId(UUID.randomUUID()),
                "Test",
                "",
                BigDecimal.ZERO,
                "RUB"
            )
        ).isInstanceOf(IllegalArgumentException.class)
         .hasMessageContaining("price > 0");
    }
    // ... ещё 5 тестов на каждое BR-C1..6
}

ProductRepository — generated jOOQ POJO Product + repository-обёртка с DSL на ProductsTable. Никакого JPA-Entity.

Шаг 3.3. PublishProductUseCase (Phase 2.2)

Здесь интереснее — нужна проверка ABAC и переход статуса. Скилл вызывает:

/ucp-pattern-design Команда «продавец публикует свой товар».
Вход: SellerId requesterId, ProductId productId.
Логика: SELECT по id, проверка product.sellerId == requesterId (BR-C4),
проверка статус ∈ {DRAFT, HIDDEN} (BR-C5), UPDATE статус на PUBLISHED.
Если не владелец → OwnProductRequiredException (404).
Если статус неподходящий → InvalidStateTransitionException (409).

Handler на этот раз содержательнее:

@Override
@Transactional
public Product handle(PublishProductUseCase uc) {
    var product = productRepository.findById(uc.productId())
        .orElseThrow(() -> new ProductNotFoundException(uc.productId()));

    if (!product.getSellerId().equals(uc.requesterId().value())) {
        throw new OwnProductRequiredException();   // BR-C4
    }
    if (product.getStatus() != ProductStatus.DRAFT
            && product.getStatus() != ProductStatus.HIDDEN) {
        throw new InvalidStateTransitionException(
            "Publish allowed only from DRAFT or HIDDEN, current: " + product.getStatus());
    }

    product.setStatus(ProductStatus.PUBLISHED)
           .setUpdatedAt(dateTimeService.now());
    return productRepository.save(product);
}

Это и есть реальная причина для @Transactional — SELECT, проверки, UPDATE атомарно. Между ними не должно происходить ничего другого с этим продуктом.

Шаги 3.4–3.5. HideProduct и Queries

По тому же шаблону. Каждый занимает ~1 минуту работы скилла + ~2 минуты на чтение и применение. Опускаю выдержки — структурно не отличаются от 3.2 и 3.3.

Шаг 3.6. API + ProblemDetails

/ucp-api-design Catalog API. Эндпоинты POST /products, /publish, /hide,
GET /{id}, GET /my. ProblemDetails по RFC 9457: PRODUCT_NOT_FOUND (404),
OWN_PRODUCT_REQUIRED (404), INVALID_STATE_TRANSITION (409),
INVALID_PRICE (400), INVALID_CURRENCY (400). Currency только RUB.

Скилл создаёт openapi/catalog-api.yaml с полными контрактами и @ControllerAdvice ProblemDetailsHandler, который ловит все доменные исключения и формирует RFC 9457 ProblemDetails:

# выдержка из openapi/catalog-api.yaml
paths:
  /products/{id}/publish:
    post:
      operationId: publishProduct
      tags: [Products]
      parameters:
        - in: path
          name: id
          schema: { type: string, format: uuid }
          required: true
      responses:
        '200':
          description: Product published
          content: { application/json: { schema: { $ref: '#/components/schemas/Product' } } }
        '404':
          description: Product not found or doesn't belong to seller
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '409':
          description: Invalid state transition
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

Из этого OpenAPI-yaml через openapi-generator-gradle-plugin генерируется интерфейс ProductsApi, который реализует наш ProductsController (мы это уже видели в шаге 3.2).

После каждого шага

/superpowers:verification-before-completion:

✓ ./gradlew compileJava — passed
✓ ./gradlew test — 23 tests, all green
✓ ./gradlew check — все проверки прошли

Не зелёное — не двигаемся дальше. Соблазн «потом починим» — обнуляется.

Шаг 4. Финальный разбор

Вызываем все скиллы методологии последовательно по diff-у:

/ucp-pattern-review

Скилл проходит по созданным файлам, цитирует правила:

✓ Контроллер не содержит бизнес-логики — соответствует R-3
✓ Handler с @Transactional — соответствует R-7
✓ UseCase — immutable record с валидацией в compact-ctor — соответствует R-1
⚠ В CreateProductHandler можно вынести setUpdatedAt в общий хелпер,
  если он повторится в других UseCase'ах. Не блокер — suggestion.
/ucp-api-review
✓ kebab-case в URL
✓ camelCase в JSON
✓ ProblemDetails RFC 9457 corrects
✓ OpenAPI metadata (operationId, tags) присутствуют
/ucp-java-style-review
✓ Imports без wildcard
✓ Имена переменных без аббревиатур
✓ Длинных лямбд не найдено
✓ Все строки ≤ 120 символов

И финал — внешний взгляд:

/superpowers:requesting-code-review

Subagent-ревьюер находит более тонкие вещи:

В PublishProductHandler — порядок проверок: сначала ownership, потом status.
Это правильный порядок (не светим существование чужих продуктов через
status-error, отсюда BR-C4 первым). Хорошо.

Но в HideProductHandler порядок обратный — сначала status, потом ownership.
Это рассогласование. Унифицировать.

Реальная находка человека-эксперта, которую linter и unit-тест не поймали бы.

Что получилось

Чек-лист соответствия спецификации:

AC из спекиПокрыто в коде
AC-C1 (POST /products → DRAFT)
AC-C2 (publish → PUBLISHED)
AC-C3 (hide → HIDDEN)
AC-C4 (чужой seller → 404)
AC-C5 (invalid transition → 409)
AC-C6 (price ≤ 0 → 400)
AC-C7 (GET only PUBLISHED → 404 для DRAFT/HIDDEN)
AC-C8 (GET /products/my пагинация)
AC-C9 (Order Service smoke-test)

Все 9 acceptance criteria из docs/spec/15-acceptance.md имеют интеграционный тест. Ни одно правило не «забылось».

Соответствие BR-C1..C6 — то же самое. Каждое бизнес-правило явно проверяется тестом с @DisplayName("BR-C5: …") для трассируемости.

Сколько времени заняло

Реалистичная оценка для этого сценария:

ЭтапВремя
Шаг 1 (спека)3-5 минут на работу скилла + 5-10 минут на чтение
Шаг 2 (план)2 минуты
Шаг 3 (исполнение)~2 часа: bootstrap + 5 UseCase/Query + API + auth + tests, по 15-20 минут на шаг
Шаг 4 (разбор)20-30 минут на финальный разбор + правки
Итого~3 часа от бизнес-описания до готового сервиса с тестами

Для сравнения: вручную писать тот же сервис с нуля, без AI и методологии, занимает у опытного Java-разработчика 2-3 рабочих дня. С AI без методологии — 1 день, но без согласованности с другими сервисами и без покрытия acceptance criteria. С методологией и AI вместе — 3 часа.

Это и есть множитель производительности. Не «AI стал писать в 100 раз быстрее», а «методология + AI закрывают 8-часовой день в 3-часовой блок с гарантированным соответствием спецификации».

Что не делалось в этом walkthrough

Для честности — что осталось за кадром:

  • Деплой в прод — этот walkthrough кончается на «зелёные тесты + готовый код в репо». До прода ещё CI/CD pipeline, infrastructure, observability. Это отдельная история и AI её сильно не ускоряет.
  • Брейншторм требований — у нас был готовый бизнес-бриф. На реальном проекте начинается с /superpowers:brainstorming или сессии Event Storming, и выход того брейнсторма уже идёт в /ucp-spec-design.
  • Интеграционные edge cases — если бизнес добавил «продукт может быть в нескольких категориях», это уже Tier B+ или C. Walkthrough на Catalog в Tier C выглядит иначе и потяжелее.

Что дальше

Обсудить применение методологии в команде →