DDD в Rust: как писать доменный код без боли и макросов

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

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 возвращается как слайс. Внешний код не может изменить содержимое — только читать. Это жёст

Блоги

Рекомендаций по книгам