Introduction
Monorepository (monorepo) is an approach to source code management where multiple projects (libraries, applications, services) are stored in a single repository. In the Rust ecosystem, this approach has received powerful support through the Cargo Workspace mechanism. In this article, we will analyze how to effectively organize a monorepo in Rust, examine the structure, dependency management, and best practices.
Why is a monorepo useful for Rust projects? It allows you to:
- Reuse code between multiple crates (libraries and binaries).
- Simplify versioning and CI/CD.
- Ensure atomicity of changes (one commit can update multiple crates).
- Reduce the complexity of dependency management.
Basics of Cargo Workspace
Cargo Workspace is a built-in Rust mechanism for managing a monorepo. It combines multiple crates under one common top-level Cargo.toml file.
Creating a workspace
To begin, let's create a project directory and a Cargo.toml file in the root:
# Cargo.toml (root)[workspace]members = [ "core", "cli", "web-api", "utils"]
[workspace.dependencies]serde = { version = "1.0", features = ["derive"] }tokio = { version = "1.37", features = ["full"] }Here we have defined four crates: core, cli, web-api, and utils. The key point is [workspace.dependencies]. This allows you to specify common dependencies for all workspace crates. Each crate can reference them without duplicating versions.
Directory structure
A typical monorepo structure in Rust looks like this:
my-monorepo/├── Cargo.toml # root workspace├── Cargo.lock # common lock file├── core/│ ├── Cargo.toml│ └── src/│ └── lib.rs├── cli/│ ├── Cargo.toml│ └── src/│ └── main.rs├── web-api/│ ├── Cargo.toml│ └── src/│ └── main.rs├── utils/│ ├── Cargo.toml│ └── src/│ └── lib.rs└── README.mdEach subdirectory is a separate crate with its own Cargo.toml. Crates can be either libraries (lib.rs) or executable applications (main.rs).
Dependency management and code reuse
Internal dependencies
Crates within a workspace can reference each other via local paths. For example, cli can depend on core and utils:
# cli/Cargo.toml[package]name = "cli"version = "0.1.0"edition = "2021"
[dependencies]core = { path = "../core" }utils = { path = "../utils" }serde = { workspace = true }tokio = { workspace = true }Note the syntax { workspace = true } — it allows you to use common dependencies declared in the root Cargo.toml. This ensures that all crates use the same version of the library.
Example: shared core library
Let's create a simple core library that will contain business logic:
// core/src/lib.rsuse serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct User { pub id: u64, pub name: String, pub email: String,}
pub fn create_user(name: String, email: String) -> User { User { id: rand::random(), name, email, }}
pub fn greet(user: &User) -> String { format!("Hello, {}!", user.name)}Now the cli crate can use this library:
// cli/src/main.rsuse core::{create_user, greet};
fn main() { let user = create