Domain-Driven Design в Rust проектах: когда типы становятся вашими союзниками
Вы когда-нибудь писали Rust-приложение, в котором бизнес-логика тонула в море структур, трейтов и макросов? Знакомое чувство, когда, глядя на код, вы не можете сходу сказать, что делает та или иная функция в контексте предметной области? Если да — добро пожаловать в клуб. Domain-Driven Design (DDD) призван решить эту проблему, а Rust с его системой типов — идеальный полигон для внедрения DDD-принципов.
В этой статье мы не будем пересказывать книгу Эванса. Вместо этого я покажу, как применять Domain-Driven Design в Rust проектах на реальных примерах. Вы узнаете, как с помощью типов-оберток, модульной архитектуры и выразительных структур сделать доменный код чистым, безопасным и понятным. Поехали!
Почему Rust и DDD — идеальная пара
Domain-Driven Design требует строгой изоляции доменной логики от инфраструктуры. Rust предоставляет для этого мощные инструменты:
- Система типов — позволяет моделировать бизнес-правила на уровне компилятора. Ошибка? Компилятор не даст её сделать.
- Владение и заимствование — гарантируют, что агрегаты и сущности не будут случайно изменены в неожиданном месте.
- Модульная система — чётко разделяет доменный слой, инфраструктуру и приложение.
Но есть и подводные камни. Rust не прощает «жирных» объектов с кучей мутабельного состояния. Поэтому DDD в Rust требует иного подхода к моделированию, чем, скажем, в Java или C#.
Ключевая идея: делайте неверные состояния непредставимыми
В DDD мы стремимся к тому, чтобы бизнес-правила были закодированы в самой структуре данных. В Rust это превращается в мантру: «Если состояние недопустимо — сделайте так, чтобы его невозможно было создать».
Вместо того чтобы писать:
struct User {
name: String,
email: String,
age: u8,
}
Мы создаём типы, которые уже на этапе компиляции гарантируют корректность:
pub struct Email(String);
pub struct Age(u8);
pub struct UserName(String);
pub struct User {
name: UserName,
email: Email,
age: Age,
}
Кажется, что это просто обёртки? На самом деле это первый шаг к DDD на Rust, где типы становятся документацией и стражем инвариантов.
Моделируем домен: Value Object и Entity
Центральные понятия DDD — Entity (сущность) и Value Object (объект-значение). В Rust они реализуются по-разному.
Value Object в Rust: неизменяемость и равенство по значению
Value Object не имеет идентичности. Два объекта равны, если равны их поля. В Rust идеально ложится на структуры с #[derive(PartialEq)] и неизменяемыми полями.
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct Money {
amount: i64,
currency: Currency,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Currency {
USD,
EUR,
RUB,
}
impl Money {
pub fn new(amount: i64, currency: Currency) -> Self {
// Бизнес-правило: деньги не могут быть отрицательными
assert!(amount >= 0, "Amount must be non-negative");
Self { amount, currency }
}
pub fn add(&self, other: &Money) -> Result<Self, String> {
if self.currency != other.currency {
return Err("Cannot add different currencies".into());
}
Ok(Self::new(self.amount + other.amount, self.currency.clone()))
}
}
Обратите внимание: Money — неизменяемый. Любая операция возвращает новый объект. Это соответствует философии DDD в Rust: никаких сеттеров, только методы, создающие новые экземпляры.
Entity: идентичность и мутации
Сущность уникальна по своему идентификатору. В Rust для этого удобно использовать Uuid или i64 из БД. Но вот что важно: сущность должна быть неизменяемой снаружи, а все мутации — через методы с явной семантикой.
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct Order {
id: OrderId,
items: Vec<OrderLine>,
status: OrderStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct OrderId(Uuid);
#[derive(Debug, Clone)]
struct OrderLine {
product_id: String,
quantity: u32,
price: Money,
}
#[derive(Debug, Clone, PartialEq)]
pub enum OrderStatus {
Draft,
Confirmed,
Shipped,
Cancelled,
}
impl Order {
pub fn new() -> Self {
Self {
id: OrderId(Uuid::new_v4()),
items: Vec::new(),
status: OrderStatus::Draft,
}
}
pub fn add_item(&mut self, product_id: String, quantity: u32, price: Money) -> Result<(), String> {
if self.status != OrderStatus::Draft {
return Err("Can only add items to draft orders".into());
}
self.items.push(OrderLine {
product_id,
quantity,
price,
});
Ok(())
}
pub fn confirm(&mut self) -> Result<(), String> {
if self.items.is_empty() {
return Err("Cannot confirm empty order".into());
}
self.status = OrderStatus::Confirmed;
Ok(())
}
}
Здесь Order — классическая сущность. Её идентичность — OrderId. Все изменения проходят через методы, которые проверяют бизнес-правила. Компилятор Rust гарантирует, что вы не сможете изменить статус напрямую.
Агрегаты: границы транзакционной согласованности
Domain-Driven Design в Rust особенно силён в моделировании агрегатов. Агрегат — это кластер сущностей и value objects, которые изменяются как единое целое. В Rust мы используем один корневой объект, который владеет всеми дочерними элементами.
Возьмём пример с заказом. Order — корень агрегата. Он владеет OrderLine. Никто не может создать OrderLine без заказа.
impl Order {
// Только Order может управлять своими линиями
fn items(&self) -> &[OrderLine] {
&self.items
}
pub fn total(&self) -> Result<Money, String> {
if self.items.is_empty() {
return Err("No items".into());
}
let currency = self.items[0].price.currency.clone();
let mut total = Money::new(0, currency.clone());
for item in &self.items {
total = total.add(&item.price)?;
}
Ok(total)
}
}
Обратите внимание: items возвращается как слайс. Внешний код не может изменить содержимое — только читать. Это жёст