Cargo workspaces для масштабирования: когда монорепо становится преимуществом
Разберём, как правильно разнести библиотеки/сервисы, управлять зависимостями и избежать дублирования кода в больших Rust-проектах.
Содержание
Cargo workspaces для масштабирования: когда монорепо становится преимуществом
Когда Rust-проекты начинают расти, почти всегда приходит момент, когда «просто добавить ещё пару библиотек» превращается в задачу с архитектурными последствиями. Команды сталкиваются с типичными симптомами: дублирование кода между сервисами, несогласованные версии зависимостей, сложность обновлений, разный стиль API у библиотек, а также хаотичный процесс релизов. Часто виноват не компилятор и не менеджер пакетов, а структура репозитория и способ управления зависимостями.
Cargo workspaces — официальный механизм для работы с множеством пакетов (crates) в одном репозитории. В разговорах его нередко воспринимают как синоним монорепо, но в реальности речь о другом: о согласованной модели сборки, тестирования и версионирования набора взаимосвязанных crates. Правильно организованный workspace может стать преимуществом: сократить трение при разработке, облегчить рефакторинги и сделать обновление зависимостей предсказуемым.
Ниже разберём, как разносить библиотеки и сервисы, как управлять зависимостями, как избежать дублирования и каких ловушек стоит избегать. Примеры будут на уровне реальных конфигураций Cargo.
Что меняется с ростом Rust-проекта
Рост обычно начинается с «логического» разрастания: один сервис обрастает модулями, появляется необходимость вынести общие куски в библиотеку, затем появляется вторая команда/сервис, которой нужна та же логика (например, обёртки над API, модели данных, клиенты, типы ошибок, утилиты сериализации). На этом этапе монорепозиторий часто кажется удобным, но без правильной структуры он быстро превращается в источник новых проблем.
Типичные проблемы без workspaces
-
Дублирование кода между репозиториями Если общий код вынесен «вручную» (copy/paste) или через публикацию crate на каждом этапе, то команда неизбежно теряет синхронность изменений.
-
Несогласованность зависимостей Даже если у двух сервисов одинаковая библиотека, они могут тянуть разные версии «транзитивных» зависимостей. В Rust это иногда проявляется не как runtime-конфликт, а как несовместимости API (или в худшем случае — как эффект «работает локально, ломается в CI»).
-
Дорогое обновление Обновить версию общей библиотеки во всех сервисах становится бюрократически сложно: приходится вручную пройтись по конфигам, собрать, проверить, разобраться в регрессиях.
-
Нестабильный CI При большом количестве репозиториев или разрозненных проектов CI тратит время на повторяющиеся сборки, а тестирование общей логики превращается в «пачку отдельных шагов», не отражающих реальную связность системы.
Эти симптомы не уникальны для Rust, но Cargo-ecosystem позволяет решить их системно — и именно workspaces дают точку опоры.
Архитектура: библиотеки и сервисы в одном workspace
Ключевое решение: внутри одного workspace держать набор crates, которые развиваются совместно. В простом варианте это сервисы + общие библиотеки, на которые сервисы опираются через path-зависимости (внутри workspace).
Пример структуры репозитория
Рассмотрим типичный layout:
repo/
Cargo.toml
crates/
common/
Cargo.toml
src/
domain/
Cargo.toml
src/
services/
api/
Cargo.toml
src/
worker/
Cargo.toml
src/
Такой подход помогает разделить ответственность: crates/ — библиотеки (повторно используемые доменные сущности, клиенты, утилиты), services/ — точки входа (binaries).
Корневой Cargo.toml workspace
Корневой файл определяет состав пакетов:
[workspace]
members = [
"crates/common",
"crates/domain",
"services/api",
"services/worker",
]
resolver = "2"
resolver = "2" — важная деталь. Он включает новую модель разрешения зависимостей, которая лучше работает в больших наборах crates, снижая неожиданные эффекты, возникающие из-за различных требований зависимостей.
Как “правильно разнести” код: правила декомпозиции
Когда монорепо становится преимуществом, это обычно проявляется в качестве декомпозиции. Но декомпозиция — не столько про папки, сколько про границы ответственности и зависимости.
1) Разделяйте доменные и инфраструктурные библиотеки
Часто общие crates делятся на две категории:
- domain / model: типы, правила, чистая логика без конкретики инфраструктуры (HTTP, БД, очереди).
- infra / adapter: интеграции — клиенты API, репозитории, адаптеры к БД, сериализация специфичных форматов.
С практической точки зрения цель — не позволить сервисам и доменным модулям «тянуть» инфраструктуру слишком глубоко. Иначе вы получите плотную взаимосвязь и «невозможность вынести одну часть».
2) Не делайте один общий “god crate”
Одна из распространённых ловушек: собрать всё в crates/common, и он начинает разрастаться до уровня «всё зависит от всего». Плюсы исчезают: обновление общей библиотеки приводит к перекомпиляции всей экосистемы, а смена одной части может затронуть десятки crates.
Лучше создавать более узкие библиотеки:
crates/domain(модели и доменная логика),crates/common/errors(единые ошибки/типизация),crates/common/serialization(инварианты сериализации),crates/api-client(клиенты к внешним сервисам).
3) Управляйте направлением зависимостей
Стандартное правило: нижние уровни (доменные библиотеки) не должны зависеть от верхних (сервисов). Сервис может зависеть от домена и инфраструктуры, но не наоборот.
Если вы обнаружили, что доменная библиотека тянет actix-web или tokio-postgres — вероятно, границы размыты. В Rust это особенно болезненно из-за того, что вы расширяете граф зависимостей и усложняете компиляцию.
Паттерн зависимостей: workspace вместо публикации на каждом шаге
Один из сильных эффектов workspaces — локальная связность. Внутри workspace вы можете ссылаться на crates через path и развивать изменения совместно.
Например, services/api использует crates/domain:
[dependencies]
domain = { path = "../../crates/domain" }
common = { path = "../../crates/common" }
Внешняя публикация (на crates.io) не требуется для внутреннего развития: это ускоряет цикл «изменил — проверил — поправил».
Когда же требуется публикация, вы можете:
- использовать separate versioning (или единый процесс релиза),
- публиковать лишь те crates, которые реально предназначены внешним пользователям,
- или оставить внутренние crates непубликуемыми, если это соответствует бизнесу.
Единая стратегия версий зависимостей
По мере роста проекта количество зависимостей растёт, и начинается «война требований». Например, два сервиса могут по-разному требовать serde или другие библиотеки, и Cargo будет пытаться разрешать версии — иногда создавая несколько вариантов одного и того же пакета в графе.
Где Cargo действительно помогает
- resolver = "2" снижает риск странных эффектов.
- workspace dependency management позволяет задать версии один раз и использовать их последовательно.
- dependency inheritance (через
[workspace.dependencies]) уменьшает дрейф конфигураций.
Пример: задаём общие зависимости на уровне workspace
В корневом Cargo.toml:
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
anyhow = "1"
thiserror = "1"
Далее в crate:
[dependencies]
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
anyhow.workspace = true
thiserror.workspace = true
Это даёт два эффекта:
- Версии не расходятся из-за человеческого фактора.
- Если нужно обновить
tokio, правка делается один раз на уровне workspace.
Типичные ошибки
-
Смешивание workspace и явных версий
Если где-то указатьtokio = "1.37"напрямую, а где-тоtokio.workspace = true, можно получить разнородное поведение при разрешении версий и всплывающие сложности при обновлении. -
Слишком широкие feature flags
У большой команды часто появляется привычка добавлять features «на всякий случай». В результате сервисы тянут больше зависимостей, чем нужно, и компиляции становятся медленнее. Особенно это заметно, когда включаются heavy features (например, TLS/crypto опции или дополнительные компоненты).
Тонкости feature flags: избегайте “feature hell”
Rust features — гибкий механизм, но в больших workspaces они часто приводят к эффекту: «на одном сервисе включено X, на другом — нет», а общий crate вдруг начинает работать иначе.
Практика: документируйте и минимизируйте features в shared crates
В библиотеке объявляйте features осмысленно и старайтесь не превращать их в «пульт управления всем». Например:
# crates/domain/Cargo.toml
[features]
default = []
serde_support = ["serde", "serde_json"]
А в коде:
- включайте
#[cfg(feature = "serde_support")]для специфичных реализаций, - оставляйте core-типы стабильными без условностей.
Практика: единые features на уровне workspace
Если библиотека поддерживает serde_support, то задайте это согласованно для всех сервисов:
# services/api/Cargo.toml
[dependencies]
domain = { path = "../../crates/domain", features = ["serde_support"] }
Или, если у вас несколько сервисов, задайте одинаковые features через политику (например, Cargo.toml для сервисных входов). Цель — чтобы «модели» не отличались по сериализации между сервисами.
Стратегия компоновки: общие типы ошибок и API
Один из наиболее практичных способов избежать дублирования — выделить общие контракты: модели ошибок, доменные события, единые DTO/форматы, клиенты и адаптеры.
Ошибки: от домена к инфраструктуре
Рекомендуемый подход — разделять:
- доменные ошибки (семантика),
- инфраструктурные ошибки (источники: БД, сетевые проблемы),
- и маппинг между ними.
Если вы всё смешаете в одну схему ошибок в common, то сервисы будут тянуть инфраструктурные зависимости даже там, где они не нужны.
Например:
crates/domain— определяет enum доменных ошибок (или типы, которые можно конвертировать),crates/common/errors— предоставляет единые обёртки/утилиты,- сервисы — маппят доменные ошибки в HTTP/графовую схему.
API-контракты и сериализация
Если сервисы используют одни и те же JSON-контракты, лучше хранить:
- типы запросов/ответов,
- правила сериализации (с
serde), - и валидационные инварианты
в отдельном crate (условно crates/api-contracts). Тогда изменение контракта — это правка в одном месте, а сервисы получают одинаковые типы. Да, иногда это приводит к необходимости регулярной публикации внутри репозитория, но workspaces решает эту проблему локальным развитием.
Как избежать дублирования кода при росте команд
Дублирование обычно появляется не из-за отсутствия «лучшего» паттерна, а из-за разрыва в коммуникации и архитектурной дисциплине. workspaces помогают, но не отменяют необходимость правил.
Три конкретных механизма против дублирования
-
“Переиспользуемость по умолчанию” через проектирование crates Если у вас есть доменные типы — они живут в библиотеке, а сервисы используют их через dependency, а не копируют.
-
Снижение стоимости изменения общих частей Когда общий код находится в том же workspace, вы меняете его и одновременно прогоняете тесты всех зависимых crates. Это превращает рефакторинг из страшного события в регулярную практику.
-
CI как механизм архитектурного контроля Не просто “cargo test” по одному пакету, а проверка связностей: тесты библиотек + тесты сервисов, зависящих от них. При грамотном setup CI становится инструментом борьбы с дрейфом.
Параллельная сборка и “как не сделать всё медленно”
Workspace неизбежно добавляет масштаб: если сделать много пакетов и не оптимизировать, можно получить долгую компиляцию. Но проблемы часто решаются управляемыми настройками.
Практика: тестируйте по скоупу
Вместо запуска всего репозитория можно собирать конкретные пакеты:
cargo test -p domain
cargo test -p api
А для всего workspace:
cargo test --workspace
Практика: учитывайте зависимость по dev-dependencies
Если shared crate имеет heavy dev-dependencies (например, большие mocking/fuzzing библиотеки), они будут влиять на компиляцию в тестовой фазе. Часто имеет смысл:
- выносить отдельные тестовые утилиты в отдельный crate,
- или использовать features для тестовой инфраструктуры.
Конфигурация workspace для реального проекта
Набор пакетов обычно включает:
- общий формат кода,
- единые политики lint/format,
- использование workspace для common tooling.
Хотя это выходит за рамки Cargo как такового, на практике именно единая конфигурация снижает «шум» между сервисами.
Компоненты, которые стоит стандартизировать
- Rust edition (обычно одинаковая),
- версии rustfmt/clippy (или хотя бы конфигурация),
- минимальные требования к компилятору,
- единая стратегия по
deny(warnings)(где уместно).
В Cargo это частично отражается через package.metadata или через единый набор настроек в CI, но важно понимать: workspace — это не только про зависимость, но и про процесс.
Когда монорепо может быть проблемой (и как это признать)
Workspaces — не универсальная кнопка. Монорепозиторий может стать проблемой, если:
-
Crates с разной жизненной циклом развиваются слишком вместе Например, один сервис нужен для внешнего интегратора и живёт отдельно по релизам, а второй — экспериментальный. Если держать их в одном массиве releases и CI прогоняет всё, команда начнёт страдать от лишней работы.
-
Непрерывная компиляция слишком дорогая Если компиляция длинная, а CI гоняет тесты всего workspace на каждый push — это быстро превратится в “slow feedback loop”.
-
Слишком много пакетов без дисциплины Добавление “ещё одного shared crate” без чёткой границы и назначений приводит к расползанию архитектуры.
Как снизить риски
- Разделяйте workspace на логические группы, если проект реально разнороден (иногда лучше несколько workspaces по доменам).
- Делайте CI умным: шёл push — запускались затронутые пакеты.
- Держите границы crates жёсткими: меньше “общего всего”, больше “общего для конкретной ответственности”.
Выбор: workspace vs “публиковать на crates.io”
Иногда команда задаётся вопросом: зачем держать shared crates внутри репозитория, если можно публиковать на crates.io и подключать по версии?
Ответ зависит от задачи:
- Для внутреннего развития и быстрой итерации workspaces дают лучший цикл разработки: нет лишнего шага релиза, изменения видны сразу.
- Для продуктов/публичных библиотек публикация может быть уместна, но часто удобнее начинать с workspace и публиковать то, что реально стабилизировалось.
Практический компромисс: сначала развиваете внутри workspace, а когда crate достигает стабильности API — делаете процесс публикации. В Rust это нормальная эволюция, а не попытка угадать будущее заранее.
Технологический чеклист: как разложить проект без дублирования
Вот компактный чеклист, который помогает избежать типичных архитектурных провалов.
Структура
- Есть отдельные crates для domain/model и для infra/adapters.
- Нет “god crate”, который включает всё подряд.
- Направление зависимостей идёт “снизу вверх”: домен не тянет сервисы.
Зависимости
-
resolver = "2"включен на уровне workspace. - В
workspace.dependenciesвынесены версии общих библиотек. - features в shared crates минимальны и осмысленны.
Процесс
- CI проверяет как минимум зависимости: тесты библиотек и сервисов, которые их используют.
- Есть возможность тестировать/собирать по конкретным пакетам (
-p), а не только--workspace.
Контроль дублирования
- Контракты (типы запросов/ответов, события) вынесены в shared crate.
- Ошибки и маппинги реализованы по слоям, а не одной общей “магией”.
Углубление в Cargo: с чего начать, если нужно систематизировать знания
Если вам важно не только “
Комментарии
Пока нет комментариев