Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Определение бизнес-ориентированных команд вместо обобщённого CRUD. Команды привносят доменный язык в API и реализуют паттерн Command Query Responsibility Segregation (CQRS).

Быстрый старт

#[derive(Entity)]
#[entity(table = "users", commands)]
#[command(Register)]
#[command(UpdateEmail: email)]
#[command(Deactivate, requires_id)]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub email: String,

    #[field(create, response)]
    pub name: String,

    #[field(response)]
    pub active: bool,
}

Генерируемый код

Структуры команд

/// Payload команды Register для User.
#[derive(Debug, Clone)]
pub struct RegisterUser {
    pub email: String,
    pub name: String,
}

/// Payload команды UpdateEmail для User.
#[derive(Debug, Clone)]
pub struct UpdateEmailUser {
    pub id: Uuid,
    pub email: String,
}

/// Payload команды Deactivate для User.
#[derive(Debug, Clone)]
pub struct DeactivateUser {
    pub id: Uuid,
}

Enum команд

/// Enum команд для сущности User.
#[derive(Debug, Clone)]
pub enum UserCommand {
    Register(RegisterUser),
    UpdateEmail(UpdateEmailUser),
    Deactivate(DeactivateUser),
}

impl EntityCommand for UserCommand {
    fn kind(&self) -> CommandKind {
        match self {
            UserCommand::Register(_) => CommandKind::Create,
            UserCommand::UpdateEmail(_) => CommandKind::Update,
            UserCommand::Deactivate(_) => CommandKind::Custom,
        }
    }

    fn name(&self) -> &'static str {
        match self {
            UserCommand::Register(_) => "Register",
            UserCommand::UpdateEmail(_) => "UpdateEmail",
            UserCommand::Deactivate(_) => "Deactivate",
        }
    }
}

Enum результатов

/// Enum результатов выполнения команд User.
#[derive(Debug, Clone)]
pub enum UserCommandResult {
    Register(User),
    UpdateEmail(User),
    Deactivate,
}

Трейт обработчика

/// Асинхронный трейт для обработки команд User.
#[async_trait]
pub trait UserCommandHandler: Send + Sync {
    type Error: std::error::Error + Send + Sync;
    type Context: Send + Sync;

    /// Диспетчеризация команды к соответствующему обработчику.
    async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
        -> Result<UserCommandResult, Self::Error>;

    /// Обработка команды Register.
    async fn handle_register(&self, cmd: RegisterUser, ctx: &Self::Context)
        -> Result<User, Self::Error>;

    /// Обработка команды UpdateEmail.
    async fn handle_update_email(&self, cmd: UpdateEmailUser, ctx: &Self::Context)
        -> Result<User, Self::Error>;

    /// Обработка команды Deactivate.
    async fn handle_deactivate(&self, cmd: DeactivateUser, ctx: &Self::Context)
        -> Result<(), Self::Error>;
}

Справочник синтаксиса команд

Базовая команда

Использует все поля #[field(create)]:

#[command(Register)]
// Генерируется: RegisterUser { email, name }

Конкретные поля

Использует только указанные поля (автоматически добавляет requires_id):

#[command(UpdateEmail: email)]
// Генерируется: UpdateEmailUser { id, email }

#[command(UpdateProfile: name, bio, avatar)]
// Генерируется: UpdateProfileUser { id, name, bio, avatar }

Команда только с ID

Добавляет только поле ID:

#[command(Deactivate, requires_id)]
// Генерируется: DeactivateUser { id }

#[command(Delete, requires_id, kind = "delete")]
// Генерируется: DeleteUser { id }, возвращает ()

Пользовательский payload

Использует внешнюю структуру:

pub struct TransferPayload {
    pub from_account: Uuid,
    pub to_account: Uuid,
    pub amount: i64,
}

#[command(Transfer, payload = "TransferPayload")]
// Использует TransferPayload напрямую

Пользовательский результат

Использует пользовательский тип результата:

pub struct TransferResult {
    pub transaction_id: Uuid,
    pub success: bool,
}

#[command(Transfer, payload = "TransferPayload", result = "TransferResult")]
// Возвращает TransferResult вместо сущности

Опции источника

Управление используемыми полями:

#[command(Create, source = "create")]     // Использует поля #[field(create)] (по умолчанию)
#[command(Modify, source = "update")]     // Использует поля #[field(update)] (опциональные)
#[command(Ping, source = "none")]         // Без полей payload

Подсказки типа

Влияют на определение типа результата:

#[command(Create, kind = "create")]   // Возвращает сущность (по умолчанию)
#[command(Update, kind = "update")]   // Возвращает сущность
#[command(Remove, kind = "delete")]   // Возвращает ()
#[command(Process, kind = "custom")]  // Определяется из source

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

use async_trait::async_trait;

struct UserHandler {
    pool: PgPool,
    email_service: EmailService,
}

struct RequestContext {
    user_id: Option<Uuid>,
    correlation_id: Uuid,
}

#[async_trait]
impl UserCommandHandler for UserHandler {
    type Error = AppError;
    type Context = RequestContext;

    async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
        -> Result<UserCommandResult, Self::Error>
    {
        match cmd {
            UserCommand::Register(c) => {
                let user = self.handle_register(c, ctx).await?;
                Ok(UserCommandResult::Register(user))
            }
            UserCommand::UpdateEmail(c) => {
                let user = self.handle_update_email(c, ctx).await?;
                Ok(UserCommandResult::UpdateEmail(user))
            }
            UserCommand::Deactivate(c) => {
                self.handle_deactivate(c, ctx).await?;
                Ok(UserCommandResult::Deactivate)
            }
        }
    }

    async fn handle_register(&self, cmd: RegisterUser, ctx: &Self::Context)
        -> Result<User, Self::Error>
    {
        // Валидация
        if cmd.email.is_empty() {
            return Err(AppError::Validation("Email обязателен".into()));
        }

        // Создание пользователя
        let user = User {
            id: Uuid::now_v7(),
            email: cmd.email.to_lowercase(),
            name: cmd.name,
            active: true,
        };

        // Сохранение
        sqlx::query(
            "INSERT INTO users (id, email, name, active) VALUES ($1, $2, $3, $4)"
        )
        .bind(user.id)
        .bind(&user.email)
        .bind(&user.name)
        .bind(user.active)
        .execute(&self.pool)
        .await?;

        // Побочные эффекты
        self.email_service.send_welcome(&user.email).await?;

        Ok(user)
    }

    async fn handle_update_email(&self, cmd: UpdateEmailUser, ctx: &Self::Context)
        -> Result<User, Self::Error>
    {
        // Проверка авторизации
        if ctx.user_id != Some(cmd.id) {
            return Err(AppError::Forbidden("Нельзя обновить чужой email".into()));
        }

        // Обновление
        let user: User = sqlx::query_as(
            "UPDATE users SET email = $1 WHERE id = $2 RETURNING *"
        )
        .bind(&cmd.email.to_lowercase())
        .bind(cmd.id)
        .fetch_one(&self.pool)
        .await?;

        // Отправка верификации
        self.email_service.send_verification(&user.email).await?;

        Ok(user)
    }

    async fn handle_deactivate(&self, cmd: DeactivateUser, ctx: &Self::Context)
        -> Result<(), Self::Error>
    {
        sqlx::query("UPDATE users SET active = false WHERE id = $1")
            .bind(cmd.id)
            .execute(&self.pool)
            .await?;

        Ok(())
    }
}

Использование команд

async fn register_user(
    handler: &impl UserCommandHandler,
    email: String,
    name: String,
) -> Result<User, AppError> {
    let cmd = RegisterUser { email, name };
    let ctx = RequestContext {
        user_id: None,
        correlation_id: Uuid::new_v4(),
    };

    match handler.handle(UserCommand::Register(cmd), &ctx).await? {
        UserCommandResult::Register(user) => Ok(user),
        _ => unreachable!(),
    }
}

// Или вызов конкретного обработчика напрямую
async fn update_email(
    handler: &impl UserCommandHandler,
    user_id: Uuid,
    new_email: String,
    ctx: &RequestContext,
) -> Result<User, AppError> {
    let cmd = UpdateEmailUser {
        id: user_id,
        email: new_email,
    };

    handler.handle_update_email(cmd, ctx).await
}

Трейт EntityCommand

Все enum команд реализуют трейт EntityCommand:

use entity_derive::{EntityCommand, CommandKind};

let cmd = UserCommand::Register(register_data);

// Получение метаданных команды
assert_eq!(cmd.name(), "Register");
assert!(matches!(cmd.kind(), CommandKind::Create));

// Сопоставление с образцом
match cmd.kind() {
    CommandKind::Create => println!("Создание сущности"),
    CommandKind::Update => println!("Обновление сущности"),
    CommandKind::Delete => println!("Удаление сущности"),
    CommandKind::Custom => println!("Пользовательская операция"),
}

Хуки команд

При одновременном включении commands и hooks:

#[derive(Entity)]
#[entity(table = "orders", commands, hooks)]
#[command(Place)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }

Генерируемые хуки:

#[async_trait]
pub trait OrderHooks: Send + Sync {
    type Error: std::error::Error + Send + Sync;

    // Стандартные CRUD-хуки...

    // Хуки команд
    async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
    async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}

Использование:

async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error> {
    // Логирование команды
    tracing::info!(command = cmd.name(), "Обработка команды");

    // Авторизация
    match cmd {
        OrderCommand::Cancel(c) => {
            let order = self.find_order(c.id).await?;
            if order.status == "shipped" {
                return Err(AppError::Forbidden("Нельзя отменить отправленный заказ".into()));
            }
        }
        _ => {}
    }

    Ok(())
}

async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error> {
    // Журнал аудита
    match (cmd, result) {
        (OrderCommand::Place(c), OrderCommandResult::Place(order)) => {
            self.audit_log("order_placed", order.id).await?;
        }
        (OrderCommand::Cancel(c), OrderCommandResult::Cancel) => {
            self.audit_log("order_cancelled", c.id).await?;
        }
    }

    Ok(())
}

Лучшие практики

  1. Доменный язык — Используйте бизнес-термины: RegisterUser вместо CreateUser
  2. Единая ответственность — Одна команда = одна бизнес-операция
  3. Явное намерение — Имена команд должны описывать действие
  4. Валидация в обработчиках — Логика валидации в обработчиках команд
  5. Идемпотентность — Проектируйте команды для безопасного повторного выполнения
  6. Используйте контекст — Передавайте метаданные запроса (user, correlation ID) через контекст

Паттерн CQRS

Команды — одна половина CQRS. Комбинируйте с проекциями для стороны запросов:

#[derive(Entity)]
#[entity(table = "orders", commands)]
#[projection(Summary: id, status, total_cents, created_at)]
#[projection(Details: id, status, items, shipping_address, total_cents)]
#[command(Place)]
#[command(Ship, requires_id)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }

// Команды (сторона записи)
let result = handler.handle(OrderCommand::Place(place_order), &ctx).await?;

// Запросы (сторона чтения)
let summary = repo.find_by_id_summary(order_id).await?;
let details = repo.find_by_id_details(order_id).await?;

См. также

  • [[Хуки|Хуки]] — Хуки жизненного цикла включая хуки команд
  • [[События|События]] — Генерация событий для журнала аудита
  • [[Атрибуты|Атрибуты]] — Полный справочник атрибутов