Междоменные (процессные) инварианты

от автора

Тот момент, когда они становятся сложнее самих доменов

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

Я строил систему по 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,
всего-то 700+ строк учебного проекта
//! # 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 Money = Decimal; pub type Quantity = u32; pub type Weight = f32;  // ————————————— // Общие структуры // ————————————— #[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 доменная логика проектируется и выражается очень красиво!

Немного философии, занудных определений и сравнений с типовыми паттернами

Что такое «междоменный инвариант»?

  • Обычный инвариант:

    «Цена не может быть отрицательной», «Нельзя зарезервировать больше, чем есть»

  • Междоменный инвариант:

    «Если доставка в пути — нельзя просто отменить заказ, нужно создать возвратную накладную»
    «При первой ошибке платежа — заморозить баллы, при второй — отменить всё»
    «Если товара нет — найти альтернативный склад, пересчитать доставку, спросить клиента, если дороже»

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

    «Междоменный (процессный) инвариант — это бизнес-правило, которое зависит от состояния нескольких доменов и управляет их взаимодействием во времени.»


Почему классическая архитектура не помогает

  • DDD учит: «всё в домене», «use case — тонкий», «никаких кросс-доменных вызовов»

  • Но:

    • Нет OrderId у процесса отмены

    • Нет агрегата для «платежа с retry»

    • Нельзя положить политику в Order::cancel() — она зависит от DeliveryStatus, PaymentAttempts, LoyaltyPoints

  • Проблема:

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

  • Вывод:

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


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

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

Попытка

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

Положить в 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


ссылка на оригинал статьи https://habr.com/ru/articles/937380/