Я всегда боялся сложных систем: как, черт возьми, их проектировать, создавать, поддерживать, читать в конце концов! Основной инструмент управления сложностью это декомпозиция, да только вот часто всё идёт не по учебникам.
Недавно столкнулся с такой проблемой:
логика между доменами сложнее самих доменов, а в книжках об этом не пишут.
Я строил систему по DDD, все красиво:
домены
агрегаты
use cases
события.
Потом пришёл сценарий: "Отменить заказ"
Я думал: "Ну, Order::cancel(), вызову inventory.release(), pricing.refund(), и готово"
Но, хмм...
Если доставка уже в пути — нужно создать возвратную накладную
Если платёж падал дважды — отменить всё, а при первой попытке — только заморозить баллы
Если товара нет — перенести резерв на другой склад, пересчитать доставку, спросить клиента, если дороже
Если клиент повторил платёж — восстановить резерв и доставку
И я понял:
Самая сложная логика — не в доменах, а между ними. В книжках по DDD, Clean Architecture, Hexagonal — об этом не пишут. Там учат:
"Use case должен быть тонким"
"Домен это центр ответственности"
"Между доменами только события или вызовы API c JSON (т.е. типы на границе теряются, значит бизнес правила границу не переходят)"
Если я пойду по книжкам и воткну этот функционал в обязанности Order, то тот станет "божественным объектом"
Зависеть от 4 доменов
Знать про статус доставки
Принимать решения по retry, hold, freeze
Нарушать SRP
А заодно придется добавлять anti corruption layer т.к. на вход может прийти что угодно, я лишаюсь гарантий компилятора, выраженных в типах
Но никто не говорит, что делать то?
Я ввёл то, чего не было в учебниках:
CrossDomainCoordinator
Принимает на себя зависимости и логику, которые лишние, чужеродные для бизнес доменов
Знает, что делать дальше
Вызывает домены параллельно
Управляет политиками: retry, hold, freeze
Публикует события для аудита
И то, что считается антипатерном
SharedKernel
Самая устойчивая логика для всех, как политика Компании
Вкратце:
if status == InTransit { self.delivery.cancel_with_fee(...).await?; self.delivery.create_return_label(...).await?; } let (inv, loy) = tokio::join!( self.inventory.cancel_reservation(...), self.loyalty.rollback_points(...), ); self.pricing.refund_payment(...).await?; self.event_bus.publish(...).await; Ok(()) }
src/
├── domain/
│ ├── pricing/
│ │ └── src/lib.rs
│ ├── inventory/
│ │ └── src/lib.rs
│ ├── delivery/
│ │ └── src/lib.rs
│ └── loyalty/
│ └── src/lib.rs
├── application/
│ ├── coordination/
│ │ ├── mod.rs
│ │ ├── handlers.rs
│ │ └── events.rs
│ └── use_cases/
│ ├── create_order.rs
│ ├── cancel_order.rs
│ ├── confirm_order_payment.rs
│ └── retry_payment.rs
├── infrastructure/
│ ├── web/
│ │ ├── handlers.rs
│ │ ├── routes.rs
│ │ └── state.rs
│ ├── adapters/
│ │ ├── mock_*.rs
│ │ └── in_memory_repository.rs
│ └── persistence/
│ ├── mod.rs
│ ├── order_repository.rs
│ └── schema.sql
└── shared/lib.rs
В коде видно, что:
домены простые, включают больше типов данных, чем поведения (логика заключена в типах), остались чистыми: inventory, delivery, loyalty — не знают друг о друге
координатор сложный и состоит почти полностью из инвариантов (правил) поведения, не имеет сущностей внутри, единственное место, где принимаются решения, зависящие от нескольких доменов
На самом деле, я схалявил и не написал для доменов логику, только заглушки. Ну, и так понятно что она простая.
Вывод
Если логика между доменами сложнее самих доменов - выдели её явно CrossDomainCoordinator - как домен, только без своих сущностей. Для такого поведенческого домена может быть отдельная команда разработки.

всего-то 800 строк учебного проекта
//! # Shared Kernel //! //! Минимальное, стабильное ядро. //! //! ## Правила: //! - Только фундаментальные типы //! - Никаких enum с бизнес-семантикой (Carrier, RefundReason и т.д.) //! - Никаких изменяемых политик //! //! ## Почему: //! Это shared kernel по DDD. Любое изменение здесь затрагивает всю систему. //! Поэтому он должен быть как "стандартная библиотека" — почти не меняется. use rust_decimal::Decimal; use uuid::Uuid; // ————————————— // Идентификаторы // ————————————— pub type CustomerId = Uuid; pub type ProductId = Uuid; pub type OrderId = Uuid; pub type WarehouseId = Uuid; pub type ReservationId = Uuid; pub type DeliveryId = Uuid; pub type RefundId = Uuid; pub type FreezeId = Uuid; // ————————————— // Денежные и количественные типы // ————————————— pub type Quantity = u32; pub type Weight = f32; #[derive(Debug, Clone, Copy, PartialEq)] pub struct Money(Decimal); #[derive(Debug, Error, Clone)] pub enum MoneyError { #[error("Сумма не может быть отрицательной: {0}")] Negative(f64), #[error("Не удалось преобразовать значение в Money")] Invalid, } impl Money { pub fn zero() -> Self { Self(Decimal::zero()) } pub fn as_decimal(&self) -> &Decimal { &self.0 } // Опционально: проверка инвариантов fn ensure_positive(self) -> Result<Self, MoneyError> { if self.0.is_sign_negative() { Err(MoneyError::Negative(self.0.to_f64().unwrap_or(0.0))) } else { Ok(self) } } } // ——————————————————— // TryFrom<f64> → Money // ——————————————————— impl TryFrom<f64> for Money { type Error = MoneyError; fn try_from(value: f64) -> Result<Self, Self::Error> { if value.is_nan() || value.is_infinite() { return Err(MoneyError::Invalid); } if value < 0.0 { return Err(MoneyError::Negative(value)); } let decimal = Decimal::try_from(value).map_err(|_| MoneyError::Invalid)?; Ok(Money(decimal)) } } // ——————————————————— // TryFrom<Decimal> → Money // ——————————————————— impl TryFrom<Decimal> for Money { type Error = MoneyError; fn try_from(value: Decimal) -> Result<Self, Self::Error> { if value.is_sign_negative() { return Err(MoneyError::Negative( value.to_f64().unwrap_or(std::f64::NAN), )); } Ok(Money(value)) } } // ——————————————————— // TryFrom<String> → Money // ——————————————————— impl TryFrom<String> for Money { type Error = MoneyError; fn try_from(value: String) -> Result<Self, Self::Error> { let decimal = value.parse::<Decimal>().map_err(|_| MoneyError::Invalid)?; Self::try_from(decimal) } } // ————————————— // Общие структуры // ————————————— #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Address { pub street: String, pub city: String, pub zip: String, pub country: String, } ``` --- ### `src/domain/pricing/src/lib.rs` ```rust //! # Pricing Domain //! //! Управляет расчётом цен, блокировкой и возвратом средств. //! //! ## Зависит только от `shared/` //! - `Money`, `OrderId`, `CustomerId` //! //! ## Не зависит от других доменов use crate::shared::{CustomerId, ProductId, OrderId, Money, Quantity}; use uuid::Uuid; #[derive(Debug, Clone)] pub struct PriceRequest { pub customer_id: CustomerId, pub items: Vec<(ProductId, Quantity)>, pub promo_code: Option<String>, pub loyalty_discount: Option<Money>, } #[derive(Debug, Clone, serde::Serialize)] pub struct PriceBreakdown { pub base: Money, pub discount: Money, pub tax: Money, pub final_price: Money, } #[derive(Debug, Clone)] pub struct RefundRequest { pub order_id: OrderId, pub amount: Money, pub reason: RefundReason, } #[derive(Debug, Clone, PartialEq)] pub enum RefundReason { CustomerCancelled, Fraudulent, SystemError, } pub type PaymentHoldId = Uuid; // ————————————— // Функции // ————————————— pub fn calculate_prices(request: PriceRequest) -> PriceBreakdown { // Упрощённый расчёт let base: Money = request.items.iter().map(|(_, q)| Money::from(*q)).sum(); let discount = request.loyalty_discount.unwrap_or(Money::zero()); let tax = base * rust_decimal_macros::dec!(0.1); let final_price = base - discount + tax; PriceBreakdown { base, discount, tax, final_price, } } pub fn refund_payment(request: RefundRequest) -> Result<RefundId, PricingError> { // Интеграция с платёжной системой Ok(Uuid::new_v4()) } pub fn hold_payment(customer_id: CustomerId, amount: Money) -> Result<PaymentHoldId, PricingError> { Ok(Uuid::new_v4()) } // ————————————— // Ошибки // ————————————— #[derive(Debug, thiserror::Error)] pub enum PricingError { #[error("Ошибка платежной системы")] PaymentSystemError, #[error("Сумма отрицательная")] NegativeAmount, } ``` --- ### `src/domain/inventory/src/lib.rs` ```rust //! # Inventory Domain //! //! Управляет резервированием, проверкой доступности и переносом товара. use crate::shared::{ProductId, Quantity, WarehouseId, ReservationId, Money, Address}; use uuid::Uuid; #[derive(Debug, Clone)] pub struct ReserveRequest { pub items: Vec<(ProductId, Quantity)>, pub warehouse_id: WarehouseId, pub priority: Priority, } #[derive(Debug, Clone, serde::Serialize)] pub struct ItemAvailability { pub product_id: ProductId, pub available: Quantity, pub warehouse_id: WarehouseId, } #[derive(Debug, Clone)] pub struct TransferRequest { pub reservation_id: ReservationId, pub from: WarehouseId, pub to: WarehouseId, } #[derive(Debug, Clone, PartialEq)] pub enum Priority { Low, Normal, High, } // ————————————— // Функции // ————————————— pub fn check_availability(items: Vec<(ProductId, Quantity)>) -> Vec<ItemAvailability> { items.into_iter() .map(|(id, _)| ItemAvailability { product_id: id, available: 10, // упрощение warehouse_id: Uuid::new_v4().into(), }) .collect() } pub fn reserve(request: ReserveRequest) -> Result<ReservationId, InventoryError> { Ok(Uuid::new_v4()) } pub fn cancel_reservation(reservation_id: ReservationId) -> Result<Vec<(ProductId, Quantity)>, InventoryError> { Ok(vec![(Uuid::new_v4(), 2)]) } pub fn transfer_reservation(request: TransferRequest) -> Result<(), InventoryError> { Ok(()) } pub fn confirm_reservation(reservation_id: ReservationId) -> Result<(), InventoryError> { Ok(()) } // ————————————— // Ошибки // ————————————— #[derive(Debug, thiserror::Error)] pub enum InventoryError { #[error("Товар не найден")] NotFound, #[error("Недостаточно на складе")] OutOfStock, } ``` --- ### `src/domain/delivery/src/lib.rs` ```rust //! # Delivery Domain //! //! Управляет доставкой, расчётом, отменой, возвратом. use crate::shared::{Address, Weight, Money, DeliveryId, ReservationId, OrderId, WarehouseId, CustomerId}; use uuid::Uuid; use time::OffsetDateTime; #[derive(Debug, Clone)] pub struct DeliveryRequest { pub address: Address, pub items: Vec<(ProductId, Weight)>, pub priority: Priority, } #[derive(Debug, Clone, serde::Serialize)] pub struct ShippingOptions { pub cost: Money, pub warehouse_id: WarehouseId, pub estimated_days: u32, pub carrier: Carrier, } #[derive(Debug, Clone, serde::Serialize)] pub struct CancellationFee { pub amount: Money, pub reason: String, } #[derive(Debug, Clone, serde::Serialize)] pub struct ReturnLabel { pub tracking_number: String, pub carrier: Carrier, pub expires_at: OffsetDateTime, } #[derive(Debug, Clone, PartialEq)] pub enum Carrier { DHL, FedEx, UPS, } #[derive(Debug, Clone, PartialEq)] pub enum Priority { Low, Normal, High, } #[derive(Debug, Clone, PartialEq)] pub enum DeliveryStatus { Pending, InTransit, Delivered, } // ————————————— // Функции // ————————————— pub fn calculate_shipping(request: DeliveryRequest) -> Result<ShippingOptions, DeliveryError> { Ok(ShippingOptions { cost: rust_decimal_macros::dec!(10.00), warehouse_id: Uuid::new_v4(), estimated_days: 3, carrier: Carrier::DHL, }) } pub fn schedule_delivery(order_id: OrderId, option: ShippingOptions) -> Result<DeliveryId, DeliveryError> { Ok(Uuid::new_v4()) } pub fn cancel_delivery(delivery_id: DeliveryId) -> Result<CancellationFee, DeliveryError> { Ok(CancellationFee { amount: rust_decimal_macros::dec!(5.00), reason: "Early cancellation fee".to_string(), }) } pub fn create_return_label(delivery_id: DeliveryId) -> Result<ReturnLabel, DeliveryError> { Ok(ReturnLabel { tracking_number: "RTN123456789".to_string(), carrier: Carrier::DHL, expires_at: OffsetDateTime::now_utc() + time::Duration::days(7), }) } pub fn get_status(delivery_id: DeliveryId) -> Result<DeliveryStatus, DeliveryError> { Ok(DeliveryStatus::Pending) } // ————————————— // Ошибки // ————————————— #[derive(Debug, thiserror::Error)] pub enum DeliveryError { #[error("Доставка не найдена")] NotFound, #[error("Нельзя отменить доставленный заказ")] CannotCancelDelivered, } ``` --- ### `src/domain/loyalty/src/lib.rs` ```rust //! # Loyalty Domain //! //! Управляет начислением, списанием, заморозкой баллов. use crate::shared::{CustomerId, Money}; use time::OffsetDateTime; use uuid::Uuid; #[derive(Debug, Clone)] pub struct PointsRequest { pub customer_id: CustomerId, pub order_total: Money, } #[derive(Debug, Clone, serde::Serialize)] pub struct LoyaltyPoints { pub amount: u32, pub tier_multiplier: f32, pub expires_at: OffsetDateTime, } #[derive(Debug, Clone)] pub struct PointsTransaction { pub customer_id: CustomerId, pub points: i32, pub reason: String, } // ————————————— // Функции // ————————————— pub fn calculate_points(request: PointsRequest) -> LoyaltyPoints { let amount = (request.order_total.to_f32().unwrap() * 0.01) as u32; LoyaltyPoints { amount, tier_multiplier: 1.0, expires_at: OffsetDateTime::now_utc() + time::Duration::days(365), } } pub fn apply_points(customer_id: CustomerId, points: u32) -> Result<Money, LoyaltyError> { Ok(Money::from(points / 10)) } pub fn rollback_points(transaction: PointsTransaction) -> Result<(), LoyaltyError> { Ok(()) } pub fn freeze_points(customer_id: CustomerId, points: u32, duration: std::time::Duration) -> Result<FreezeId, LoyaltyError> { Ok(Uuid::new_v4()) } pub fn unfreeze_points(freeze_id: FreezeId) -> Result<(), LoyaltyError> { Ok(()) } // ————————————— // Ошибки // ————————————— #[derive(Debug, thiserror::Error)] pub enum LoyaltyError { #[error("Баллы не найдены")] NotFound, #[error("Недостаточно баллов")] InsufficientPoints, } ``` --- ### `src/application/coordination/events.rs` ```rust //! # События //! //! Передаются в CrossDomainCoordinator. //! //! Все типы используют только shared:: и доменные типы. use crate::shared::*; #[derive(Debug, Clone)] pub struct OrderCancelledEvent { pub order_id: OrderId, pub customer_id: CustomerId, pub delivery_id: DeliveryId, pub reservation_id: ReservationId, pub points: u32, pub total: Money, pub delivery_status: DeliveryStatus, } #[derive(Debug, Clone)] pub struct PaymentFailedEvent { pub order_id: OrderId, pub customer_id: CustomerId, pub reservation_id: ReservationId, pub delivery_id: DeliveryId, pub points: u32, pub amount: Money, pub attempt_number: u8, pub failure_reason: PaymentError, } #[derive(Debug, Clone)] pub struct InventoryShortageEvent { pub order_id: OrderId, pub warehouse_id: WarehouseId, pub items: Vec<(ProductId, Quantity)>, pub reservation_id: ReservationId, pub address: Address, pub original_shipping_cost: Money, } #[derive(Debug, Clone)] pub struct PaymentRetryEvent { pub reservation_id: ReservationId, pub delivery_id: DeliveryId, pub customer_id: CustomerId, pub freeze_id: FreezeId, } #[derive(Debug, Clone)] pub struct OrderCancelledCompleteEvent { pub order_id: OrderId, pub refund_id: RefundId, pub returned_items: Vec<(ProductId, Quantity)>, pub cancellation_fee: Money, } ``` --- ### `src/application/coordination/mod.rs` ```rust //! # CrossDomainCoordinator //! //! Центр сложной координации. //! //! ## Почему здесь сосредоточена сложность: //! //! - **Система координационно сложная, а не доменно-сложная**: //! - Домены — простые, стабильные. //! - Сложность — в политике отката, параллелизме, событиях. //! //! - **Много кросс-доменных политик**: //! - При отмене: проверить статус доставки, создать возврат, откатить баллы. //! - При ошибке платежа: заморозить баллы, отложить доставку. //! //! - **Event-driven поведение**: //! - Retry, hold, unfreeze — управляются через события. //! //! → Поэтому `CrossDomainCoordinator` — это **ядро системы**. use std::sync::Arc; use crate::application::coordination::events::*; use crate::domain::pricing; use crate::domain::inventory; use crate::domain::delivery; use crate::domain::loyalty; pub struct CrossDomainCoordinator { pub pricing: Arc<dyn PricingService>, pub inventory: Arc<dyn InventoryService>, pub delivery: Arc<dyn DeliveryService>, pub loyalty: Arc<dyn LoyaltyService>, pub event_bus: Arc<dyn EventBus>, } impl CrossDomainCoordinator { pub fn new( pricing: Arc<dyn PricingService>, inventory: Arc<dyn InventoryService>, delivery: Arc<dyn DeliveryService>, loyalty: Arc<dyn LoyaltyService>, event_bus: Arc<dyn EventBus>, ) -> Self { Self { pricing, inventory, delivery, loyalty, event_bus } } pub async fn handle_order_cancelled(&self, event: OrderCancelledEvent) -> Result<(), Box<dyn std::error::Error>> { let delivery_status = self.delivery.get_status(event.delivery_id).await?; let cancellation_fee = match delivery_status { delivery::DeliveryStatus::Pending => Money::zero(), delivery::DeliveryStatus::InTransit => { let fee = self.delivery.cancel_delivery(event.delivery_id).await?; let label = self.delivery.create_return_label(event.delivery_id).await?; self.notify_customer(event.customer_id, &label).await; fee.amount } delivery::DeliveryStatus::Delivered => return Err("Cannot cancel delivered order".into()), }; let (inv, loy) = tokio::join!( self.inventory.cancel_reservation(event.reservation_id), self.loyalty.rollback_points(loyalty::PointsTransaction { customer_id: event.customer_id, points: -(event.points as i32), reason: format!("Order {} cancelled", event.order_id), }) ); let refund_amount = event.total - cancellation_fee; let refund_id = self.pricing.refund_payment(pricing::RefundRequest { order_id: event.order_id, amount: refund_amount, reason: pricing::RefundReason::CustomerCancelled, }).await?; self.event_bus.publish(OrderCancelledCompleteEvent { order_id: event.order_id, refund_id, returned_items: inv?, cancellation_fee, }).await?; Ok(()) } pub fn handle_payment_failed(&self, event: PaymentFailedEvent) -> Result<(), Box<dyn std::error::Error>> { self.inventory.soft_release(event.reservation_id)?; self.delivery.put_on_hold(event.delivery_id)?; self.loyalty.freeze_points(event.customer_id, event.points, std::time::Duration::from_secs(86400))?; if event.attempt_number > 1 { self.inventory.cancel_reservation(event.reservation_id)?; self.delivery.cancel_delivery(event.delivery_id)?; self.loyalty.rollback_points(loyalty::PointsTransaction { customer_id: event.customer_id, points: -(event.points as i32), reason: "Payment failed after multiple attempts".to_string(), })?; } Ok(()) } pub fn handle_payment_retry(&self, event: PaymentRetryEvent) -> Result<(), Box<dyn std::error::Error>> { self.inventory.restore_reservation(event.reservation_id)?; self.delivery.remove_hold(event.delivery_id)?; self.loyalty.unfreeze_points(event.freeze_id)?; Ok(()) } async fn notify_customer(&self, _customer_id: CustomerId, _label: &delivery::ReturnLabel) { tracing::info!("Возвратная накладная отправлена"); } } ``` --- ### `src/infrastructure/web/handlers.rs` (фрагмент) ```rust pub async fn create_order( State(state): State<SharedState>, Json(req): Json<CreateOrderRequest>, ) -> Result<Json<serde_json::Value>, (StatusCode, String)> { let customer_id: CustomerId = req.customer_id.parse().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid"))?; let items: Vec<(ProductId, Quantity)> = req.items .into_iter() .map(|(id, q)| Ok((id.parse()?, q))) .collect::<Result<_, _>>() .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid"))?; let address = req.address.into(); let order_id = state.create_order_use_case .execute(customer_id, items, address, None) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(json!({ "order_id": order_id.to_string() }))) } // CreateOrderInteractor // application/use_cases/create_order.rs use std::sync::Arc; use crate::application::ports::*; pub trait CreateOrderUseCase: Send + Sync { async fn execute( &self, customer_id: CustomerId, items: Vec<(ProductId, Quantity)>, address: Address, use_loyalty_points: Option<u32>, ) -> Result<OrderId>; } pub struct CreateOrderInteractor { pricing: Arc<dyn PricingService>, inventory: Arc<dyn InventoryService>, delivery: Arc<dyn DeliveryService>, loyalty: Arc<dyn LoyaltyService>, repository: Arc<dyn OrderRepository>, } impl CreateOrderInteractor { pub fn new( pricing: Arc<dyn PricingService>, inventory: Arc<dyn InventoryService>, delivery: Arc<dyn DeliveryService>, loyalty: Arc<dyn LoyaltyService>, repository: Arc<dyn OrderRepository>, ) -> Self { Self { pricing, inventory, delivery, loyalty, repository } } } #[async_trait] impl CreateOrderUseCase for CreateOrderInteractor { async fn execute(...) -> Result<OrderId> { // ... логика создания заказа } } // main.rs use std::sync::Arc; // 1. Создаём реализации портов (adapters) let pricing = Arc::new(MockPricingService); let inventory = Arc::new(MockInventoryService); let delivery = Arc::new(MockDeliveryService); let loyalty = Arc::new(MockLoyaltyService); let repository = Arc::new(InMemoryOrderRepository::new()); let event_bus = Arc::new(MockEventBus); // 2. Создаём координатор let coordinator = Arc::new(CrossDomainCoordinator::new( pricing.clone(), inventory.clone(), delivery.clone(), loyalty.clone(), event_bus, )); // 3. Создаём use case с зависимостями let create_order_use_case: Arc<dyn CreateOrderUseCase> = Arc::new(CreateOrderInteractor::new( pricing, inventory, delivery, loyalty, repository, )); let state = Arc::new(ApplicationState { create_order_use_case, cancel_order_use_case, confirm_payment_use_case, retry_payment_use_case, }); let app = Router::new() .route("/orders", post(handlers::create_order)) .with_state(state); // ← Axum передаст state в обработчики // ApplicationState // src/infrastructure/web/state.rs pub struct ApplicationState { pub create_order_use_case: Arc<dyn CreateOrderUseCase>, pub cancel_order_use_case: Arc<dyn CancelOrderUseCase>, // ... другие use cases } pub type SharedState = Arc<ApplicationState>; // state.create_order_use_case // infrastructure/web/handlers.rs pub async fn create_order( State(state): State<SharedState>, // ← получаем state Json(req): Json<CreateOrderRequest>, ) -> Result<Json<Value>, (StatusCode, String)> { // Используем use case let order_id = state.create_order_use_case .execute(customer_id, items, address, None) .await?; Ok(Json(json!({ "order_id": order_id.to_string() }))) }
TL;DR: мотайте код сразу до слова "coordination"
На что это похоже?
В ООП аналогом является паттерн "чистая выдумка"
В ФП аналогом является просто набор функций, берущих на себя зависимости
Отдельно хочу заметить, что на Rust доменная логика проектируется и выражается очень красиво!
Почти нет логики, она компактна, зато много типов. Вместо такого
fn process_order(customer_id: &str) { if customer_id.is_empty() { return Err("Empty ID"); } if !is_valid_uuid(customer_id) { return Err("Invalid format"); } // ... дальше логика }
я пишу такое
fn process_order(customer_id: CustomerId) { // уже валидный, не пустой, правильного типа // сразу логика }
Логика вынесена на уровень компиляции
Где код? Где проверки? Где валидация? Где обработка ошибок?
pub async fn handle_order_cancelled( event: OrderCancelledEvent, services: &CoordinationServices, ) -> Result<(), CancellationError> { let delivery_status = services.delivery.get_status(event.delivery_id).await?; if requires_return_label(&delivery_status) { let label = services.delivery.create_return_label(event.delivery_id).await?; services.notifier.send(label).await; } let (inv, loy) = tokio::join!( services.inventory.cancel_reservation(event.reservation_id), services.loyalty.rollback_points(event.points_transaction()), ); services.pricing.refund_payment(RefundRequest::from(&event)).await?; services.event_bus.publish(event.into_complete()).await?; Ok(()) }
Их нет потому что они уже в типах и зависимостях:
`event` — уже валидное событие
`services` — все зависимости готовы
каждый `.await?` уже обработан
`requires_return_label` - чистая функция
Логика не исчезла, она поднялась на уровень типов и композиции. Концентрация и чистота логики на максималках.
Немного философии, занудных определений и сравнений с типовыми паттернами
Что такое "междоменный инвариант"?
Обычный инвариант:
"Цена не может быть отрицательной", "Нельзя зарезервировать больше, чем есть"
Междоменный инвариант:
"Если доставка в пути — нельзя просто отменить заказ, нужно создать возвратную накладную"
"При первой ошибке платежа — заморозить баллы, при второй — отменить всё"
"Если товара нет — найти альтернативный склад, пересчитать доставку, спросить клиента, если дороже"Определение:
"Междоменный (процессный) инвариант — это бизнес-правило, которое зависит от состояния нескольких доменов и управляет их взаимодействием во времени."
Почему классическая архитектура не помогает
DDD учит: "всё в домене", "use case — тонкий", "никаких кросс-доменных вызовов"
Но:
OrderId есть в контексте, но логика работы с платежами (включая retry) — не зона ответственности процесса отмены. Это должен решать отдельный агрегат или сервис.
Проблема:
Архитектура описывает сущности, но не процессы.
Вывод:
Междоменные инварианты — это не ошибка проектирования, а признак зрелой системы.
Куда девать эту логику?
Разбор антипаттернов и альтернатив:
Попытка | Почему не работает |
|---|---|
Положить в |
|
Положить в use case | Use case становится "толстым", дублируется логика |
Размазать по доменам | Каждый делает свою часть — но никто не отвечает за целостность |
Создать | Это не домен, это оркестратор — но в ООП мы называем это "сервис" и считаем второстепенным |
Решение:
Нужно явно выделить процессный домен — область ответственности для управления жизненным циклом.
Процессный домен: новый тип домена
Определение:
Процессный домен — это область знаний, отвечающая за жизненные циклы, переходы состояний и кросс-доменные политики.
Отличия от классического домена:
Критерий
Сущностный домен
Процессный домен
Центр
Сущность (
Order)Процесс (
OrderCancellation)Идентификатор
Есть (
OrderId)Нет (или вторичен)
Хранение
Агрегат, БД
Временные действия, события
Экспертиза
Бизнес-аналитики
Инженеры, SRE, процессные аналитики
Реализация
Entity, Aggregate
Набор функций, оркестратор
Примеры процессных доменов:
Управление жизненным циклом заказа
Платёж с повторными попытками
Возврат товара
Интеграция с внешними системами
Как реализовать процессный домен?
Варианты:
Оркестратор (в монолите)→
CrossDomainCoordinator, функции с явными зависимостямиОтдельный микросервис→
order-orchestrator,payment-retry-serviceWorkflow Engine→ Temporal, Cadence, AWS Step Functions