Rust безопаснее: типы для доменной логики и запрет некорректных состояний
Покажем, как моделировать данные так, чтобы часть ошибок не могла возникнуть: newtypes, варианты состояний и ограничение инвариантов типами.
Содержание
Rust безопаснее: типы для доменной логики и запрет некорректных состояний
Rust часто воспринимают как язык “про безопасность”, но в реальности основной выигрыш возникает не из‑за проверок на уровне компилятора “вообще”, а из‑за того, что программист начинает моделировать домен так, чтобы часть ошибок просто не могла выразиться в коде. Это та самая идея, которая в англоязычном сообществе называется “making illegal states unrepresentable” — делаем невозможные состояния нетипизируемыми.
В этой статье разберём практические техники: newtype, разбиение доменной логики на варианты состояний (enums) и ограничение инвариантов типами. Поговорим о том, как думать о моделях данных, какие компромиссы неизбежны и как не скатиться в чрезмерную “типизацию ради типизации”.
Почему типы должны отвечать за инварианты
Инвариант — это утверждение, которое должно быть истинным всегда. Например:
- сумма в заказе не может быть отрицательной;
- пользователь не может находиться одновременно в статусах
ActiveиBanned; - токен может быть в состоянии “валиден” или “просрочен”, но не “и то и другое”;
- у заказа может быть
Shipped, ноDeliveredвозможно только послеShipped.
Традиционный подход на других языках часто строится так: есть структуры с полями, а корректность обеспечивается набором проверок в рантайме. Это работает, пока:
- кто-то не забудет проверить условие в одном из мест;
- не появится “обходной” путь (например, десериализация/ручное создание структур);
- код начнёт расширяться, а инварианты окажутся рассеяны по всему проекту.
Rust позволяет “собрать” инварианты вокруг типов: сделать так, чтобы некорректные значения не могли быть построены. Не только “значения” — иногда важно запрещать именно сочетания значений, то есть состояния.
newtype: когда один i32 — это уже не i32
Проблема “одинаковых чисел”
Представьте домен: есть UserId, OrderId, InvoiceId. В прототипе легко представить их как u64. Но тогда код вида:
fn pay(user_id: u64, order_id: u64) { /* ... */ }
let u: u64 = 10;
let o: u64 = 20;
pay(o, u); // компилятор не заметит ошибку местами
С точки зрения типов обе переменные — одинаковые “числа”, и вероятность ошибки выше.
Решение: newtype вокруг базового типа
Идея newtype проста: создаём новый тип как “обёртку” над базовым. Это отдельный тип со своим смыслом, который компилятор не будет путать.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrderId(u64);
impl UserId {
pub fn new(value: u64) -> Result<Self, &'static str> {
if value == 0 {
Err("UserId не может быть 0")
} else {
Ok(UserId(value))
}
}
pub fn get(self) -> u64 { self.0 }
}
impl OrderId {
pub fn new(value: u64) -> Result<Self, &'static str> {
if value == 0 {
Err("OrderId не может быть 0")
} else {
Ok(OrderId(value))
}
}
pub fn get(self) -> u64 { self.0 }
}
Теперь ошибка местами становится невозможной:
fn pay(user_id: UserId, order_id: OrderId) { /* ... */ }
let u = UserId::new(10).unwrap();
let o = OrderId::new(20).unwrap();
pay(o, u); // ❌ не скомпилируется
Нюанс: newtype почти всегда требует конструктор с проверками
Если просто написать struct UserId(u64); и не ограничить доступ, то можно создать некорректное значение напрямую (если поле публичное). Важно держать внутреннее поле приватным и предоставлять контролируемые конструкторы.
Ещё важнее: когда newtype применяется к типам с ограничениями формата (например, email, номер карты, сумма > 0), логично проверять это на этапе построения.
Частая ошибка: “декларативность” вместо инвариантов
Плохо, когда newtype используют только для “осмысленного имени”, но при этом не закрепляют инвариант. Например:
EmailкакStringбез проверки;PositiveAmountкакu64, но можно сделать0.
Если уж выделяете доменный тип — постарайтесь “подтянуть” инварианты к конструктору.
Варианты состояний: enum как машина состояний домена
Многие “некорректные состояния” возникают не из‑за неверного одиночного значения, а из‑за несогласованных полей. Классика: сущность описывается структурой со множеством опциональных полей, а логика решает, какие из них должны/не должны быть заполнены.
Пример: заказ как набор опциональных полей
Упрощённо:
#[derive(Debug)]
pub struct Order {
pub id: OrderId,
pub status: OrderStatus,
pub shipped_at: Option<DateTime>,
pub delivered_at: Option<DateTime>,
}
#[derive(Debug, Clone, Copy)]
pub enum OrderStatus {
Created,
Shipped,
Delivered,
Cancelled,
}
С этим легко создать невозможное:
- статус
Delivered, ноdelivered_at: None; - статус
Created, ноshipped_at: Some(...); - статус
Cancelled, ноdelivered_at: Some(...).
Rust сам по себе это не предотвратит: инварианты остаются в комментариях и условных проверках.
Лучший подход: моделировать состояния как варианты enum
Сделаем “заказ” как тип, который на уровне компилятора отражает состояние. Например:
#[derive(Debug)]
pub struct OrderBase {
pub id: OrderId,
}
#[derive(Debug)]
pub struct OrderCreated {
pub base: OrderBase,
}
#[derive(Debug)]
pub struct OrderShipped {
pub base: OrderBase,
pub shipped_at: DateTime,
}
#[derive(Debug)]
pub struct OrderDelivered {
pub base: OrderBase,
pub shipped_at: DateTime,
pub delivered_at: DateTime,
}
#[derive(Debug)]
pub struct OrderCancelled {
pub base: OrderBase,
pub cancelled_at: DateTime,
}
#[derive(Debug)]
pub enum Order {
Created(OrderCreated),
Shipped(OrderShipped),
Delivered(OrderDelivered),
Cancelled(OrderCancelled),
}
Теперь нельзя случайно создать “состояние с несовместимыми полями”: каждое состояние несёт ровно те данные, которые ему принадлежат.
Переходы между состояниями: методы, которые не дают сделать лишнее
Одна из ключевых практик — проектировать API сущностей так, чтобы переходы оформлялись методами, а не “ручным присваиванием полей”.
impl Order {
pub fn ship(self, at: DateTime) -> Result<Order, &'static str> {
match self {
Order::Created(created) => Ok(Order::Shipped(OrderShipped {
base: created.base,
shipped_at: at,
})),
_ => Err("Нельзя отгрузить заказ в текущем состоянии"),
}
}
pub fn deliver(self, at: DateTime) -> Result<Order, &'static str> {
match self {
Order::Shipped(shipped) => Ok(Order::Delivered(OrderDelivered {
base: shipped.base,
shipped_at: shipped.shipped_at,
delivered_at: at,
})),
_ => Err("Нельзя доставить заказ в текущем состоянии"),
}
}
pub fn cancel(self, at: DateTime) -> Result<Order, &'static str> {
match self {
Order::Created(created) => Ok(Order::Cancelled(OrderCancelled {
base: created.base,
cancelled_at: at,
})),
Order::Shipped(_) => Err("Заказ уже отгружен; отмена запрещена"),
Order::Delivered(_) => Err("Нельзя отменить доставленный заказ"),
Order::Cancelled(_) => Err("Заказ уже отменён"),
}
}
}
Да, проверки всё равно есть — но теперь они локализованы: невозможно “подставить” несоответствие поля и статуса, потому что структура данных не допускает такой комбинации.
Нюанс: enum-моделирование может усложнить сериализацию и доступ к полям
В реальных системах часто нужно:
- хранить сущность в БД;
- получать и обновлять её по частям;
- работать с read‑model и write‑model.
В таких случаях обычно делают компромисс:
- write‑model хранит строгие инварианты (enum/typed states);
- read‑model может быть плоским (например, для UI).
Ограничение инвариантов типами: “валюта не равна любым суммам”
Помимо статусов, доменные инварианты часто касаются диапазонов, единиц измерения и контекстов.
Пример: положительная сумма и запрет нулевых/отрицательных значений
Если по бизнес‑логике сумма не может быть нулевой, полезно сделать отдельный тип.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NonZeroAmount(u64);
impl NonZeroAmount {
pub fn new(value: u64) -> Result<Self, &'static str> {
if value == 0 {
Err("Сумма должна быть > 0")
} else {
Ok(NonZeroAmount(value))
}
}
pub fn get(self) -> u64 { self.0 }
}
Если вы используете u64 напрямую, то любое место сможет случайно создать 0. С NonZeroAmount вы переносите ошибку ближе к источнику данных — к точке построения.
Пример: валюта как часть типа (и почему это важно)
Частая ошибка: складывать суммы разных валют. В типобезопасной модели это стоит запретить.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Currency { USD, EUR }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Money<C> {
amount: u64,
_currency: std::marker::PhantomData<C>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct USD;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EUR;
impl<C> Money<C> {
pub fn new(amount: u64) -> Result<Self, &'static str> {
if amount == 0 { Err("Сумма должна быть > 0") } else {
Ok(Money { amount, _currency: std::marker::PhantomData })
}
}
pub fn amount(self) -> u64 { self.amount }
}
// Сложение только одноимённых валют:
use std::ops::Add;
impl<C> Add for Money<C> {
type Output = Money<C>;
fn add(self, rhs: Money<C>) -> Money<C> {
Money { amount: self.amount + rhs.amount, _currency: std::marker::PhantomData }
}
}
Так вы добиваетесь того, что “складывать USD и EUR” не получится компиляторно: тип Money<USD> и Money<EUR> — разные.
Нюанс: реальный обмен валют требует явных преобразований
Вы не отменяете обмен валютами — просто заставляете делать это осознанно:
pub fn convert_usd_to_eur(usd: Money<USD>, rate: f64) -> Money<EUR> {
let eur_amount = (usd.amount() as f64 * rate).round() as u64;
Money::<EUR>::new(eur_amount).expect("rate приводит к корректной сумме")
}
В результате в коде всегда видно, где происходят курсы/округления/потери.
Композиция техник: newtype + типизированные состояния
Лучший эффект часто достигается при комбинировании:
newtypeделает идентификаторы и значения осмысленными и валидируемыми;enumзадаёт допустимые состояния сущности;- методы переходов обеспечивают корректные переходы между состояниями.
Например, возьмём “платёж” и “статусы”, где важно запретить некорректные сочетания:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PaymentId(u64);
impl PaymentId {
pub fn new(v: u64) -> Result<Self, &'static str> {
if v == 0 { Err("PaymentId > 0") } else { Ok(PaymentId(v)) }
}
pub fn get(self) -> u64 { self.0 }
}
#[derive(Debug)]
pub struct PaymentCreated {
pub id: PaymentId,
pub amount: NonZeroAmount,
}
#[derive(Debug)]
pub struct PaymentAuthorized {
pub id: PaymentId,
pub amount: NonZeroAmount,
pub authorized_at: DateTime,
}
#[derive(Debug)]
pub struct PaymentCaptured {
pub id: PaymentId,
pub amount: NonZeroAmount,
pub authorized_at: DateTime,
pub captured_at: DateTime,
}
#[derive(Debug)]
pub enum Payment {
Created(PaymentCreated),
Authorized(PaymentAuthorized),
Captured(PaymentCaptured),
}
impl Payment {
pub fn authorize(self, at: DateTime) -> Result<Self, &'static str> {
match self {
Payment::Created(p) => Ok(Payment::Authorized(PaymentAuthorized {
id: p.id,
amount: p.amount,
authorized_at: at,
})),
_ => Err("Нельзя авторизовать в текущем состоянии"),
}
}
pub fn capture(self, at: DateTime) -> Result<Self, &'static str> {
match self {
Payment::Authorized(p) => Ok(Payment::Captured(PaymentCaptured {
id: p.id,
amount: p.amount,
authorized_at: p.authorized_at,
captured_at: at,
})),
_ => Err("Нельзя захватить в текущем состоянии"),
}
}
}
В этой модели вы не храните Option-поля “вдруг пригодится”. Состояние определяет, какие поля вообще существуют. А идентификаторы и суммы валидируются на уровне типов.
Практические подводные камни: когда компилятор мешает, но не зря
1) Десериализация из внешнего мира
Данные приходят из БД/HTTP — там всегда есть шанс получить некорректное состояние. Строгая модель заставит явно обработать это.
Например, при восстановлении Order из БД вы должны:
- проверить, что поля соответствуют статусу;
- либо вернуть ошибку десериализации;
- либо перейти к “безопасному” состоянию (например,
Cancelled/Unknown), если бизнес допускает.
Часто это полезно: “внешний мир” становится источником ошибок, а не внутренние несогласованные состояния.
2) Нагрузка на разработчика и “много типов”
Типизация состояния и newtype могут быстро превратить код в лес структур и enum‑веток. Это не всегда плохо, но важно помнить о здравом смысле:
- если у сущности действительно есть несколько семантически разных состояний — enum отлично подходит;
- если “статус” просто поле для отображения — возможно, типизировать не нужно.
3) Мутации и performance
При строгом enum‑подходе переходы возвращают новые значения (обычно через self → Result<Order, ...>). Это может восприниматься как лишнее копирование, но в Rust оптимизатор и владение играют за вас: перемещения (move) обычно дешёвые, а большие структуры не копируются, а переносятся.
Если структура действительно большая, можно держать общие данные в Arc/Box, или проектировать так, чтобы state-часть была компактной.
4) Exposed fields и “обход инвариантов”
Как только поля становятся публичными, внешний код может построить некорректную комбинацию. Поэтому обычно:
- держат поля приватными;
- предоставляют конструкторы;
- используют методы переходов.
Иногда полезно делать “сырой” тип для хранения (например, OrderRow), а затем преобразовывать его в строгий доменный тип.
Как выбрать уровень строгости: чек‑лист для доменной модели
Чтобы не “перетипизировать”, полезно пройтись по вопросам:
-
Есть ли инварианты, которые нарушаются при текущем дизайне?
Если да — переносим к инвариантам типы. -
Некорректность — это конкретное поле или сочетание полей?
- одиночное значение →
newtype+ проверка в конструкторе; - сочетание (статус + поля) → enum состояний.
- одиночное значение →
-
Кто может создать объект?
Если внешний код/десериализация — придётся проектировать “границу” и обработку ошибок. -
Как выглядит API использования?
Если разработчики постоянно делают одни и те же проверки перед операцией — значит, логика может быть “зашита” в типы и методы.
Что в итоге даёт типизация: меньше багов и более точные контракты
Смысл не в том, чтобы любой ценой избежать Result или match. Смысл в том, чтобы:
- некорректные значения были невозможны на уровне конструкторов (
newtype); - некорректные состояния не представлялись в данных (
enumвариантов); - операции были “разрешены только там, где это допустимо
Комментарии
Пока нет комментариев