3 min read
On this page

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.lock at the workspace root (consistent dependency versions)
  • One target/ directory (shared compilation artifacts)
  • A root Cargo.toml that 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 graphserver depends on storage and transport, but domain depends 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 core crate — 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 member Cargo.toml without .workspace = true creates 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.