Я всегда боялся сложных систем: как, черт возьми, их проектировать, создавать, поддерживать, читать в конце концов! Основной инструмент управления сложностью это декомпозиция, да только вот часто всё идёт не по учебникам.

Недавно столкнулся с такой проблемой:
логика между доменами сложнее самих доменов, а в книжках об этом не пишут.

Я строил систему по 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 - как домен, только без своих сущностей. Для такого поведенческого домена может быть отдельная команда разработки.

немного кода на Rust,
немного кода на Rust,
всего-то 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) — не зона ответственности процесса отмены. Это должен решать отдельный агрегат или сервис.

  • Проблема:

    Архитектура описывает сущности, но не процессы.

  • Вывод:

    Междоменные инварианты — это не ошибка проектирования, а признак зрелой системы.


Куда девать эту логику?

Разбор антипаттернов и альтернатив:

Попытка

Почему не работает

Положить в Order

Orderне должен знать проDeliveryStatus,LoyaltyFreezeId

Положить в use case

Use case становится "толстым", дублируется логика

Размазать по доменам

Каждый делает свою часть — но никто не отвечает за целостность

Создать OrderService

Это не домен, это оркестратор — но в ООП мы называем это "сервис" и считаем второстепенным

Решение:

Нужно явно выделить процессный домен — область ответственности для управления жизненным циклом.


Процессный домен: новый тип домена

  • Определение:

    Процессный домен — это область знаний, отвечающая за жизненные циклы, переходы состояний и кросс-доменные политики.

  • Отличия от классического домена:

    Критерий

    Сущностный домен

    Процессный домен

    Центр

    Сущность (Order)

    Процесс (OrderCancellation)

    Идентификатор

    Есть (OrderId)

    Нет (или вторичен)

    Хранение

    Агрегат, БД

    Временные действия, события

    Экспертиза

    Бизнес-аналитики

    Инженеры, SRE, процессные аналитики

    Реализация

    Entity, Aggregate

    Набор функций, оркестратор

  • Примеры процессных доменов:

    • Управление жизненным циклом заказа

    • Платёж с повторными попытками

    • Возврат товара

    • Интеграция с внешними системами


Как реализовать процессный домен?

Варианты:

  1. Оркестратор (в монолите)CrossDomainCoordinator, функции с явными зависимостями

  2. Отдельный микросервисorder-orchestrator, payment-retry-service

  3. Workflow Engine→ Temporal, Cadence, AWS Step Functions

Комментарии (10)


  1. TaskForce141
    15.08.2025 19:12

    никто не говорит, что делать

    Врать не буду: я тоже не скажу, что делать. Самому приходится сталкиваться с похожими проблемами, но внятное решение пока не осознал. Напишу сумбурно, как проблема в целом видится мне.

    Продолжительное время намеренно не читал книги, и до них мыслить о работе явно было проще. Полагаю, что это произрастает из привычки мыслить понятием "учебника", в котором зачастую было написано: "вот так делай, а вот так не делай, и вот тебе ещё полный набор исключений вдогонку". И до знакомства с новыми правилами старые покрывали абсолютное большинство встречаемых задач. В технической же литературе обычно даются не правила, а принципы, под которыми подразумеваются своего рода оси и вдоль которых предлагается двигаться, чтобы избежать тех или иных проблем, которые конкретный автор мог счесть наиболее частыми или критичными.

    Парадигмы, методологии, практики - все они по своей сути отражают ограниченные наборы таких проблем и опыт конкретных компаний/команд/людей по их решению. Применимое для одних не обязательно будет одинаково понятным и полезным для всех остальных - универсальных решений, как известно, не бывает. Если в рамках конкретной парадигмы или даже конкретной книге, описывающей её принципы, описан набор понятий и способов их применения, думаю, не стоит их воспринимать как т.н. "истину в последней инстанции". Могут существовать дополнительные книги, продолжающие и дополняющие идею предыдущих; можно до этих самых дополнений попытаться добраться и самостоятельно, поскольку они зачастую вытекают из уже ранее предоставленной основы; какие-то нюансы применения, вероятно, можно будет понять, только столкнувшись с ними в схожей ситуации (как сейчас). Для более полноценного понимания нужны не только сами знания, но и практика их применения (в том числе безуспешная).

    Единственное решение проблемы в целом, которое вижу для себя: находить время, чтобы вдоволь напробоваться намеренно отходить от тех или иных принципов в рамках одного и того же типового проекта, реализованного несколько раз. Не вижу лучшего способа понять, когда кода будет меньше, в каком случае последующие изменения вносить сложнее, чья парадигма круче.


    1. Dhwtj Автор
      15.08.2025 19:12

      Да, я с удивлением понимаю, что в приложении можно быть множество архитектурных паттернов, которые на курсах предлагают "выбрать". А статьи, скажем, про микросервисы в 90% случаев говорят как делать, но не говорят зачем.

      Притом, основная ценность архитектора не сказать как, а сказать зачем, выбрать и обосновать.


  1. AppCrafter
    15.08.2025 19:12

    Мы привыкли видеть домены как главную сущность, а взаимодействие между ними как некое дополнение. На самом же деле именно взаимодействие является основной сущностью, а домены это сгустки, фрагменты, стабильные участки такого взаимодействия.


    1. Dhwtj Автор
      15.08.2025 19:12

      Да.

      В том же Озон тысячи микросервисов, хотя у них нет столько полноценных доменов. Видимо, это междоменные инварианты они так реализуют


  1. SolidSnack
    15.08.2025 19:12

    Между доменами только события или вызовы API c JSON (т.е. типы на границе теряются, значит бизнес правила границу не переходят)

    Подскажите, а это где такое написано?

    Логика вынесена на уровень компиляции

    Где код? Где проверки? Где валидация? Где обработка ошибок?

    Вам можно создавать объект, валидировать входные данные в конструкторе и у вас всегда будет валидный объект либо ошибка...


    1. Dhwtj Автор
      15.08.2025 19:12

      Вам можно создавать объект, валидировать входные данные в конструкторе и у вас всегда будет валидный объект либо ошибка

      Не ошибка, а исключение

      В C#: `new Money(-10)` - `throw`- может упасть в любом слое

      Но можно сделать пустой конструктор и функцию TryCreate или TryFrom, которая вернёт Result<T,Err>

      В Rust: `Money::try_from(-10.0)` -`Result<Money, Error>` - компилятор заставляет обработать

      Подскажите, а это где такое написано?

      Между контекстами в DDD нет общей модели, чтобы не создавать зависимостей


      1. SolidSnack
        15.08.2025 19:12

        Между контекстами в DDD нет общей модели, чтобы не создавать зависимостей

        А инверсия зависимостей которую описывает DDD это?..


        1. Dhwtj Автор
          15.08.2025 19:12

          Подожди, какая нафиг инверсия зависимостей? это между классами. а между доменами что?

          Связь через события, DTO


  1. CuBeR_HeMuL
    15.08.2025 19:12

    Вы буквально описали создание обычного сценария, который призван оркестрировать вызовы разных доменных сервисов, обеспечивать общую транзакционность, вызывать сервисы из разных доменов и так далее.

    Все учебники лишь показывают, как можно делать, но конечное решение всегда будет состоять из какой-либо смеси практик, принципов и паттернов, а качество и наполнение смеси зависит от вашего опыта. DDD это в первую очередь не про написание кода, а про способ мышления при проектировании системы (под системой я понимаю не только код, но и все процессы в компании), способ общения с бизнесом.


  1. Dhwtj Автор
    15.08.2025 19:12

    Это как регламент взаимодействия подразделений компании, который не принадлежит нет одному отделу.