Я всегда боялся сложных систем: как, черт возьми, их проектировать, создавать, поддерживать, читать в конце концов! Основной инструмент управления сложностью это декомпозиция, да только вот часто всё идёт не по учебникам.
Недавно столкнулся с такой проблемой:
логика между доменами сложнее самих доменов, а в книжках об этом не пишут.
Я строил систему по 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-service
Workflow Engine→ Temporal, Cadence, AWS Step Functions