Раздел 23 · Rust

Доменные типы на Rust: newtype, TryFrom, приватные поля

senior~60 мин

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

Доменные типы на Rust: newtype, TryFrom, приватные поля

Сцена · откуда тема

В коде String это просто String. Он ничего не знает про то, что внутри лежит id брони, номер комнаты или email гостя. Когда десять функций принимают String, ничто не мешает подставить имя гостя в позицию id брони: компилятор молчит, тест может не упасть, баг всплывает в проде после жалобы. То же с числами: u32 это и номер этажа, и количество ночей, и сумма в центах, и перепутать их легко.

Domain-Driven Design начинается там, где мы перестаём прятать домен за примитивами и заводим тип под каждое понятие. У отеля есть ReservationId, RoomNumber, GuestEmail. В голове у менеджера ресепшен это три разные вещи, и в коде они тоже не должны путаться. Этот урок про то, как развести их на уровне типов, а не на уровне договорённости и code review. Хорошая новость: всё, что для этого нужно, ты уже видел в идиоматичном Rust и в стандартных трейтах, осталось собрать это в дисциплину.

Сквозной домен · Hotel Booking

Следующие четыре урока собирают один маленький домен бронирования отеля. Один ограниченный контекст, один агрегат Reservation, который проходит путь брони от создания до выезда. Сегодня закладываем фундамент: словарь типов. Эта серия самостоятельная, теорию вводим с нуля, как будто другие языковые треки ты не открывал.

Что опишем:

  • ReservationId, идентификатор брони. Формат res_ плюс не меньше восьми буквенно-цифровых символов.
  • RoomNumber, номер комнаты. Три или четыре цифры.
  • GuestEmail, email гостя, проходит простую проверку.
  • Day и DateRange, интервал заезда и выезда с инвариантом check_in < check_out.
  • Money и Currency, сумма с валютой, складывать можно только одинаковые валюты.

К концу урока в examples/ddd-hotel/rust/src/domain/types.rs лежит весь словарь, и его можно читать как единый язык домена: каждый тип это термин, у каждого термина форма и инвариант.

Ориентир · невозможные состояния невыразимы

Запомни фразу, на которой держится весь трек. Невозможные состояния невыразимы. Если в коде встречается проверка вида «а вдруг тут невалидное значение»:

// плохо: инвариант проверяется в каждой функции, и легко забыть
fn nights(check_in: u32, check_out: u32) -> u32 {
    if check_out > check_in {
        check_out - check_in
    } else {
        0 // как мы сюда попали? непонятно, и 0 это тихая ложь
    }
}

это сигнал, что тип спроектирован неверно. Правильно нарисованный тип просто не даёт собрать плохое значение. Если DateRange валиден по построению, проверять check_in < check_out в каждой функции не нужно: ты делаешь это один раз на границе, дальше типы держат гарантию. Это и есть приём parse don’t validate: парсер на входе превращает сырьё в тип, который дальше несёт доказательство своей корректности.

Раздел 1 · Newtype как номинальный тип

В Rust инструмент для этого, это newtype: одноэлементная структура-обёртка с собственным именем. На рантайме это та же строка, накладных расходов ноль, но для компилятора ReservationId и RoomNumber теперь разные типы.

use serde::{Deserialize, Serialize};

use super::errors::ValueError;

/// Идентификатор брони. Формат `res_` + не меньше восьми буквенно-цифровых
/// символов, регистр не важен.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ReservationId(String);

impl ReservationId {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

Поле обёртки String приватное (без pub), и это ключевой момент следующего раздела. А чтобы значение нельзя было собрать абы как, конструктор валидирует вход. В Rust для «собрать тип из сырья, возможно с отказом» есть стандартный трейт TryFrom:

impl TryFrom<&str> for ReservationId {
    type Error = ValueError;

    fn try_from(raw: &str) -> Result<Self, Self::Error> {
        if is_valid_reservation_id(raw) {
            Ok(ReservationId(raw.to_owned()))
        } else {
            Err(ValueError::InvalidReservationId(raw.to_owned()))
        }
    }
}

Это и есть smart-конструктор: единственная дверь, через которую рождается ReservationId, и она проверяет формат. Саму проверку держим в одной маленькой функции, без внешних зависимостей:

/// `^res_[a-z0-9]{8,}$`, регистронезависимо. Пишем руками, чтобы не тащить
/// зависимость от regex в учебный пример.
fn is_valid_reservation_id(raw: &str) -> bool {
    let lower = raw.to_ascii_lowercase();
    let Some(tail) = lower.strip_prefix("res_") else {
        return false;
    };
    tail.len() >= 8
        && tail
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
}

Теперь подставить голую строку или RoomNumber в позицию ReservationId компилятор не разрешит, а собрать ReservationId из мусора не выйдет: либо валидное значение, либо ValueError.

Раздел 2 · Приватное поле это и есть граница доверия

Почему поле String внутри ReservationId обязано быть приватным? Потому что видимость в Rust работает на уровне модуля: приватное поле не видно снаружи модуля, где объявлен тип. Значит, выражение ReservationId("мусор".into()) за пределами types.rs просто не скомпилируется, и единственный путь получить значение, это TryFrom.

Отсюда сильная гарантия: если у тебя на руках ReservationId, он валиден. Не «вероятно валиден», не «валиден, если кто-то не забыл проверить», а валиден по построению, потому что других дверей нет. Это превращает тип в границу доверия: сырьё проверяется один раз при пересечении, дальше домен работает с заведомо корректными значениями. Тот же приём ты применял в защищённом Rust под именем sealed и приватных конструкторов, здесь он несёт доменный смысл.

Остальные идентификаторы устроены точно так же, отличается только проверка:

/// Номер комнаты: три или четыре цифры.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RoomNumber(String);

impl TryFrom<&str> for RoomNumber {
    type Error = ValueError;

    fn try_from(raw: &str) -> Result<Self, Self::Error> {
        let digits = (3..=4).contains(&raw.len()) && raw.chars().all(|c| c.is_ascii_digit());
        if digits {
            Ok(RoomNumber(raw.to_owned()))
        } else {
            Err(ValueError::InvalidRoomNumber(raw.to_owned()))
        }
    }
}

Три типа, три имени, три инварианта. Объявление каждого занимает десяток строк и закрывает целый класс ошибок навсегда.

Раздел 3 · Составной инвариант: DateRange

Не каждый инвариант про одно поле. Часть инвариантов про пару полей: check_in < check_out, discount <= price, end > start. Это случай, когда без типа-обёртки пришлось бы городить проверку в каждой функции. Сначала заведём Day как newtype поверх числа, чтобы день нельзя было перепутать с любым другим u32:

/// День заезда/выезда. Целое вида `yyyymmdd` (>= 0); конкретная кодировка
/// домену не важна, важен лишь порядок.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Day(u32);

impl Day {
    pub fn new(value: u32) -> Self {
        Day(value)
    }

    pub fn value(self) -> u32 {
        self.0
    }
}

Day выводит PartialOrd/Ord, поэтому дни сравнимы. А DateRange проверяет инвариант в конструкторе и отдаёт Result: перевёрнутый или пустой интервал не родится:

/// Интервал бронирования с инвариантом `check_in < check_out`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DateRange {
    check_in: Day,
    check_out: Day,
}

impl DateRange {
    pub fn new(check_in: Day, check_out: Day) -> Result<Self, ValueError> {
        if check_in < check_out {
            Ok(DateRange { check_in, check_out })
        } else {
            Err(ValueError::BadDateRange {
                check_in: check_in.value(),
                check_out: check_out.value(),
            })
        }
    }

    pub fn check_in(self) -> Day {
        self.check_in
    }

    pub fn check_out(self) -> Day {
        self.check_out
    }
}

Поля снова приватные, и new единственная дверь. После DateRange::new(...)? ты знаешь, что заезд раньше выезда, и ни в place_reservation, ни в extend_stay, ни где-либо ещё это не перепроверяешь. Правило живёт в одной точке.

Раздел 4 · Currency и Money: тотальная арифметика

В биллинге постоянно складываются суммы: номер, мини-бар, обслуживание. Сложить EUR и USD как голые числа значит получить бессмысленную сумму без подписи, а это реальные деньги и реальная жалоба. Валюту делаем обычным enum (в Rust перечисление и так номинально), а Money храним в минимальных единицах:

/// Валюта Money.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Currency {
    USD,
    EUR,
    RUB,
}

/// Деньги в минимальных единицах (центах) с явной валютой.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Money {
    pub amount_cents: u64,
    pub currency: Currency,
}

Сумма в центах, не в долларах: дробный float складывать опасно, целые центы нет, ровно поэтому так делает и Stripe. А сложение валют делаем тотальным: при несовпадении валют не паникуем, а возвращаем ошибку.

/// Сложение Money. Возвращает ошибку при несовпадении валют, а не паникует:
/// доменная арифметика обязана быть тотальной.
pub fn sum_money(left: Money, right: Money) -> Result<Money, ValueError> {
    if left.currency != right.currency {
        return Err(ValueError::CurrencyMismatch {
            left: format!("{:?}", left.currency),
            right: format!("{:?}", right.currency),
        });
    }
    Ok(Money {
        amount_cents: left.amount_cents + right.amount_cents,
        currency: left.currency,
    })
}

Можно было бы загнать валюту в параметр типа (Money<USD> и Money<EUR> как разные типы) и ловить несовпадение на этапе компиляции. Это сильнее, но валюта обычно приходит из базы или API строкой, и compile-time параметр там быстро вырождается. Поэтому в нашем домене проверка на рантайме через Result, и этого достаточно, пока все сложения идут через sum_money.

Раздел 5 · ValueError: канал отказа конструктора

Куда складывать причину «не сошлось»? В отдельный тип ошибки. Отказ smart-конструктора, это не то же самое, что нарушение бизнес-правила (про второе будет следующий урок), поэтому заводим под него собственный enum через thiserror:

use thiserror::Error;

/// Отказ smart-конструктора значения. Возникает в `TryFrom`/`new` типов из
/// `types.rs` до того, как значение попадёт в decider.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ValueError {
    #[error("невалидный ReservationId: {0:?}")]
    InvalidReservationId(String),

    #[error("невалидный RoomNumber: {0:?}")]
    InvalidRoomNumber(String),

    #[error("невалидный email гостя: {0:?}")]
    InvalidEmail(String),

    #[error("check_in ({check_in}) должен быть строго меньше check_out ({check_out})")]
    BadDateRange { check_in: u32, check_out: u32 },

    #[error("несовпадение валют: {left} против {right}")]
    CurrencyMismatch { left: String, right: String },
}

Каждый вариант, это конкретная причина с понятным русским сообщением. На границе с HTTP это сразу станет ответом 400 с человеческим текстом, без перевода и маппинга. Разделять ошибки конструирования и ошибки бизнес-логики важно: это два разных слоя, и смешивать их каналы значит путать «ты прислал кривой ввод» и «так нельзя по правилам отеля».

Раздел 6 · Весь словарь

Сложив всё вместе, получаем словарь домена: семь терминов, у каждого имя и инвариант, меньше сотни строк. Открой examples/ddd-hotel/rust/src/domain/types.rs и прочти его как единый язык: ReservationId, RoomNumber, GuestEmail, Day, DateRange, Currency, Money. Дальше весь домен опирается на этот словарь, и его контракт виден с первого взгляда. На нём в следующем уроке поедет Decider: команды, события и переходы состояния.

Чек-лист

Чек-листготово

Домашка

Дальше

Следующий урок, Decider на Rust: decide, evolve, replay. На словарь из этого урока садятся команды и события, и появляется канон Жереми Шассена: чистая decide, тотальная evolve и replay как сворачивание стрима. К концу следующего урока у тебя будет работающий BDD-тест жизненного цикла брони без единой строчки persistence.