Определение бизнес-ориентированных команд вместо обобщённого 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(())
}
Лучшие практики
- Доменный язык — Используйте бизнес-термины:
RegisterUserвместоCreateUser - Единая ответственность — Одна команда = одна бизнес-операция
- Явное намерение — Имена команд должны описывать действие
- Валидация в обработчиках — Логика валидации в обработчиках команд
- Идемпотентность — Проектируйте команды для безопасного повторного выполнения
- Используйте контекст — Передавайте метаданные запроса (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?;
См. также
- [[Хуки|Хуки]] — Хуки жизненного цикла включая хуки команд
- [[События|События]] — Генерация событий для журнала аудита
- [[Атрибуты|Атрибуты]] — Полный справочник атрибутов