Я всегда боялся сложных систем: как, черт возьми, их проектировать, создавать, поддерживать, читать в конце концов! Основной инструмент управления сложностью это декомпозиция, да только вот часто всё идёт не по учебникам.
Недавно столкнулся с такой проблемой:
логика между доменами сложнее самих доменов, а в книжках об этом не пишут.
Я строил систему по 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
Комментарии (10)
AppCrafter
15.08.2025 19:12Мы привыкли видеть домены как главную сущность, а взаимодействие между ними как некое дополнение. На самом же деле именно взаимодействие является основной сущностью, а домены это сгустки, фрагменты, стабильные участки такого взаимодействия.
Dhwtj Автор
15.08.2025 19:12Да.
В том же Озон тысячи микросервисов, хотя у них нет столько полноценных доменов. Видимо, это междоменные инварианты они так реализуют
SolidSnack
15.08.2025 19:12Между доменами только события или вызовы API c JSON (т.е. типы на границе теряются, значит бизнес правила границу не переходят)
Подскажите, а это где такое написано?
Логика вынесена на уровень компиляции
Где код? Где проверки? Где валидация? Где обработка ошибок?
Вам можно создавать объект, валидировать входные данные в конструкторе и у вас всегда будет валидный объект либо ошибка...
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 нет общей модели, чтобы не создавать зависимостей
SolidSnack
15.08.2025 19:12Между контекстами в DDD нет общей модели, чтобы не создавать зависимостей
А инверсия зависимостей которую описывает DDD это?..
Dhwtj Автор
15.08.2025 19:12Подожди, какая нафиг инверсия зависимостей? это между классами. а между доменами что?
Связь через события, DTO
CuBeR_HeMuL
15.08.2025 19:12Вы буквально описали создание обычного сценария, который призван оркестрировать вызовы разных доменных сервисов, обеспечивать общую транзакционность, вызывать сервисы из разных доменов и так далее.
Все учебники лишь показывают, как можно делать, но конечное решение всегда будет состоять из какой-либо смеси практик, принципов и паттернов, а качество и наполнение смеси зависит от вашего опыта. DDD это в первую очередь не про написание кода, а про способ мышления при проектировании системы (под системой я понимаю не только код, но и все процессы в компании), способ общения с бизнесом.
Dhwtj Автор
15.08.2025 19:12Это как регламент взаимодействия подразделений компании, который не принадлежит нет одному отделу.
TaskForce141
Врать не буду: я тоже не скажу, что делать. Самому приходится сталкиваться с похожими проблемами, но внятное решение пока не осознал. Напишу сумбурно, как проблема в целом видится мне.
Продолжительное время намеренно не читал книги, и до них мыслить о работе явно было проще. Полагаю, что это произрастает из привычки мыслить понятием "учебника", в котором зачастую было написано: "вот так делай, а вот так не делай, и вот тебе ещё полный набор исключений вдогонку". И до знакомства с новыми правилами старые покрывали абсолютное большинство встречаемых задач. В технической же литературе обычно даются не правила, а принципы, под которыми подразумеваются своего рода оси и вдоль которых предлагается двигаться, чтобы избежать тех или иных проблем, которые конкретный автор мог счесть наиболее частыми или критичными.
Парадигмы, методологии, практики - все они по своей сути отражают ограниченные наборы таких проблем и опыт конкретных компаний/команд/людей по их решению. Применимое для одних не обязательно будет одинаково понятным и полезным для всех остальных - универсальных решений, как известно, не бывает. Если в рамках конкретной парадигмы или даже конкретной книге, описывающей её принципы, описан набор понятий и способов их применения, думаю, не стоит их воспринимать как т.н. "истину в последней инстанции". Могут существовать дополнительные книги, продолжающие и дополняющие идею предыдущих; можно до этих самых дополнений попытаться добраться и самостоятельно, поскольку они зачастую вытекают из уже ранее предоставленной основы; какие-то нюансы применения, вероятно, можно будет понять, только столкнувшись с ними в схожей ситуации (как сейчас). Для более полноценного понимания нужны не только сами знания, но и практика их применения (в том числе безуспешная).
Единственное решение проблемы в целом, которое вижу для себя: находить время, чтобы вдоволь напробоваться намеренно отходить от тех или иных принципов в рамках одного и того же типового проекта, реализованного несколько раз. Не вижу лучшего способа понять, когда кода будет меньше, в каком случае последующие изменения вносить сложнее,
чья парадигма круче.Dhwtj Автор
Да, я с удивлением понимаю, что в приложении можно быть множество архитектурных паттернов, которые на курсах предлагают "выбрать". А статьи, скажем, про микросервисы в 90% случаев говорят как делать, но не говорят зачем.
Притом, основная ценность архитектора не сказать как, а сказать зачем, выбрать и обосновать.