$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогRust безопаснее: типы для доменной логики и запрет некорректных состояний

Rust безопаснее: типы для доменной логики и запрет некорректных состояний

$ sudo teach IT
·21 июля 2026 г.·10 мин·52
Rust безопаснее: типы для доменной логики и запрет некорректных состояний

Покажем, как моделировать данные так, чтобы часть ошибок не могла возникнуть: newtypes, варианты состояний и ограничение инвариантов типами.

Содержание
Почему типы должны отвечать за инвариантыnewtype: когда один i32 — это уже не i32Проблема “одинаковых чисел”Решение: newtype вокруг базового типаНюанс: newtype почти всегда требует конструктор с проверкамиЧастая ошибка: “декларативность” вместо инвариантовВарианты состояний: enum как машина состояний доменаПример: заказ как набор опциональных полейЛучший подход: моделировать состояния как варианты enumПереходы между состояниями: методы, которые не дают сделать лишнееНюанс: enum-моделирование может усложнить сериализацию и доступ к полямОграничение инвариантов типами: “валюта не равна любым суммам”Пример: положительная сумма и запрет нулевых/отрицательных значенийПример: валюта как часть типа (и почему это важно)Нюанс: реальный обмен валют требует явных преобразованийКомпозиция техник: newtype + типизированные состоянияПрактические подводные камни: когда компилятор мешает, но не зря1) Десериализация из внешнего мира2) Нагрузка на разработчика и “много типов”3) Мутации и performance4) Exposed fields и “обход инвариантов”Как выбрать уровень строгости: чек‑лист для доменной моделиЧто в итоге даёт типизация: меньше багов и более точные контракты

Rust часто воспринимают как язык “про безопасность”, но в реальности основной выигрыш возникает не из‑за проверок на уровне компилятора “вообще”, а из‑за того, что программист начинает моделировать домен так, чтобы часть ошибок просто не могла выразиться в коде. Это та самая идея, которая в англоязычном сообществе называется “making illegal states unrepresentable” — делаем невозможные состояния нетипизируемыми.

В этой статье разберём практические техники: newtype, разбиение доменной логики на варианты состояний (enums) и ограничение инвариантов типами. Поговорим о том, как думать о моделях данных, какие компромиссы неизбежны и как не скатиться в чрезмерную “типизацию ради типизации”.


Почему типы должны отвечать за инварианты

Инвариант — это утверждение, которое должно быть истинным всегда. Например:

  • сумма в заказе не может быть отрицательной;
  • пользователь не может находиться одновременно в статусах Active и Banned;
  • токен может быть в состоянии “валиден” или “просрочен”, но не “и то и другое”;
  • у заказа может быть Shipped, но Delivered возможно только после Shipped.

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

  • кто-то не забудет проверить условие в одном из мест;
  • не появится “обходной” путь (например, десериализация/ручное создание структур);
  • код начнёт расширяться, а инварианты окажутся рассеяны по всему проекту.

Rust позволяет “собрать” инварианты вокруг типов: сделать так, чтобы некорректные значения не могли быть построены. Не только “значения” — иногда важно запрещать именно сочетания значений, то есть состояния.


newtype: когда один i32 — это уже не i32

Проблема “одинаковых чисел”

Представьте домен: есть UserId, OrderId, InvoiceId. В прототипе легко представить их как u64. Но тогда код вида:

code
fn pay(user_id: u64, order_id: u64) { /* ... */ }

let u: u64 = 10;
let o: u64 = 20;
pay(o, u); // компилятор не заметит ошибку местами

С точки зрения типов обе переменные — одинаковые “числа”, и вероятность ошибки выше.

Решение: newtype вокруг базового типа

Идея newtype проста: создаём новый тип как “обёртку” над базовым. Это отдельный тип со своим смыслом, который компилятор не будет путать.

code
#[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 }
}

Теперь ошибка местами становится невозможной:

code
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 как машина состояний домена

Многие “некорректные состояния” возникают не из‑за неверного одиночного значения, а из‑за несогласованных полей. Классика: сущность описывается структурой со множеством опциональных полей, а логика решает, какие из них должны/не должны быть заполнены.

Пример: заказ как набор опциональных полей

Упрощённо:

code
#[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

Сделаем “заказ” как тип, который на уровне компилятора отражает состояние. Например:

code
#[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 сущностей так, чтобы переходы оформлялись методами, а не “ручным присваиванием полей”.

code
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).

Ограничение инвариантов типами: “валюта не равна любым суммам”

Помимо статусов, доменные инварианты часто касаются диапазонов, единиц измерения и контекстов.

Пример: положительная сумма и запрет нулевых/отрицательных значений

Если по бизнес‑логике сумма не может быть нулевой, полезно сделать отдельный тип.

code
#[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 вы переносите ошибку ближе к источнику данных — к точке построения.

Пример: валюта как часть типа (и почему это важно)

Частая ошибка: складывать суммы разных валют. В типобезопасной модели это стоит запретить.

code
#[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> — разные.

Нюанс: реальный обмен валют требует явных преобразований

Вы не отменяете обмен валютами — просто заставляете делать это осознанно:

code
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 + типизированные состояния

Лучший эффект часто достигается при комбинировании:

  1. newtype делает идентификаторы и значения осмысленными и валидируемыми;
  2. enum задаёт допустимые состояния сущности;
  3. методы переходов обеспечивают корректные переходы между состояниями.

Например, возьмём “платёж” и “статусы”, где важно запретить некорректные сочетания:

code
#[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), а затем преобразовывать его в строгий доменный тип.


Как выбрать уровень строгости: чек‑лист для доменной модели

Чтобы не “перетипизировать”, полезно пройтись по вопросам:

  1. Есть ли инварианты, которые нарушаются при текущем дизайне?
    Если да — переносим к инвариантам типы.

  2. Некорректность — это конкретное поле или сочетание полей?

    • одиночное значение → newtype + проверка в конструкторе;
    • сочетание (статус + поля) → enum состояний.
  3. Кто может создать объект?
    Если внешний код/десериализация — придётся проектировать “границу” и обработку ошибок.

  4. Как выглядит API использования?
    Если разработчики постоянно делают одни и те же проверки перед операцией — значит, логика может быть “зашита” в типы и методы.


Что в итоге даёт типизация: меньше багов и более точные контракты

Смысл не в том, чтобы любой ценой избежать Result или match. Смысл в том, чтобы:

  • некорректные значения были невозможны на уровне конструкторов (newtype);
  • некорректные состояния не представлялись в данных (enum вариантов);
  • операции были “разрешены только там, где это допустимо

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Rust

Курс по теме

Rust

Rust — с нуля до уверенной работы с самым безопасным системным языком. Для начинающих и опытных. Освойте владение, трейты, многопоточность и async — и пишите надёжный код без страха утечек памяти.

Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Простыми словами о защищенном соединении

Простыми словами о защищенном соединении

Базовый курс для обычных людей: что такое защищённое соединение, как оно устроено, что оно умеет и чего не умеет, как выбрать сервис и настроить его на своих устройствах.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Сервис, который не падает - как построить безопасную зашифрованную сеть

Сервис, который не падает - как построить безопасную зашифрованную сеть

Практический курс по безопасности сети: как собрать собственный сервис защищённого доступа - от пустого сервера до подписки с автовыбором - и, что важнее, как потом не уронить его своими руками.

Перейти к курсу

Другие статьи

TypeScript vs JavaScript: когда типы спасают проект, а когда мешают
typescript

TypeScript vs JavaScript: когда типы спасают проект, а когда мешают

Сравниваем подходы на реальных примерах кода, разбираем, при каких размерах и архитектурах проекта TypeScript окупается, а когда достаточно JSDoc.

16 июля 2026 г.
730
Ownership и Borrowing в Rust: почему компилятор умнее вас (и это хорошо)
ownership

Ownership и Borrowing в Rust: почему компилятор умнее вас (и это хорошо)

Пошагово разбираем систему владения Rust, объясняем, почему она исключает целые классы ошибок памяти и как научиться думать в её терминах с первых дней.

16 июля 2026 г.
690
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1431
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1070
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Почему типы должны отвечать за инвариантыnewtype: когда один i32 — это уже не i32Проблема “одинаковых чисел”Решение: newtype вокруг базового типаНюанс: newtype почти всегда требует конструктор с проверкамиЧастая ошибка: “декларативность” вместо инвариантовВарианты состояний: enum как машина состояний доменаПример: заказ как набор опциональных полейЛучший подход: моделировать состояния как варианты enumПереходы между состояниями: методы, которые не дают сделать лишнееНюанс: enum-моделирование может усложнить сериализацию и доступ к полямОграничение инвариантов типами: “валюта не равна любым суммам”Пример: положительная сумма и запрет нулевых/отрицательных значенийПример: валюта как часть типа (и почему это важно)Нюанс: реальный обмен валют требует явных преобразованийКомпозиция техник: newtype + типизированные состоянияПрактические подводные камни: когда компилятор мешает, но не зря1) Десериализация из внешнего мира2) Нагрузка на разработчика и “много типов”3) Мутации и performance4) Exposed fields и “обход инвариантов”Как выбрать уровень строгости: чек‑лист для доменной моделиЧто в итоге даёт типизация: меньше багов и более точные контракты