Skip to content
intermediate Phase 9 · Modules, Crates, and Cargo Workspaces

Cargo Workspaces for Multi-Crate Repos

Split a project into cooperating crates that share one lockfile and target directory.

45m
0 problems
Topic Progress 0%

One Root Manifest, One target Directory: Standing Up a Workspace

A workspace is a single Cargo.toml at the repository root that owns a set of member crates — and three shared resources that make the set behave like one project:

repo/
├── Cargo.toml          # workspace root (virtual manifest: no [package] section)
├── Cargo.lock          # ONE lockfile for every member
├── target/             # ONE build directory for every member
├── crates/
│   ├── core/           #   [package] name = "billing-core"  (lib)
│   │   ├── Cargo.toml
│   │   └── src/lib.rs
│   ├── cli/            #   [package] name = "billing-cli"   (bin)
│   │   ├── Cargo.toml
│   │   └── src/main.rs
│   └── macros/         #   [package] name = "billing-macros"
└── tests-integration/  #   optional member for cross-crate tests
# repo/Cargo.toml — virtual manifest
[workspace]
resolver = "2"
members = ["crates/*"]
default-members = ["crates/core", "crates/cli"]

[workspace.package]
version = "0.4.1"
edition = "2021"
license = "MIT"

[workspace.dependencies]
billing-core = { path = "crates/core", version = "0.4.1" }
serde = { version = "1.0", features = ["derive"] }

No [package] means a virtual manifest: the root builds nothing itself, it only coordinates. Run cargo build at the root and every member compiles into the same target/, reusing object files across crates — a member that changed recompiles, its dependents re-link, everything untouched stays cached. That shared directory is the biggest practical win: three separate repos each with their own target/ compile the same dependencies (serde, tokio, ...) three times; a workspace compiles them once.

[workspace.package] and [workspace.dependencies] are the deduplication layers: members inherit with

# crates/cli/Cargo.toml
[package]
name = "billing-cli"
version.workspace = true       # takes root's version
edition.workspace = true

[dependencies]
billing-core.workspace = true  # takes root's path + version + features
tokio = { workspace = true, features = ["macros"] }   # add member-only features

so a version bump for the whole repo is one line at the root, and every member agrees on the serde version by construction. [workspace.dependencies] is also the only place a shared path dependency should live — members then say .workspace = true instead of each hand-writing "../core".

Two constraints the resolver enforces: the lockfile and target/ cannot be shared with anything outside the workspace (a member directory containing its own Cargo.lock or nested workspace gets an error telling you to exclude it), and dependency cycles between members are forbidden exactly as they are within a crate. Those two rules are what keeps the workspace a single resolution unit — which is the point: one graph, one lock, one set of versions for every crate you ship.

Cargo does the bookkeeping for you in the common case: cargo new --lib crates/core executed at the root adds itself to members when it detects a workspace, and cargo add -p billing-cli serde edits the right member's manifest from anywhere in the tree. Two errors tell you the membership rules were broken: "current package believes it's in a workspace when it's not" (a member's manifest is missing or the root does not list it — fix by adding the path to members) and "workspace member ... is excluded" (the path is in both members and exclude; they must be disjoint). The [workspace] exclude list exists for directories that must not build with the tree — generated code, vendored snapshots, or a crate with an incompatible edition pinned for testing.

Interview framing: "what does a workspace buy you?" — shared dependency resolution (one Cargo.lock), shared build cache (one target/), centralized metadata ([workspace.package], [workspace.dependencies]), and per-crate selection (-p). Say "one resolution, one cache, one version policy" and follow with the virtual-manifest detail.

Building One Member: cargo -p, Path Dependencies, and the lib/bin Split

With a workspace in place, the question becomes "which crate am I building?" — and Cargo answers with selection flags:

cargo build                      # default-members
cargo build --workspace          # every member
cargo build -p billing-core      # exactly one member, from anywhere in the tree
cargo test  -p billing-cli       # that member's unit + integration tests
cargo run   -p billing-cli -- --config prod.toml   # run its binary, args after --
cargo bench -p billing-core
cargo check --workspace --all-targets              # fast CI gate over everything

-p (short for --package) takes the package name, not the directory — cargo build -p crates/core fails, cargo build -p billing-core works. Because every member builds into the shared target/, -p billing-cli reuses billing-core's already-compiled rlib rather than rebuilding it; member selection is about what to produce, not about isolating caches. --exclude prunes the other direction (cargo test --workspace --exclude billing-macros), and default-members decides what plain cargo build does when you are standing in the root.

The reason workspaces exist for most teams is this layout:

# crates/core/Cargo.toml — the library
[lib]
name = "billing_core"
path = "src/lib.rs"

# crates/cli/Cargo.toml — the binary
[dependencies]
billing-core = { workspace = true }

The library holds every rule, parser, and pure function; the binary is a thin main that parses arguments, builds a runtime, and calls into the library. That split pays three ways: unit tests run against the library without launching a process (cargo test -p billing-core finishes in milliseconds), other members (and other teams) reuse the logic as a dependency, and integration tests can exercise the binary end-to-end via assert_cmd in a separate member while the core stays fast to test. A tests/ directory inside crates/core covers library behavior; a member like tests-integration covers the composed system.

Where tests live follows the same boundary logic:

crates/core/src/lib.rs        #[cfg(test)] mod tests   — unit tests, private access
crates/core/tests/api.rs      integration tests of the LIBRARY as a user would see it
crates/server/tests/e2e.rs    cross-crate tests that spawn the real binary
crates/test-support/          shared fixtures, published = false, a dev-dependency of others

Each tests/*.rs file inside a member is its own crate linking that member's pub surface, so cargo test -p billing-core runs the unit and integration suites for that crate only — the fast inner loop — while cargo test --workspace walks every member against the same lockfile. cargo test -p billing-cli -- --nocapture shows println! output, and cargo test --workspace -- --test-threads=4 throttles parallel test execution globally; both forms pass arguments after -- through to the test binaries, never to Cargo.

Path dependencies wire the members together:

# crates/cli/Cargo.toml (workspace root present, so this is a workspace member)
[dependencies]
billing-core = { path = "../core", version = "0.4.1" }   # or billing-core.workspace = true

The version alongside path is what makes the crate publishable: cargo publish -p billing-cli rewrites the path requirement into a crates.io requirement of that version at upload time. Without a version, publishing fails with "all dependencies must have a version" — the classic mistake when someone adds a local crate dependency by hand instead of through [workspace.dependencies].

cargo publish --dry-run -p billing-core then --dry-run -p billing-cli (in that order) is the release sequence: publish libraries before binaries that depend on them, one package at a time, from the same lockfile-tested tree.

When to Split: Deciding How Many Crates Your Repo Should Have

The workspace gives you the machinery; the design question is where the crate boundaries go. Splitting too early produces a repo of five-line crates and manifest churn; splitting too late produces a 12,000-line crate where cargo check takes 40 seconds and every internal detail is effectively public. Four signals say split now:

  1. Compile time. If editing one file forces a full recompile of everything, a boundary would let unchanged crates stay cached in the shared target/. Crates recompile as units, so the win comes from isolating the file you edit most — usually the thin binary shell versus everything else.
  2. Direction of dependency. Library before binary, core before adapters, traits before implementations. The dependency arrows must stay acyclic: if module A needs B and B needs A, promote the shared piece into a third crate below both — the compiler refuses member cycles anyway, so the structure is enforced.
  3. Who else consumes it. A parser another team wants as a dependency must be its own crate with its own version and pub surface; a helper only you use should stay a module.
  4. Feature and platform gating. Code that only compiles on Windows (or behind heavy optional deps) is cheaper to isolate as a member than to sprinkle #[cfg] through a monolith.

One more thing a root manifest can centralize, which most teams discover only after enforcing it: lints.

# root Cargo.toml
[workspace.lints.rust]
unsafe_code = "forbid"
unused = "warn"

[workspace.lints.clippy]
unwrap_used = "warn"

# member Cargo.toml
[lints]
workspace = true

One policy, every crate, no per-member drift — and cargo clippy --workspace -- -D warnings in CI reads the same rules the developers see locally. Combined with a shared target/ that CI caches between runs (the directory is keyed by toolchain and inputs, so restoring it cuts cold builds dramatically), the workspace becomes the unit your tooling operates on: one cargo check --workspace --all-targets gate, one lint policy, one lockfile diff per dependency bump.

What the split buys in practice, concretely: cargo test -p billing-core compiles and runs only the core and its dev-dependencies (seconds, no runtime, no binary), while cargo test --workspace in CI covers everything with one command and one lockfile. Because the lockfile is shared, you never face "works at 1.4, broken at 1.6" between sibling crates — there is only ever one serde in the build.

Watch the boundary costs too: every pub item at the seam becomes API you must maintain (a crate boundary is a harder boundary than a module, but it forces pub to cross it), cross-crate refactors touch several manifests, and a git diff that changes a public type now needs a coordinated version bump across members. The honest rule: split along lines you already refactor along, and keep the count small enough that a newcomer can name every crate from memory.

A layout that satisfies most services: core (pure logic, no I/O), cli or server (binary shell), macros (procedural macros must be their own crate anyway), and optionally test-support for shared fixtures as a dev-dependency. From here the roadmap turns to gating what each of those crates compiles at all: Feature Flags and Optional Dependencies.

Quiz

1. What are the three things a Cargo workspace shares across all member crates?

Question 1 options

2. `cargo build -p crates/core` fails. What is the correct invocation, and why?

Question 2 options

3. `cargo publish -p billing-cli` fails with "all dependencies must have a version". What is wrong?

Question 3 options

4. Two members need each other's types to compile. What does Cargo do, and what is the structural fix?

Question 4 options

Flashcards

Question

What is a virtual manifest, and what does the root `[workspace]` section provide?

Answer

A root `Cargo.toml` with `[workspace]` but no `[package]` — it builds nothing itself, only coordinates. It lists `members`/`default-members`, sets `resolver = "2"`, and hosts `[workspace.package]` (shared version/edition/license) and `[workspace.dependencies]` (shared version + path specs).

Question

How does a member inherit workspace values?

Answer

`version.workspace = true`, `edition.workspace = true` under `[package]`, and `dep.workspace = true` under `[dependencies]` (optionally adding member-only features). One root edit then updates every member consistently.

Question

The key `cargo` selection flags in a workspace.

Answer

`cargo build` → `default-members`; `--workspace` → all members; `-p <name>` → one member (package *name*, not path); `--exclude <name>` → prune from `--workspace`; `cargo run -p cli -- args` → run that binary with its arguments.

Question

Why split a project into a library crate and a binary crate?

Answer

The lib holds testable logic reachable by `cargo test -p core` (fast, no process spawn) and reusable by other members; the bin is a thin `main` doing arg parsing, runtime setup, and calls. Integration tests can then target either layer.

Question

When should you split a monorepo into another crate — and what does it cost?

Answer

Split when compile time suffers, dependency arrows want to stay one-way, another consumer needs the code, or optional/platform code should be isolated. Cost: every crossing item needs `pub`, cross-crate refactors touch several manifests, and public seams need coordinated version bumps. Keep the count small enough to memorize.

Revision Notes

Key Takeaways

  • 1. A virtual root manifest coordinates without building: `members` defines the set, `default-members` defines what plain `cargo build` does, and the shared `target/` means each dependency compiles once for the whole repo
  • 2. One `Cargo.lock` and one resolution guarantee every member agrees on dependency versions — the direct answer to cross-crate version skew
  • 3. `[workspace.package]` and `[workspace.dependencies]` centralize metadata and paths; members inherit with `.workspace = true` so a bump is a one-line root edit
  • 4. `-p` selects a member by package *name*; `cargo run -p cli -- args` runs its binary; path dependencies need a `version` alongside to remain publishable
  • 5. Crate boundaries should follow compile-time pain, dependency direction, real consumers, and feature isolation — and cycles force the shared piece down into its own crate

Interview Tips

  • • "\"What does a workspace give you?\" — one lockfile, one target directory, one resolution, centralized metadata, and per-member selection; mention the virtual manifest and `default-members` as the details that show you have used one"
  • • "\"Why is `-p crates/core` wrong?\" — `-p` takes the package name from `[package]`; paths are for `cargo build --manifest-path` or for running inside the member directory"
  • • "\"How do you release several crates in order?\" — publish dependencies first with `cargo publish -p <lib> --dry-run`, then dependents; note the `path` + `version` rewrite that makes it possible"
  • • "\"Two modules keep cyclically importing each other — now what?\" — promote the shared surface into a lower crate; crates enforce acyclicity, which is exactly the discipline you wanted at module level"

Cheat Sheet

Cargo Workspaces

Root manifest (virtual — no [package]):

[workspace]
resolver = "2"
members = ["crates/*"]
default-members = ["crates/core", "crates/cli"]

[workspace.package]
version = "0.4.1"
edition = "2021"

[workspace.dependencies]
billing-core = { path = "crates/core", version = "0.4.1" }
serde = { version = "1.0", features = ["derive"] }

Member inheritance:

[package]
name = "billing-cli"
version.workspace = true

[dependencies]
billing-core.workspace = true
tokio = { workspace = true, features = ["macros"] }

Selection:

cargo build                     # default-members
cargo build --workspace         # everything
cargo build -p billing-core     # one member (package name, not path)
cargo test -p billing-cli
cargo run -p billing-cli -- --flag value
cargo publish --dry-run -p billing-core   # libs before bins that depend on them

Shared by construction: one Cargo.lock, one target/, one resolution (no version skew between members). Forbidden: member dependency cycles, a member keeping its own lockfile.

Boundary rule: split along lines you refactor along — lib vs bin, pure core vs I/O adapters, proc-macros (always their own crate), shared test fixtures as a dev-only member.

Continue Learning