← Назад к списку тем

29. Архитектура и паттерны проектирования

Builder, Newtype, Typestate, RAII, стратегия обработки ошибок в приложении, организация workspace-проекта.

Builder pattern: consuming vs non-consuming

В Rust нет перегрузки конструкторов и опциональных параметров, поэтому builder — не украшение, а рабочая лошадка для типов с большим количеством полей. Ключевой архитектурный вопрос — сигнатура методов: self (consuming) или &mut self (non-consuming). Это не стилистика, а вопрос эргономики API.

// Consuming builder — методы забирают self и возвращают self.
// Плюс: можно строить в одну цепочку выражений, работает с условной сборкой через if let.
// Минус: нельзя сохранить промежуточный builder и переиспользовать его для нескольких объектов.
#[derive(Debug)]
pub struct HttpRequest {
    url: String,
    method: String,
    headers: Vec<(String, String)>,
    timeout_ms: u64,
}

pub struct HttpRequestBuilder {
    url: String,
    method: String,
    headers: Vec<(String, String)>,
    timeout_ms: u64,
}

impl HttpRequestBuilder {
    pub fn new(url: impl Into<String>) -> Self {
        Self {
            url: url.into(),
            method: "GET".to_string(),
            headers: Vec::new(),
            timeout_ms: 30_000,
        }
    }

    // self, не &mut self — метод потребляет builder и возвращает новый
    pub fn method(mut self, m: &str) -> Self {
        self.method = m.to_string();
        self
    }

    pub fn header(mut self, key: &str, value: &str) -> Self {
        self.headers.push((key.to_string(), value.to_string()));
        self
    }

    pub fn timeout_ms(mut self, ms: u64) -> Self {
        self.timeout_ms = ms;
        self
    }

    pub fn build(self) -> HttpRequest {
        HttpRequest {
            url: self.url,
            method: self.method,
            headers: self.headers,
            timeout_ms: self.timeout_ms,
        }
    }
}

let req = HttpRequestBuilder::new("https://api.example.com")
    .method("POST")
    .header("Content-Type", "application/json")
    .timeout_ms(5_000)
    .build();

Non-consuming вариант (&mut self -> &mut Self) полезен, когда сборка ветвится: builder можно сохранить в переменную, передать в функцию, дособрать по условию, а build() вызвать в конце отдельным вызовом. Требует явного let mut builder = ..., зато не блокирует промежуточное хранение состояния.

Typed builder: обязательные поля проверяются компилятором

Обычный builder переносит проверку обязательных полей в рантайм (build() возвращает Result или паникует). Для по-настоящему критичных конструкторов можно закодировать «обязательность» поля в типе builder'а через generic-параметры — тогда build() просто не будет существовать, пока не собраны все обязательные поля.

struct Missing;
struct Set<T>(T);

struct ServerBuilder<Host, Port> {
    host: Host,
    port: Port,
}

impl ServerBuilder<Missing, Missing> {
    fn new() -> Self {
        ServerBuilder { host: Missing, port: Missing }
    }
}

impl<Port> ServerBuilder<Missing, Port> {
    fn host(self, h: String) -> ServerBuilder<Set<String>, Port> {
        ServerBuilder { host: Set(h), port: self.port }
    }
}

impl<Host> ServerBuilder<Host, Missing> {
    fn port(self, p: u16) -> ServerBuilder<Host, Set<u16>> {
        ServerBuilder { host: self.host, port: Set(p) }
    }
}

// build() существует только когда оба поля Set<T> — иначе метод не найден компилятором
impl ServerBuilder<Set<String>, Set<u16>> {
    fn build(self) -> Server {
        Server { host: self.host.0, port: self.port.0 }
    }
}

// ServerBuilder::new().port(8080).build(); // ошибка компиляции: build() не найден для Missing host
🔑 Typed builder (typestate-builder) полностью убирает класс ошибок «забыл обязательное поле» — программа с такой ошибкой не скомпилируется. Цена — больше boilerplate и менее читаемые сообщения об ошибках компилятора. Используйте для публичных API с несколькими обязательными полями; для внутренних структур с 1-2 полями обычный builder с Result из build() проще и достаточен.

Newtype pattern: типобезопасность вместо соглашений

Классическая проблема: функция с несколькими параметрами одного примитивного типа позволяет перепутать аргументы местами — компилятор не увидит ошибки, потому что u64 — это u64.

// Плохо: ничто не мешает перепутать порядок аргументов
fn transfer_funds(from_user: u64, to_user: u64, order_id: u64, amount_cents: u64) {
    // ...
}

// Компилируется, но семантически перепутаны from/to и order_id/amount
transfer_funds(42, 1001, 500, 7);

Newtype — кортежная структура-обёртка из одного поля — превращает каждый смысл в отдельный тип. Ошибка перестановки аргументов ловится на этапе компиляции.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(pub u64);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrderId(pub u64);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AmountCents(pub u64);

fn transfer_funds(from_user: UserId, to_user: UserId, order_id: OrderId, amount: AmountCents) {
    // ...
}

// transfer_funds(OrderId(500), UserId(42), UserId(1001), AmountCents(7));
// ^ ошибка компиляции: expected `UserId`, found `OrderId`

transfer_funds(UserId(42), UserId(1001), OrderId(500), AmountCents(700));

Эргономика: реализация трейтов без потери типобезопасности

Голая обёртка неудобна — придётся писать .0 на каждый доступ. Реализуем Deref для чтения (без DerefMut, чтобы не разрешать произвольную мутацию внутреннего значения в обход инвариантов), From для конструирования и Display для форматирования.

use std::fmt;
use std::ops::Deref;

impl Deref for UserId {
    type Target = u64;
    fn deref(&self) -> &u64 { &self.0 }
}

impl From<u64> for UserId {
    fn from(id: u64) -> Self { UserId(id) }
}

impl fmt::Display for UserId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "user#{}", self.0)
    }
}

let id: UserId = 42u64.into();
println!("{id}");              // использует Display
let raw: u64 = *id;             // использует Deref для чтения
let doubled = *id * 2;         // арифметика через deref-коэрсию

Newtype и orphan rule

Вторая классическая причина для newtype — обход orphan rule: нельзя реализовать внешний трейт для внешнего типа в своём крейте (например, serde::Serialize для chrono::DateTime в кастомном формате, или Display для Vec<T>). Обёртка над внешним типом — «свой» тип, для него реализация разрешена.

use std::fmt;

// Vec<T> — чужой тип (из std), Display для него напрямую не реализовать (orphan rule).
struct CsvRow(Vec<String>);

impl fmt::Display for CsvRow {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0.join(","))
    }
}
КритерийNewtype (struct UserId(u64))Type alias (type UserId = u64)
Проверка компиляторомДа — отдельный номинальный типНет — просто синоним, взаимозаменяем с u64
Перепутать аргументыОшибка компиляцииКомпилируется молча
Реализация внешних трейтовРазрешена (свой тип)Действуют ограничения исходного типа
Накладные расходы в рантаймеНулевые (zero-cost abstraction)Нулевые
Когда использоватьДомен/бизнес-идентификаторы, единицы измеренияЧисто для читаемости сигнатур, без гарантий

Typestate pattern: недопустимые состояния — ошибка компиляции

Typestate кодирует конечный автомат объекта в системе типов: каждое состояние — отдельный тип (или разный generic-параметр), и методы, доступные только в конкретном состоянии, реализованы исключительно для этого типа. Вызов метода в «неправильном» состоянии — не паника в рантайме, а ошибка компиляции: метод просто не существует у данного типа.

use std::marker::PhantomData;
use std::io::{self, Read, Write};
use std::net::TcpStream;

// Маркерные типы состояний — не содержат данных, существуют только на уровне типов
pub struct Disconnected;
pub struct Connected;

pub struct Connection<State> {
    stream: Option<TcpStream>,
    addr: String,
    _state: PhantomData<State>,
}

// Конструктор доступен только в состоянии Disconnected
impl Connection<Disconnected> {
    pub fn new(addr: &str) -> Self {
        Connection { stream: None, addr: addr.to_string(), _state: PhantomData }
    }

    // Переход Disconnected -> Connected возвращает НОВЫЙ тип Connection<Connected>
    pub fn connect(self) -> io::Result<Connection<Connected>> {
        let stream = TcpStream::connect(&self.addr)?;
        Ok(Connection { stream: Some(stream), addr: self.addr, _state: PhantomData })
    }
}

// send()/recv() существуют ТОЛЬКО для Connection<Connected>
impl Connection<Connected> {
    pub fn send(&mut self, data: &[u8]) -> io::Result<()> {
        self.stream.as_mut().unwrap().write_all(data)
    }

    pub fn recv(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        self.stream.as_mut().unwrap().read(buf)
    }

    // Переход Connected -> Disconnected
    pub fn disconnect(self) -> Connection<Disconnected> {
        // self.stream дропается здесь, закрывая TCP-соединение
        Connection { stream: None, addr: self.addr, _state: PhantomData }
    }
}

fn example() -> io::Result<()> {
    let conn = Connection::new("127.0.0.1:9000");
    // conn.send(b"hi"); // ОШИБКА КОМПИЛЯЦИИ: метод `send` не найден у Connection<Disconnected>

    let mut conn = conn.connect()?;   // теперь Connection<Connected>
    conn.send(b"ping")?;               // доступно

    let conn = conn.disconnect();       // снова Connection<Disconnected>
    // conn.send(b"ping"); // и снова ошибка компиляции
    Ok(())
}
⚠️ Компилятор действительно откажется собирать conn.send(...) для Connection<Disconnected> с сообщением вида "no method named `send` found for struct `Connection<Disconnected>`" — потому что impl Connection<Connected> просто не добавляет этот метод в неверный тип. Это сильнее, чем проверка состояния в рантейме через if self.state != State::Connected { return Err(...) }: ошибка ловится на этапе разработки, а не на проде.

Альтернатива PhantomData — отдельные независимые структуры на каждое состояние без общего generic-параметра, если состояния сильно отличаются по данным. Generic-подход выгоднее, когда общих полей много и хочется единой реализации Debug/Drop для всех состояний сразу.

RAII: ресурсы, которые сами себя освобождают

В Rust RAII — не опциональная практика, а фундамент модели владения: любой ресурс (память, файловый дескриптор, лок, соединение, транзакция) должен быть обёрнут в тип, чей Drop гарантирует освобождение независимо от пути выполнения — включая раннее return, ? и панику при размотке стека.

Guard-объект для транзакции с rollback по умолчанию

pub struct Transaction<'a> {
    conn: &'a mut DbConnection,
    committed: bool,
}

impl<'a> Transaction<'a> {
    pub fn begin(conn: &'a mut DbConnection) -> Self {
        conn.execute("BEGIN");
        Transaction { conn, committed: false }
    }

    pub fn execute(&mut self, query: &str) {
        self.conn.execute(query);
    }

    // Явный commit потребляет self — после этого Drop уже не откатит
    pub fn commit(mut self) {
        self.conn.execute("COMMIT");
        self.committed = true;
    }
}

impl<'a> Drop for Transaction<'a> {
    fn drop(&mut self) {
        if !self.committed {
            // Сработает и при обычном выходе из области видимости, и при панике во время size-размотки
            self.conn.execute("ROLLBACK");
        }
    }
}

fn update_balance(conn: &mut DbConnection, amount: i64) -> Result<(), DbError> {
    let mut tx = Transaction::begin(conn);
    tx.execute("UPDATE accounts SET balance = balance + ?");
    if amount < 0 {
        return Err(DbError::InvalidAmount); // tx дропается здесь -> автоматический ROLLBACK
    }
    tx.commit(); // только теперь Drop становится no-op
    Ok(())
}

Тот же принцип используется в std: MutexGuard освобождает лок в Drop, File закрывает дескриптор, tempfile::NamedTempFile удаляет временный файл с диска. Правило простое: если у ресурса есть операция «освободить», она должна быть в Drop, а не в вызываемом отдельно методе, который легко забыть вызвать.

🚫 В Drop::drop нельзя вызывать методы, которые могут запаниковать или требуют async — паника внутри drop во время уже идущей размотки стека (panic-during-unwind) приводит к немедленному abort процесса, а не к graceful shutdown. Для async-ресурсов используйте явный async fn close(self) и полагайтесь на дисциплину вызова либо на linter/clippy-правила, а не на Drop.

Стратегия обработки ошибок: библиотека vs приложение

Golden rule экосистемы Rust: библиотечный код возвращает конкретные, типизированные ошибки (через thiserror), а прикладной/бинарный код агрегирует их в один динамический тип (через anyhow) с добавлением контекста. Смешение этих двух подходов — частая архитектурная ошибка на code review.

Критерийthiserroranyhow
Где применятьБиблиотечные крейты (domain, infra)Бинарные крейты (main, CLI, обработчики верхнего уровня)
Тип ошибкиКонкретный enum, известный на этапе компиляцииanyhow::Error — стирает тип, хранит любую std::error::Error
Обработка вызывающей сторонойМожет match по вариантамОбычно только логирование/вывод, без матчинга
Добавление контекстаВручную через варианты enum.context("...") / .with_context(...)
Стоимость на исполненииНулевая, статическая диспетчеризацияАллокация + vtable (динамическая диспетчеризация)

Доменные ошибки и разделение по слоям

В многослойном приложении (repository → service → API) каждый слой определяет свой тип ошибок и трансформирует ошибку нижнего слоя через #[from] или явный map_err, не протаскивая детали реализации наверх.

use thiserror::Error;

// Слой репозитория: ошибки максимально близки к инфраструктуре
#[derive(Debug, Error)]
pub enum RepoError {
    #[error("row not found")]
    NotFound,
    #[error("database connection failed")]
    Connection(#[from] sqlx::Error),
}

// Слой сервиса: доменные термины, не завязан на sqlx
#[derive(Debug, Error)]
pub enum ServiceError {
    #[error("user {0} not found")]
    UserNotFound(UserId),
    #[error("insufficient balance: need {needed}, have {available}")]
    InsufficientBalance { needed: u64, available: u64 },
    #[error("internal storage error")]
    Storage(#[from] RepoError),
}

// Явная трансформация там, где #[from] недостаточно (нужна доп. логика)
fn load_user(repo: &UserRepo, id: UserId) -> Result<User, ServiceError> {
    repo.find(id).map_err(|e| match e {
        RepoError::NotFound => ServiceError::UserNotFound(id),
        other => ServiceError::Storage(other),
    })
}

// Слой API: маппинг доменных ошибок в HTTP-статусы, детали не утекают наружу
impl ServiceError {
    pub fn status_code(&self) -> u16 {
        match self {
            ServiceError::UserNotFound(_) => 404,
            ServiceError::InsufficientBalance { .. } => 409,
            ServiceError::Storage(_) => 500,
        }
    }
}

// Бинарный крейт (main.rs / CLI): anyhow с контекстом, тип стёрт
fn run() -> anyhow::Result<()> {
    let cfg = load_config("app.toml")
        .context("failed to load application config")?;
    let pool = connect_db(&cfg.database_url)
        .with_context(|| format!("failed to connect to {}", cfg.database_url))?;
    Ok(())
}
✅ Правило на собеседовании и на code review: если тип экспортируется из библиотечного крейта (lib.rs) — используйте thiserror с конкретным enum. Если ошибка возникает и сразу обрабатывается в бинарнике (main.rs, обработчик команды CLI) — anyhow с контекстом читается быстрее и не требует объявления новых типов ради одного места использования.

Организация workspace-проекта

Начиная с определённого размера, монолитный крейт с одним lib.rs на десятки тысяч строк становится узким местом: долгая инкрементальная компиляция (весь крейт — единица компиляции), размытые границы модулей, невозможность собрать только нужную часть. Cargo workspace решает это разбиением на независимо компилируемые крейты с общим Cargo.lock.

// Cargo.toml (корень workspace)
[workspace]
resolver = "2"
members = [
    "crates/domain",   # бизнес-логика, чистый Rust, без I/O
    "crates/infra",    # реализация репозиториев: sqlx, redis, http-клиенты
    "crates/api",      # HTTP-слой: axum handlers, DTO, маппинг ошибок в статусы
    "crates/cli",      # бинарник для админ-скриптов и миграций
]

# Общие зависимости и их версии фиксируются один раз для всего workspace
[workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
thiserror = "1"
domain = { path = "crates/domain" }
infra  = { path = "crates/infra" }

[profile.release]
lto = true
codegen-units = 1
// crates/api/Cargo.toml — дочерний крейт наследует версии через workspace = true
[package]
name = "api"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
domain = { workspace = true }
infra  = { workspace = true }

[features]
default = []
metrics = ["dep:prometheus"]   # опциональная функциональность за флагом

[dependencies.prometheus]
version = "0.13"
optional = true

Ключевой архитектурный приём — domain как library crate без внешних I/O-зависимостей: только структуры, доменные ошибки, трейты-порты (trait UserRepository). Реализации этих трейтов живут в infra и зависят от domain, а не наоборот — это Dependency Inversion на уровне крейтов, а не только трейтов. Такое разделение позволяет собирать и тестировать domain за секунды, без поднятия БД и без recompile всего infra при правке бизнес-правила.

КритерийCargo workspaceМножество независимых репозиториев
Единый Cargo.lockДа — версии зависимостей согласованыНет — риск version drift между сервисами
Инкрементальная сборкаПересобирается только изменённый крейт и его зависимыеКаждый репозиторий собирается независимо
Атомарные PR через границы модулейДа, один PR меняет domain + infra сразуНужна координация версий между репо
Подходит дляОдин продукт/монолит с чёткими слоямиНезависимо разворачиваемые сервисы разных команд

Принципы: make illegal states unrepresentable

SOLID был сформулирован для языков с наследованием и рантайм-полиморфизмом по умолчанию; в Rust его переосмысливают через систему типов и композицию трейтов, а не иерархии классов.

Parse, don't validate

Вместо того чтобы валидировать «сырые» данные раз за разом на границах разных функций, конвертируйте их в более строгий тип один раз на входе — дальше по коду инвариант гарантирован типом, а не дисциплиной вызывающего.

// Плохо: EmailStr — это просто String, инвариант "валидный email" нигде не зафиксирован
fn send_welcome_email(email: &str) { /* приходится валидировать заново */ }

// Хорошо: конструктор — единственная точка входа, дальше тип гарантирует валидность
#[derive(Debug, Clone)]
pub struct Email(String);

impl Email {
    pub fn parse(raw: &str) -> Result<Self, EmailError> {
        if !raw.contains('@') || raw.starts_with('@') {
            return Err(EmailError::InvalidFormat);
        }
        Ok(Email(raw.to_lowercase()))
    }
}

// Функция принимает Email, а не &str — валидация уже произошла на границе
fn send_welcome_email(email: &Email) { /* инвариант гарантирован типом */ }

Недопустимые состояния — невозможны

// Плохо: можно создать структуру с логически противоречивыми полями
struct Download {
    url: String,
    progress: f32,
    error: Option<String>,     // что если error.is_some() И progress == 100.0?
    is_complete: bool,   // может рассинхронизироваться с progress
}

// Хорошо: enum делает противоречивые комбинации полей невыразимыми в принципе
enum DownloadState {
    InProgress { progress: f32 },
    Failed { reason: String },
    Completed { bytes: u64 },
}

struct Download {
    url: String,
    state: DownloadState,
}

Композиция трейтов вместо наследования

Там, где в ООП-языках была бы иерархия абстрактных классов, в Rust — набор маленьких трейтов, которые тип реализует по необходимости (аналог Interface Segregation): функции принимают impl Trait или generic с bound'ами, а не конкретные структуры.

trait Readable { fn read_bytes(&self) -> Vec<u8>; }
trait Cacheable { fn cache_key(&self) -> String; }

// Функция запрашивает ровно те способности, которые ей нужны
fn serve_cached<T: Readable + Cacheable>(item: &T) -> Vec<u8> {
    // item.cache_key() для поиска в кэше, item.read_bytes() при промахе
    item.read_bytes()
}
🔑 Итог: в Go-курсе SOLID формулируется через интерфейсы и явные конструкторы. В Rust та же дисциплина выражается через типы данных — enum вместо булевых флагов, newtype вместо примитивов, typestate вместо рантайм-проверок состояния, маленькие трейты вместо больших интерфейсов. Компилятор становится соавтором ревью: часть багов, которые в других языках ловятся тестами, здесь не компилируются в принципе.