Cargo workspaces saved our monorepo
We were about two weeks away from splitting our Rust monorepo into three separate repos when a colleague pointed out that Cargo 1.64 had landed workspace inheritance. That pushed our split decision back by a quarter, and honestly I don’t think we’re going to need to do it now.
The problem before was real. We had eleven crates in the workspace — three binaries and eight library crates. Each Cargo.toml had its own dependency declarations. tokio was listed in six of them, with subtly different feature flags. serde was listed in nine, all with features = ["derive"] but two of them accidentally missing the rc feature which we actually needed. Whenever we bumped a dependency, we had to bump it in eleven places. Anyone who touched Rust during this period will recognize what follows: a matrix of subtly mismatched versions.
When I started, my sanity check was to grep:
$ grep -r '^tokio' */Cargo.toml
api-server/Cargo.toml:tokio = { version = "1.28", features = ["full"] }
worker/Cargo.toml:tokio = { version = "1.27", features = ["rt-multi-thread", "macros"] }
common/Cargo.toml:tokio = { version = "1.28", features = ["sync", "time"] }
# ... and so on for eight more files
This didn’t cause bugs often, but when it did, the bugs were confusing. Two of our services ran with different Tokio versions for about two weeks because one PR bumped the version and the other eight weren’t touched. It didn’t break anything obvious — Tokio is pretty careful about compatibility — but it did mean our binary sizes diverged and I had to explain to a newer engineer why the lockfile suddenly had tokio v1.28.2 AND tokio v1.27.0. Fun.
With workspace inheritance, you do this in your top-level Cargo.toml:
[workspace]
members = ["api-server", "worker", "common", "..."]
[workspace.package]
version = "0.14.0"
edition = "2021"
rust-version = "1.70"
authors = ["The Thing Team"]
[workspace.dependencies]
tokio = { version = "1.28", features = ["full"] }
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json"] }
tracing = "0.1"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres"] }
anyhow = "1.0"
thiserror = "1.0"
And in each member crate’s Cargo.toml, you inherit:
[package]
name = "api-server"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
tokio.workspace = true
serde.workspace = true
sqlx.workspace = true
# ... only the things this crate actually uses
One source of truth for the version, one source of truth for features. You can still override per-crate if you need to (e.g. for a CLI crate that doesn’t need all the tokio features), but the default is that everyone agrees.
The feature flag story is where I got bitten, and it’s worth calling out. When two crates in the same build graph both depend on, say, tokio, Cargo unions the feature sets. That means if one crate pulls in tokio with features = ["sync"] and another pulls in tokio with features = ["rt-multi-thread"], every crate that sees tokio sees the full union. This sounds fine — more features, no problem — but it also means that if your library crate declares default-features = false and someone somewhere else in the workspace pulls in tokio with default features, your library now sees the default features, and your no_std-ish dreams are gone.
Workspace inheritance doesn’t fix this, but having a single workspace.dependencies declaration makes it much easier to spot. There’s one place to look at, and you can see the full feature set in one glance.
One more thing worth mentioning: dev-dependencies can also be inherited:
[workspace.dependencies]
# shared with the main crates
tokio = { version = "1.28", features = ["full"] }
# dev-only
tokio-test = "0.4"
proptest = "1.0"
[dev-dependencies]
tokio-test.workspace = true
proptest.workspace = true
We had four crates running the same version of proptest and I didn’t even notice they were in sync by accident until I deleted the explicit declarations in favor of workspace inheritance.
A couple of tips for anyone doing this migration:
- Do it one crate at a time.
cargo check --workspaceafter each move. The compilation graph will tell you immediately if a feature is missing. - Use
cargo tree -i tokioto see every path through the dep graph that pulls in a crate. This caught us with a transitive where one of our libs was pullingtokiofrom a dependency’s dev-deps, which is a thing I didn’t know could happen. - Don’t put the version in both places. Workspace inheritance replaces the version field entirely; if you write
version = "1.28"ANDworkspace = true, Cargo will yell at you. - Keep the workspace
Cargo.tomlboring. The more conditionals and per-crate overrides you add, the more it looks like the thing you were trying to escape.
I wrote about Tokio vs std sync in a real service a while back, which touched on some of the same crate-sprawl issues. Workspace inheritance is one of those quality-of-life features that doesn’t sound revolutionary but genuinely changed how we reason about our own codebase.