В 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 = ..., зато не блокирует промежуточное хранение состояния.
Обычный 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
Result из build() проще и достаточен.Классическая проблема: функция с несколькими параметрами одного примитивного типа позволяет перепутать аргументы местами — компилятор не увидит ошибки, потому что 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: нельзя реализовать внешний трейт для внешнего типа в своём крейте (например, 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 кодирует конечный автомат объекта в системе типов: каждое состояние — отдельный тип (или разный 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 для всех состояний сразу.
В Rust RAII — не опциональная практика, а фундамент модели владения: любой ресурс (память, файловый дескриптор, лок, соединение, транзакция) должен быть обёрнут в тип, чей Drop гарантирует освобождение независимо от пути выполнения — включая раннее return, ? и панику при размотке стека.
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.Golden rule экосистемы Rust: библиотечный код возвращает конкретные, типизированные ошибки (через thiserror), а прикладной/бинарный код агрегирует их в один динамический тип (через anyhow) с добавлением контекста. Смешение этих двух подходов — частая архитектурная ошибка на code review.
| Критерий | thiserror | anyhow |
|---|---|---|
| Где применять | Библиотечные крейты (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(())
}
lib.rs) — используйте thiserror с конкретным enum. Если ошибка возникает и сразу обрабатывается в бинарнике (main.rs, обработчик команды CLI) — anyhow с контекстом читается быстрее и не требует объявления новых типов ради одного места использования.Начиная с определённого размера, монолитный крейт с одним 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 сразу | Нужна координация версий между репо |
| Подходит для | Один продукт/монолит с чёткими слоями | Независимо разворачиваемые сервисы разных команд |
SOLID был сформулирован для языков с наследованием и рантайм-полиморфизмом по умолчанию; в Rust его переосмысливают через систему типов и композицию трейтов, а не иерархии классов.
Вместо того чтобы валидировать «сырые» данные раз за разом на границах разных функций, конвертируйте их в более строгий тип один раз на входе — дальше по коду инвариант гарантирован типом, а не дисциплиной вызывающего.
// Плохо: 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()
}
enum вместо булевых флагов, newtype вместо примитивов, typestate вместо рантайм-проверок состояния, маленькие трейты вместо больших интерфейсов. Компилятор становится соавтором ревью: часть багов, которые в других языках ловятся тестами, здесь не компилируются в принципе.