Command и Query: структура запросов и команд
Command и Query: структура запросов и команд
Command - меняет состояние. Query - не меняет. Это дисциплина: когда ты разделяешь их, код становится прозрачнее, тестируемее и предсказуемее.
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].
Анатомия 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);
}
}
Валидация команд
Валидация разделяется на два уровня:
Уровень Где Примеры
───────────── ────────────── ───────────────────────
Структурная До хендлера Поля не пустые, 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 не делает запись в БД