-
-
Notifications
You must be signed in to change notification settings - Fork 0
Команды
Определение бизнес-ориентированных команд вместо обобщённого 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 команд для сущности 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 результатов выполнения команд 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 }
Команда с sets(...) пишет названные колонки напрямую, и им не нужно быть #[field(update)] — то есть они не попадают ни в публичный patch-DTO, ни в SET-список апсерта:
#[command(VerifyPassport, payload(passport_provider), sets( passport_verified = "true", passport_verified_at = "NOW()" ))] // Генерируется: VerifyPassportUser { id, passport_provider } // pool.verify_passport(command) -> User
Один UPDATE пишет фиксированные выражения плюс колонки из payload и ничего больше. Выражения попадают в запрос дословно, как в #[column(default = "...")]; имена колонок проверяются по сущности на компиляции.
С включёнными transactions та же операция доступна на адаптере — она попадает в общую транзакцию с остальными записями; отсутствующая строка там даёт Ok(None), как и у прочих методов адаптера.
let mut tx = pool.begin().await?; let verified = UserTransactionRepo::new(&mut tx) .verify_passport(VerifyPassportUser { id, passport_provider: Some("gov".into()) }) .await?; tx.commit().await?;
Добавляет только поле ID:
#[command(Deactivate, requires_id)] // Генерируется: DeactivateUser { id } #[command(Delete, requires_id, kind = "delete")] // Генерируется: DeleteUser { id }, возвращает ()
Использует внешнюю структуру:
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 }
Все 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. Комбинируйте с проекциями для стороны запросов:
#[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?;
🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어 | 🇪🇸 Español | 🇨🇳 中文
Getting Started
Features
Advanced
Начало работы
Возможности
Продвинутое
시작하기
기능
고급
Comenzando
Características
Avanzado
入门
功能
高级