Command и Query: структура запросов и команд

Command и Query: структура запросов и команд

Command - меняет состояние. Query - не меняет. Это дисциплина: когда ты разделяешь их, код становится прозрачнее, тестируемее и предсказуемее.

Command меняет состояние и возвращает error; Query возвращает DTO без побочных эффектов

CQS vs CQRS

CQS (Command-Query Separation) - принцип Бертрана Мейера на уровне методов:

// CQS: метод либо возвращает данные, либо меняет состояние
type UserService struct { repo UserRepo }

// Command - меняет состояние, ничего не возвращает (кроме ошибки)
func (s *UserService) Deactivate(ctx context.Context, id int64) error {
    return s.repo.SetActive(ctx, id, false)
}

// Query - возвращает данные, ничего не меняет
func (s *UserService) GetByID(ctx context.Context, id int64) (User, error) {
    return s.repo.FindByID(ctx, id)
}
<?php
declare(strict_types=1);

// CQS: метод либо возвращает данные, либо меняет состояние
final class UserService
{
    public function __construct(
        private readonly UserRepository $repo,
    ) {}

    // Command - меняет состояние, ничего не возвращает
    public function deactivate(int $id): void
    {
        $this->repo->setActive($id, false);
    }

    // Query - возвращает данные, ничего не меняет
    public function getById(int $id): User
    {
        return $this->repo->findById($id);
    }
}

CQRS поднимает CQS на архитектурный уровень - у команд и запросов разные модели, хендлеры, иногда разные хранилища.

Анатомия Command

Command - это DTO, описывающий намерение:

// Command = что нужно сделать (imperative naming)
type CompleteLesson struct {
    UserID   int64 `json:"user_id" validate:"required"`
    LessonID int64 `json:"lesson_id" validate:"required"`
}

// Handler - выполняет команду
type CompleteLessonHandler struct {
    lessons  LessonRepo
    progress ProgressRepo
    events   EventPublisher
}

func (h *CompleteLessonHandler) Handle(ctx context.Context, cmd CompleteLesson) error {
    // 1. Валидация бизнес-правил
    lesson, err := h.lessons.GetByID(ctx, cmd.LessonID)
    if err != nil {
        return fmt.Errorf("lesson not found: %w", err)
    }

    if !lesson.IsAvailableFor(cmd.UserID) {
        return ErrLessonNotAvailable
    }

    // 2. Изменение состояния
    if err := h.progress.MarkCompleted(ctx, cmd.UserID, cmd.LessonID); err != nil {
        return fmt.Errorf("mark completed: %w", err)
    }

    // 3. Публикация события (опционально)
    return h.events.Publish(ctx, LessonCompletedEvent{
        UserID:   cmd.UserID,
        LessonID: cmd.LessonID,
    })
}
<?php
declare(strict_types=1);

// Command = что нужно сделать (imperative naming)
final readonly class CompleteLesson
{
    public function __construct(
        public int $userId,
        public int $lessonId,
    ) {}
}

// Handler - выполняет команду
final class CompleteLessonHandler
{
    public function __construct(
        private readonly LessonRepository $lessons,
        private readonly ProgressRepository $progress,
        private readonly EventPublisher $events,
    ) {}

    public function __invoke(CompleteLesson $cmd): void
    {
        // 1. Валидация бизнес-правил
        $lesson = $this->lessons->getById($cmd->lessonId)
            ?? throw new LessonNotFoundException($cmd->lessonId);

        if (!$lesson->isAvailableFor($cmd->userId)) {
            throw new LessonNotAvailableException();
        }

        // 2. Изменение состояния
        $this->progress->markCompleted($cmd->userId, $cmd->lessonId);

        // 3. Публикация события (опционально)
        $this->events->publish(new LessonCompletedEvent(
            userId: $cmd->userId,
            lessonId: $cmd->lessonId,
        ));
    }
}

В Symfony такую команду диспатчат через MessageBusInterface::dispatch($cmd); обработчик помечается атрибутом #[AsMessageHandler].

Command возвращает только `error`. Если после записи нужно показать результат - делай отдельный Query. Исключение: можно вернуть ID созданной сущности, но не более.

Анатомия Query

Query - запрос данных без побочных эффектов:

// Query = что нужно прочитать
type GetTrackLessons struct {
    TrackID int64 `json:"track_id" validate:"required"`
    UserID  int64 `json:"user_id" validate:"required"`
}

// Результат - DTO под конкретный UI
type TrackLessonItem struct {
    ID          int64  `json:"id"`
    Title       string `json:"title"`
    Order       int    `json:"order"`
    Completed   bool   `json:"completed"`
    DurationMin int    `json:"duration_min"`
}

type GetTrackLessonsHandler struct {
    readRepo LessonReadRepo // отдельный репозиторий для чтения
}

func (h *GetTrackLessonsHandler) Handle(ctx context.Context, q GetTrackLessons) ([]TrackLessonItem, error) {
    return h.readRepo.ListByTrack(ctx, q.TrackID, q.UserID)
}
<?php
declare(strict_types=1);

// Query = что нужно прочитать
final readonly class GetTrackLessons
{
    public function __construct(
        public int $trackId,
        public int $userId,
    ) {}
}

// Результат - DTO под конкретный UI
final readonly class TrackLessonItem
{
    public function __construct(
        public int $id,
        public string $title,
        public int $order,
        public bool $completed,
        public int $durationMin,
    ) {}
}

final class GetTrackLessonsHandler
{
    public function __construct(
        // отдельный репозиторий для чтения
        private readonly LessonReadRepository $readRepo,
    ) {}

    /** @return TrackLessonItem[] */
    public function __invoke(GetTrackLessons $q): array
    {
        return $this->readRepo->listByTrack($q->trackId, $q->userId);
    }
}
Если в Query внезапно обновляется база - ты теряешь контроль и ловишь баги уровня «почему список меняет данные?». Даже «безобидные» побочные эффекты вроде записи в кэш при чтении нарушают принцип.

Валидация команд

Валидация разделяется на два уровня:

Уровень              Где                     Примеры
─────────────        ──────────────          ───────────────────────
Структурная          До хендлера             Поля не пустые, email валиден,
                     (middleware/validator)   числа в диапазоне

Бизнес-правила       В хендлере              Урок доступен пользователю,
                                             лимит не превышен,
                                             предыдущий шаг пройден
// Структурная валидация - можно вынести в middleware
func validateStruct(cmd any) error {
    return validator.New().Struct(cmd)
}

// Бизнес-валидация - только в хендлере, там есть контекст
func (h *CompleteLessonHandler) Handle(ctx context.Context, cmd CompleteLesson) error {
    // Бизнес-правило: предыдущие уроки должны быть пройдены
    prev, err := h.lessons.GetPrevious(ctx, cmd.LessonID)
    if err != nil {
        return err
    }
    if prev != nil && !h.progress.IsCompleted(ctx, cmd.UserID, prev.ID) {
        return ErrPreviousLessonNotCompleted
    }
    // ...
}
<?php
declare(strict_types=1);

// Структурная валидация - атрибутами Symfony Validator на полях команды:
// final readonly class CompleteLesson {
//     public function __construct(
//         #[Assert\Positive] public int $userId,
//         #[Assert\Positive] public int $lessonId,
//     ) {}
// }

// Бизнес-валидация - только в хендлере, там есть контекст
final class CompleteLessonHandler
{
    public function __construct(
        private readonly LessonRepository $lessons,
        private readonly ProgressRepository $progress,
    ) {}

    public function __invoke(CompleteLesson $cmd): void
    {
        // Бизнес-правило: предыдущие уроки должны быть пройдены
        $prev = $this->lessons->getPrevious($cmd->lessonId);
        if ($prev !== null && !$this->progress->isCompleted($cmd->userId, $prev->id)) {
            throw new PreviousLessonNotCompletedException();
        }
        // ...
    }
}

Организация кода

Команды и запросы удобно группировать по фичам:

internal/
  lesson/
    command/
      complete_lesson.go      // Command + Handler
      start_lesson.go
    query/
      get_track_lessons.go    // Query + Handler
      get_lesson_detail.go
    domain/
      lesson.go               // доменная модель (Write)
      lesson_read.go           // DTO для чтения (Read)

Или проще, если проект небольшой:

internal/
  usecase/
    complete_lesson.go        // Command
    get_track_lessons.go      // Query

Command Bus и Query Bus

В крупных проектах команды и запросы маршрутизируются через шину (bus):

// Простейший Command Bus
type CommandBus struct {
    handlers map[reflect.Type]any
}

func (b *CommandBus) Register(cmd any, handler any) {
    b.handlers[reflect.TypeOf(cmd)] = handler
}

func (b *CommandBus) Dispatch(ctx context.Context, cmd any) error {
    handler, ok := b.handlers[reflect.TypeOf(cmd)]
    if !ok {
        return fmt.Errorf("no handler for %T", cmd)
    }
    // вызываем handler.Handle(ctx, cmd)
    // ...
}
<?php
declare(strict_types=1);

// Простейший Command Bus
final class CommandBus
{
    /** @var array<class-string, callable> */
    private array $handlers = [];

    public function register(string $commandClass, callable $handler): void
    {
        $this->handlers[$commandClass] = $handler;
    }

    public function dispatch(object $cmd): void
    {
        $class = $cmd::class;
        if (!isset($this->handlers[$class])) {
            throw new RuntimeException("no handler for $class");
        }
        ($this->handlers[$class])($cmd);
    }
}

В реальных Symfony-проектах руками такую шину не пишут - берут Symfony\Component\Messenger\MessageBusInterface с middleware-стеком (ValidationMiddleware, DoctrineTransactionMiddleware, LoggingMiddleware).

Для Go-проектов средного размера Bus часто избыточен - достаточно прямого вызова хендлера. Bus пригодится, когда нужны middleware (логирование, метрики, retry) для всех команд разом.

Тестирование

Разделение Command/Query упрощает тесты:

func TestCompleteLesson_Success(t *testing.T) {
    // Arrange: мокаем только зависимости команды
    lessonRepo := &mockLessonRepo{lesson: testLesson}
    progressRepo := &mockProgressRepo{}
    handler := NewCompleteLessonHandler(lessonRepo, progressRepo, &mockPublisher{})

    // Act
    err := handler.Handle(ctx, CompleteLesson{UserID: 1, LessonID: 42})

    // Assert
    require.NoError(t, err)
    assert.True(t, progressRepo.completedCalled)
}

func TestCompleteLesson_PreviousNotCompleted(t *testing.T) {
    lessonRepo := &mockLessonRepo{lesson: testLesson, previous: prevLesson}
    progressRepo := &mockProgressRepo{completed: false}
    handler := NewCompleteLessonHandler(lessonRepo, progressRepo, &mockPublisher{})

    err := handler.Handle(ctx, CompleteLesson{UserID: 1, LessonID: 42})

    assert.ErrorIs(t, err, ErrPreviousLessonNotCompleted)
}
<?php
declare(strict_types=1);

final class CompleteLessonHandlerTest extends TestCase
{
    public function testSuccess(): void
    {
        // Arrange: мокаем только зависимости команды
        $lessons = $this->createMock(LessonRepository::class);
        $lessons->method('getById')->willReturn($this->testLesson());
        $progress = $this->createMock(ProgressRepository::class);
        $progress->expects(self::once())->method('markCompleted')->with(1, 42);

        $handler = new CompleteLessonHandler($lessons, $progress, new InMemoryEventPublisher());

        // Act
        $handler(new CompleteLesson(userId: 1, lessonId: 42));
    }

    public function testPreviousNotCompleted(): void
    {
        $lessons = $this->createMock(LessonRepository::class);
        $lessons->method('getPrevious')->willReturn($this->prevLesson());
        $progress = $this->createMock(ProgressRepository::class);
        $progress->method('isCompleted')->willReturn(false);

        $handler = new CompleteLessonHandler($lessons, $progress, new InMemoryEventPublisher());

        $this->expectException(PreviousLessonNotCompletedException::class);
        $handler(new CompleteLesson(userId: 1, lessonId: 42));
    }
}

Query тестировать ещё проще - нет побочных эффектов, только вход и выход.

Мини-задание

  • Выдели один use-case из своего проекта как Command (с Handler)
  • Выдели один экран как Query (с отдельной Read-структурой)
  • Напиши тест на Command: успешный сценарий + один бизнес-кейс с ошибкой
  • Проверь: ни один Query не делает запись в БД

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.