rust-workspaces-gestion-crates

Workspaces in Rust: organizing projects with crates

  • 4 min

A workspace in Cargo is a folder with several packages and shared configuration.

So far, your projects have followed the standard single-package (crate) scheme: a Cargo.toml that defines a binary or a library.

But as software grows, it’s common to want to split it into independent logical components:

  • A Core (business logic).
  • An API (that uses the core).
  • A CLI (command-line tool that also uses the core).
  • A Utility Library.

If you did this with separate projects, you would have to publish your libraries to crates.io or use complicated relative paths, and each project would compile its dependencies separately (wasting disk space and CPU).

Workspaces solve this. They allow multiple packages within the same project to share the same target directory and the same Cargo.lock file.

Creating a workspace

A Workspace is simply a folder with a special Cargo.toml file at the root. This file does not define a package, but rather lists its “members”.

Let’s create a fictional project called mi_app that will have two parts:

  1. motor: A library with the logic (calculator).
  2. cli: A console program that uses the motor.

Folder structure

First, we create the root folder and the configuration file.

mkdir mi_app
cd mi_app
touch Cargo.toml
Copied!

The workspace Cargo.toml

Open the root Cargo.toml and write this. Note that it does not have a [package] section, but rather a [workspace] section.

[workspace]
resolver = "3"

members = [
    "motor",
    "cli",
]
Copied!

Creating the members

Now, inside the mi_app folder, we generate the two packages using normal Cargo:

# Create the library 'motor'
cargo new motor --lib

# Create the binary 'cli'
cargo new cli
Copied!

Your file structure should look like this:

mi_app/ ├── Cargo.toml (The workspace one) ├── Cargo.lock (Shared and unique) ├── target/ (Shared) ├── motor/ │ ├── Cargo.toml │ └── src/lib.rs └── cli/ ├── Cargo.toml └── src/main.rs

Connecting the packages

Right now, motor and cli are neighbors, but cli doesn’t know that motor exists. We need to declare the dependency.

Open cli/Cargo.toml and add the dependency using a relative path:

[package]
name = "cli"
version = "0.1.0"
edition = "2024"

[dependencies]
# Local dependency:
motor = { path = "../motor" }
Copied!

This tells Cargo: “Don’t look for motor on the internet, look for it in the folder next door”.

Using the library

Now we can use the code from motor inside cli.

In motor/src/lib.rs:

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}
Copied!

In cli/src/main.rs:

fn main() {
    let result = motor::add(5, 10);
    println!("The result from the motor is: {}", result);
}
Copied!

Advantages of a workspace

Why bother doing this instead of having loose folders?

  1. Shared Compilation: If both motor and cli use the external library serde, the workspace will download and compile it only once. Without workspaces, each project would compile it separately, duplicating time and disk space.
  2. Single Cargo.lock: Cargo resolves the workspace’s dependencies together and records the result in a single file. It may still include multiple versions of the same library if requirements are incompatible, but all members share the same locked resolution.
  3. Unified Tests: You can run cargo test from the root and it will test all packages in the workspace at once.

Commands in a workspace

When you are at the root of the workspace, Cargo commands change their behavior slightly:

  • cargo build: Compiles all members of the workspace.
  • cargo run: If Cargo cannot unambiguously choose a binary, it will return an error, and we can select the package with -p.
cargo run -p cli
Copied!
  • cargo test -p motor: Runs the tests only for the motor library.

Shared external dependencies

In modern Rust, you can even define the versions of external dependencies in the root Cargo.toml to avoid repeating them in each child.

In mi_app/Cargo.toml (Root):

[workspace.dependencies]
serde = "1.0"
tokio = "1"
Copied!

In cli/Cargo.toml (Child):

[dependencies]
serde = { workspace = true } # Inherits the version from the parent
Copied!

This is fantastic for maintaining consistency in large projects.