Workspaces
As Rust projects grow, a single crate becomes unwieldy. Cargo workspaces let you split a project into multiple crates that share a single Cargo.lock, a common build directory, and coordinated dependency versions. This is the monorepo pattern for Rust.
What Is a Workspace?
A workspace is a set of crates managed together. They share:
- One
Cargo.lockat the workspace root (consistent dependency versions) - One
target/directory (shared compilation artifacts) - A root
Cargo.tomlthat lists the members
Each member is still an independent crate with its own Cargo.toml, its own src/, and its own tests.
Setting Up a Workspace
Create a root Cargo.toml with a [workspace] section:
my-app/
Cargo.toml # workspace root
Cargo.lock
crates/
core/
Cargo.toml
src/lib.rs
api/
Cargo.toml
src/main.rs
cli/
Cargo.toml
src/main.rs
# Root Cargo.toml
[workspace]
members = [
"crates/core",
"crates/api",
"crates/cli",
]
resolver = "2"
Each member has its own Cargo.toml:
# crates/core/Cargo.toml
[package]
name = "my-app-core"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
# crates/api/Cargo.toml
[package]
name = "my-app-api"
version = "0.1.0"
edition = "2021"
[dependencies]
my-app-core = { path = "../core" }
axum = "0.7"
tokio = { version = "1", features = ["full"] }
Members reference each other with path dependencies. Cargo resolves these within the workspace.
Shared Dependencies
When multiple crates use the same dependency, declare it once in the workspace root with [workspace.dependencies]:
# Root Cargo.toml
[workspace]
members = ["crates/core", "crates/api", "crates/cli"]
resolver = "2"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
tracing = "0.1"
Then reference it in member crates:
# crates/api/Cargo.toml
[dependencies]
my-app-core = { path = "../core" }
serde.workspace = true
tokio.workspace = true
anyhow.workspace = true
This guarantees all crates use the same version of each dependency. No more version drift between workspace members.
Workspace Commands
Cargo commands at the workspace root operate on all members:
cargo build # builds all workspace members
cargo test # runs tests across all members
cargo build -p my-app-api # builds only the api crate
cargo test -p my-app-core # tests only the core crate
cargo run -p my-app-cli # runs the cli binary
This makes CI simple: one cargo test runs everything.
When to Split into Multiple Crates
Splitting too early adds overhead. Splitting too late means painful refactoring. Here are concrete signals:
Compile time
Rust recompiles an entire crate when any file in it changes. If your crate takes 30 seconds to compile and you changed one line in a utility function, splitting that utility into its own crate means only the small crate recompiles:
Before: one crate, 30s rebuild on any change
After: core (2s) + api (15s) + cli (10s)
Change in core: 2s + 15s + 10s = 27s
Change in api: 15s only
Change in cli: 10s only
API boundaries
If part of your code is a reusable library (config parsing, domain types, protocol handling), it should be its own crate. This forces a clean API boundary that documentation and other teams can rely on:
// crates/core/src/lib.rs
pub mod user;
pub mod order;
pub mod error;
// crates/api/src/main.rs
use my_app_core::user::User;
use my_app_core::order::Order;
Reuse across binaries
If your project has multiple binaries (API server, CLI tool, background worker), shared logic belongs in a library crate:
crates/
core/ # shared domain logic
api/ # HTTP server binary
cli/ # command-line tool binary
worker/ # background job binary
All three binaries depend on core but are independent from each other.
The Monorepo Pattern in Rust
Many production Rust projects use a monorepo workspace. The pattern looks like this:
project/
Cargo.toml
crates/
domain/ # business types, validation, no IO
storage/ # database layer, depends on domain
transport/ # HTTP/gRPC layer, depends on domain
server/ # binary, wires everything together
client-sdk/ # published crate for consumers
tools/
migration/ # database migration tool
load-test/ # load testing binary
# Root Cargo.toml
[workspace]
members = [
"crates/domain",
"crates/storage",
"crates/transport",
"crates/server",
"crates/client-sdk",
"tools/migration",
"tools/load-test",
]
resolver = "2"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }
Benefits of this structure:
- Single lockfile — all crates use compatible dependency versions
- Incremental builds — changing
domain/only recompiles what depends on it - Shared CI — one pipeline builds, tests, and lints everything
- Clear dependency graph —
serverdepends onstorageandtransport, butdomaindepends on nothing internal
Example: A Workspace in Practice
// crates/domain/src/lib.rs
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Task {
pub id: u64,
pub title: String,
pub done: bool,
}
impl Task {
pub fn new(id: u64, title: String) -> Self {
Task { id, title, done: false }
}
pub fn complete(&mut self) {
self.done = true;
}
}
// crates/server/src/main.rs
use my_app_domain::Task;
fn main() {
let mut task = Task::new(1, "Set up workspace".into());
println!("Created: {:?}", task);
task.complete();
println!("Completed: {:?}", task);
}
Created: Task { id: 1, title: "Set up workspace", done: false }
Completed: Task { id: 1, title: "Set up workspace", done: true }
Common Pitfalls
- Premature splitting — do not create a workspace for a 500-line project. Start with one crate; split when compile times or code organization demand it.
- Circular dependencies — crate A cannot depend on crate B if B depends on A. Extract shared types into a third crate.
- Version drift in path dependencies — path dependencies ignore the version field. If you later publish these crates, the version field starts to matter.
- Forgetting
resolver = "2"— Rust 2021 edition requires resolver version 2. Without it, feature unification behaves differently and you may get surprising dependency conflicts. - Giant
corecrate — if your shared crate grows to 20,000 lines, it is time to split it further. The goal is fast incremental compilation per crate. - Missing
workspace = true— adding a dependency in the memberCargo.tomlwithout.workspace = truecreates a separate version from the workspace-level one.
Key Takeaways
- Workspaces let multiple crates share a lockfile, build directory, and dependency versions.
- Use
[workspace.dependencies]to keep dependency versions consistent across members. - Split into multiple crates when compile times hurt, API boundaries emerge, or multiple binaries share logic.
- The monorepo workspace pattern is standard for production Rust projects.
- Start with one crate. Split when the pain is real, not theoretical.