Rust Workspace and Project Topology
Authority: Cargo Book — Workspaces, The Book ch7 and ch14-03, Rust Reference ch7, RFC 1525, matklad — Large Rust Workspaces.
This skill decides how many crates a project should have and how they relate. Its companion rust-module-layout decides what lives inside one crate's src/.
Capability Boundaries
✅ Strengths
- Single-crate vs multi-crate workspace decision (decision tree with five concrete triggers)
- Project-driven workspace layouts: small root-flat, hybrid/domain-grouped, contained
crates/, nested, and root-package - Virtual manifest vs root package — trade-offs and migration
- Workspace-level shared configuration:
[workspace.package],[workspace.dependencies],[workspace.lints] - Dependency direction DAGs — types → core → sdk → server → binary
- Diagnosing and refactoring the "mixed root package" anti-pattern
- Crate naming, publishing, and version coordination across members
⚠️ Prerequisites
- Rust ownership and basic module syntax — see
rust-stable - In-crate module layout (lib.rs facade, mod declarations, visibility) — see
rust-module-layout
❌ Out of Scope
- Cargo.toml
[dependencies]syntax and feature resolution → userust-cargo-build - In-crate src/ directory layout → use
rust-module-layout - Rust syntax fundamentals → use
rust-stable - Testing organization → use
rust-testing
When to Use
- "Should I split this into a workspace or keep it one crate?"
- "How do I configure a Cargo workspace?"
- "Refactor my rbatis-style mixed root package"
- "Why does cargo only build one crate at the root?"
- "How do I share dependencies across workspace members?"
- "Is my dependency direction correct?"
Data Privacy
This skill does not collect, store, or transmit any user data.
Foundations — Packages, Crates, Modules
In-crate module layout depth (mod files, visibility, re-exports) is covered by the companion
rust-module-layoutskill. This section covers only the parts that affect project-level decisions.
Package vs crate vs workspace
| Term | Meaning |
|---|---|
| Package | One Cargo.toml and the source it points at; what you publish to crates.io |
| Crate | A compilation unit — either a library (src/lib.rs) or a binary (src/main.rs, src/bin/*.rs). A package can contain multiple crates (1 lib + N binaries). |
| Workspace | A collection of packages sharing one Cargo.lock and target/. |
| Module | A nameable scope inside a crate — declared with mod foo;, resolved to src/foo.rs or src/foo/mod.rs. |
Single-package layouts
# Library only # Binary only # Library + binary
my-lib/ my-app/ my-crate/
├── Cargo.toml ├── Cargo.toml ├── Cargo.toml
└── src/ └── src/ └── src/
└── lib.rs └── main.rs ├── lib.rs
└── main.rs
# Multi-binary (one package, multiple binaries)
my-app/
├── Cargo.toml
└── src/
├── lib.rs
├── main.rs # binary named after the package
└── bin/
├── tool_a.rs # binary `tool_a`
└── tool_b.rs # binary `tool_b`
Module declaration (the rule that surprises Java/Python devs)
// src/lib.rs — declare modules explicitly; directories are NOT auto-discovered
pub mod front_of_house; // loads src/front_of_house.rs OR src/front_of_house/mod.rs
mod back_of_house; // private
pub(crate) mod utils; // crate-visible
Everything is private by default; pub exposes. See rust-module-layout for the full visibility model, the parent-bound reachability rule, and the modern foo.rs + foo/ layout.
use paths (quick reference)
use crate::front_of_house::hosting; // absolute (current crate)
use std::collections::HashMap; // absolute (external)
use self::back_of_house::Cook; // relative (current module)
use super::parent_module::helper; // relative (parent)
use std::{cmp::Ordering, io}; // nested
use std::fmt::Result as FmtResult; // alias
pub use crate::front_of_house::hosting; // re-export (facade pattern)
Conditional compilation
#[cfg(target_os = "linux")]
fn only_linux() {}
#[cfg(feature = "serde")]
fn with_serde() {}
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
struct Config;
if cfg!(target_os = "linux") { /* runtime check */ }
Project Scaffolding
cargo new my-app # Binary project
cargo new my-lib --lib # Library project
cargo init # Initialize current directory
For multi-crate workspaces, create the root virtual manifest by hand (there is no cargo workspace new), choose the member topology from the decision model below, then run cargo new --lib <selected-member-path>. For a small root-flat workspace that may be cargo new --lib my-core; for a contained large workspace it may be cargo new --lib crates/my-core. For templates, use cargo-generate.
Workspace Patterns (single-crate vs multi-crate)
Authority: Cargo Book — Workspaces, The Book ch14-03, matklad — Large Rust Workspaces, RFC 1525.
A workspace is a collection of one or more packages that share a single Cargo.lock and target/ directory. Workspaces exist for four reasons: faster builds (shared dependency compilation), coordinated versions (one lockfile, one cargo publish flow), cleaner boundaries (each crate is an independent compilation unit with its own public API), and independent reuse (users can depend on one crate without pulling in the others).
Decision tree — how many crates?
Is this project one cohesive library or one binary?
├── Yes → Single-crate layout (Section 8.1)
└── No, it has multiple subdomains
├── Do the subdomains share types and call each other heavily?
│ └── Single-crate with module directories (use rust-module-layout skill)
└── At least one subdomain is independently useful / independently versioned / has different deps?
└── Multi-crate workspace (Section 8.2)
Rule of thumb: split into crates only when at least one of these is true (from references/production-workspace-boundaries.md):
- The subdomain requires independent publishing or third-party reuse
- The subdomain needs distinct
feature/ target /no_std/ WASM boundaries - You need to prohibit a reverse dependency at compile time (e.g.,
coremust not depend ontokio) - The subdomain has an independently versioned public API
- Test/build lifecycles are significantly different
If none of these apply, prefer modules over crates. Modules are cheaper (no publish, no version coordination, no separate Cargo.toml). See the rust-module-layout skill for in-crate organization.
Single-crate layout
The default for small-to-medium projects. One Cargo.toml, one src/. Four variants: library only, binary only, library + binary (same package), or multi-binary (src/bin/<name>.rs). See examples/single-crate.md for all four skeletons.
When to graduate to a workspace: when a binary or library needs an independent dependency/feature/target boundary, release trajectory, or build/test lifecycle. A second binary alone can remain under src/bin/ in the same package.
Multi-crate workspace — two flavors of root
Cargo supports two kinds of root Cargo.toml:
| Flavor | What's in root | When to use |
|---|---|---|
| Virtual manifest | [workspace] only — no [package] |
Default for new workspaces. Clean root, no top-level src/; members may be root-flat, hybrid, or contained. |
| Root package | [workspace] + [package] + src/ at root |
Small (2-3 package) workspaces where one package is unambiguously primary; you accept the trade-offs below. |
The Cargo team and community (notably matklad's Large Rust Workspaces) recommend virtual manifests for any non-trivial workspace:
- Root pollution — a
[package]at the root forcessrc/,tests/,benches/into the top level alongside every other crate's directory. - Command ergonomics — with a root package,
cargo buildat the root builds only the root package;--workspaceis needed for everything. Virtual manifests build all members by default. - Publishing friction — root package + members leads to confusing
cargo publishordering.
Project-driven layout patterns
| Pattern | Layout | When | Used by |
|---|---|---|---|
| A. Root-flat virtual | <member>/ beside root Cargo.toml |
Default for small cohesive workspaces, usually 2-8 packages/members | Tokio, Serde, Clap |
| B. Hybrid/domain-grouped | core members at root plus support/*, examples/*, or another real family |
Growing workspaces with stable families or noisy adapters/examples | framework and migration workspaces |
| C. Contained/grouped | crates/<member>/ or crates/<category>/* |
Large workspaces, multi-language repositories, or roots that need a Rust container | Bevy-style large repositories |
| D. Nested sub-workspaces | vendor/<sub>/ with own [workspace] |
Git submodule or vendored-workspace isolation (rare) | vendored upstream workspaces |
| E. Root package | [package] at root plus sibling/contained members |
2-3 packages with one genuinely primary crate | library plus companion CLI |
Workspace member count is a signal, not a law. Decide package/crate boundaries first, then consider repository root noise, independent publishing, adapter/plugin families, targets, examples/tests, other languages, and established paths. As a review trigger: 2-8 cohesive packages normally stay root-flat; around 8-20 compare root-flat with a hybrid; 20+ commonly benefits from grouping or crates/. Never create one Cargo package per Maven/Gradle module without a Rust boundary reason.
Full skeletons and the decision matrix: references/workspace-layouts.md.
Pattern A skeleton — small workspace default
my-project/
├── Cargo.toml # virtual: [workspace]
├── my-core/ # library
│ ├── Cargo.toml
│ └── src/lib.rs
├── my-net/ # library
│ └── ...
└── my-cli/ # binary
└── src/main.rs
# root Cargo.toml — virtual manifest
[workspace]
resolver = "3"
members = ["my-core", "my-net", "my-cli"]
[workspace.package]
edition = "2024"
version = "0.1.0"
license = "Apache-2.0"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] } # pin once
my-core = { path = "my-core" } # internal deps here
# my-net/Cargo.toml
[package]
name = "my-net"
version.workspace = true # inherits from [workspace.package]
edition.workspace = true
[dependencies]
serde.workspace = true # inherits from [workspace.dependencies]
my-core.workspace = true # internal workspace dep
The "mixed root package" anti-pattern
A common mistake from developers coming from Java/Maven or Python: putting a real [package] with substantial code at the workspace root while also having member crates.
# Cargo.toml — DON'T (anonymized from a real public crate)
[workspace]
members = ["codegen", "macro-driver", "example"]
[package] # ❌ root package + workspace together
name = "my_framework"
version = "4.9.6"
[dependencies]
my-codegen = { path = "codegen" }
my-macro-driver = { path = "macro-driver" }
my-framework/
├── Cargo.toml # 163 lines mixing workspace + package config
├── src/ # ❌ the root package's source pollutes the root
│ ├── lib.rs # 30 lines of `pub use *::*`
│ ├── crud.rs # 591 lines (monster file — see rust-module-layout)
│ ├── executor.rs # 642 lines
│ └── plugin/
├── codegen/ # member crate
├── macro-driver/ # member crate
└── tests/
Five problems: root pollution, asymmetric commands (cargo build builds only the root), coupled versioning, no clear facade (lib.rs becomes a glob re-export hub), and migration is a breaking change once published.
Refactor target — virtual manifest with all crates under crates/:
my-framework/
├── Cargo.toml # virtual manifest — workspace config only
└── crates/
├── my-framework/ # was: src/ at root
│ ├── Cargo.toml
│ └── src/
├── codegen/
├── macro-driver/
└── example/
Migration is mechanical and preserves the published crate name — full six-step procedure in references/mixed-root-package-antipattern.md and a worked example in examples/migration-mixed-to-virtual.md.
Workspace-level shared configuration
Three workspace-level tables eliminate drift across members. See references/workspace-dependencies.md for the full syntax.
# root Cargo.toml
[workspace.package] # shared package metadata
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "Apache-2.0"
[workspace.dependencies] # shared external + internal deps
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
my-core = { path = "crates/core" }
[workspace.lints.rust] # shared lint config
unsafe_code = "forbid"
missing_docs = "warn"
Members opt in with .workspace = true:
# crates/net/Cargo.toml
[package]
name = "my-net"
version.workspace = true # inherits 0.1.0
edition.workspace = true
[dependencies]
serde.workspace = true # inherits pinned version + features
my-core.workspace = true
[lints]
workspace = true # inherits shared lints
Dependency direction (DAG)
Crate dependencies form a directed acyclic graph. Cycles are forbidden (cargo will error). Plan direction deliberately.
low-level types ──► domain logic ──► application ──► binary
(my-types) (my-core) (my-server) (my-cli)
Anti-pattern — leaky direction:
# crates/core/Cargo.toml — ❌ core depending on CLI concerns
[dependencies]
clap = "4" # CLI lib has no business in core
my-cli = { path = "../cli" } # core cannot depend on the binary
Verification:
cargo tree # visualize the dep graph
cargo tree --invert --package my-core # what depends on my-core?
If my-types shows up as depending on anything non-std, the direction is wrong. Layering rules in references/dependency-direction.md.
Reference example — sa-token-rs (11-crate virtual workspace)
sa-token-rs is a real-world Rust port of the Java Sa-Token permission framework — an exemplar of every pattern this skill teaches. Virtual manifest, 11 crates, clean 5-layer DAG, crates/<category>/ grouping, full [workspace.*] inheritance.
Layout (contained + domain-grouped hybrid)
sa-token-rs/
├── Cargo.toml # virtual manifest
└── crates/
├── sa-token/ # Layer 3: facade (public API)
├── sa-token-core/ # Layer 1: core types & traits (no internal deps)
├── sa-token-derive/ # Layer 2: proc-macro (core only)
├── sa-token-context-mock/ # Layer 2: mock context (core only)
├── sa-token-dao-memory/ # Layer 2: memory DAO (core only)
├── sa-token-dao-redis/ # Layer 4: redis DAO (facade + core)
├── sa-token-axum/ # Layer 4: axum adapter (facade + core)
├── sa-token-plugin/ # domain grouping inside container
│ ├── sa-token-jwt/ # Layer 4: JWT plugin
│ └── sa-token-sign/ # Layer 4: signature plugin
├── sa-token-demo/ # domain grouping inside container
│ └── sa-token-demo-axum/ # Layer 5: binary example
└── sa-token-test/ # Layer 5: integration tests
DAG — 5 layers, every arrow points up
L1 sa-token-core
▲
L2 sa-token-derive sa-token-context-mock sa-token-dao-memory
▲ ▲ ▲
L3 sa-token (facade — aggregates L2 leaves into one public API)
▲
L4 sa-token-dao-redis sa-token-axum sa-token-jwt sa-token-sign
▲
L5 sa-token-demo-axum sa-token-test (binaries + tests)
Why it's exemplary
- Virtual manifest — no root
[package];cargo buildbuilds all 11 by default. - Contained + domain-grouped hybrid —
crates/sa-token-*for leaves, with plugin and demo families grouped beneathcrates/. - Full
[workspace.*]inheritance —version,edition = "2024",rust-version = "1.85",license,repository,[workspace.lints](unsafe_code = "forbid",missing_docs = "warn",clippy::pedantic = "warn"), and[workspace.dependencies]for internal + external deps. - Feature-gated optional dep —
sa-tokenexposesredis = ["dep:sa-token-dao-redis"]. - Clean DAG — core has zero internal deps; facade aggregates; adapters/plugins/DAOs sit on top; demos/tests consume everything.
See references/sa-token-rs-case-study.md for the full member-by-member Cargo.toml breakdown, DAG verification commands, and how the layout supports independent publishing.
Workspace commands cheat sheet
cargo build --workspace # build every member (default in virtual manifest)
cargo build -p my-core # build one member
cargo test --workspace # test every member
cargo check --workspace # fast type-check everything
cargo doc --workspace --no-deps # docs for every member
cargo publish -p my-core # publish one member
cargo run -p my-cli # run a specific binary
cargo workspaces version minor # bump all members in lockstep (needs cargo-workspaces)
cargo workspaces publish # publish all members in topological order
For batch version bumps and publishes across all members, install cargo-workspaces.
Workflow
- Decide project type and crate boundaries — start single-crate; split only for independent reuse/versioning, dependency or target isolation, or a distinct lifecycle. Otherwise use modules.
- Inventory topology signals — resulting package count, publish units, adapter/plugin families, examples/tests, repository root noise, languages, and compatibility-sensitive existing paths.
- Choose a layout — small cohesive workspace: root-flat; growing workspace with real families: hybrid/domain-grouped; large or multi-language workspace: contained/grouped. Treat counts as review triggers, not hard thresholds.
- Pin shared metadata and deps at workspace level —
[workspace.package],[workspace.dependencies],[workspace.lints]. Members opt in with.workspace = true. - Verify dependency direction —
cargo tree --invert --package my-coremust show only higher-level crates depending on lower-level ones. No reverse edges, no cycles. - Select naming conventions — snake_case crate names, kebab-case in
Cargo.tomlname. Use full words; avoid 2-letter abbreviations (seerust-module-layout's naming reference). - Validate —
cargo check --workspace,cargo tree,cargo doc --workspace --no-deps. Inspect the generated docs sidebar — it should reflect your intended public API surface.
Gotchas
- Distinguish between
crate::(references the current crate root) and::other_crate_name::(absolute path reference to an external crate). - Restricted visibility such as
pub(crate)is not an Edition boundary; verify the project's Rust version rather than assuming Edition 2015 forbids it. - Both
module.rsandmodule/mod.rsare supported; new code typically prefers the former, but do not misinterpret a deprecated warning as an error for usingmod.rs. - Workspace resolver settings apply globally; Edition 2021 defaults to resolver 2, while Edition 2024 defaults to resolver 3.
- The path argument in
pub(in path)must point to ancestor modules of the current item and cannot be used to expose visibility across arbitrary sibling modules. - Use of the
#[path]attribute bypasses filesystem conventions — module paths no longer follow default file tree structures after application. - A virtual manifest cannot contain
[dependencies]or[package]. If you seefailed to parse manifest at ... missing field package, you've mixed virtual and root-package syntax. Either remove[package](virtual) or add it (root package) — don't half-do both. cargo buildat a root-package workspace only builds the root. Use--workspaceto build everything. With a virtual manifest,cargo buildalready builds all members — fewer surprises.- Workspace globs match one level only.
members = ["crates/*"]does not matchcrates/libs/core/; usecrates/libs/*or explicit paths. Root-flat members can use explicit names or a carefully scoped glob, but nevermembers = ["*"]because it captures non-package directories. - Workspace-internal
pathdeps still need versions for publish.my-core = { path = "../core", version = "0.1.0" }— withoutversion,cargo publishrejects it. Use[workspace.dependencies]to keep the version in one place. - Bumping a workspace-shared dep requires editing only the root
Cargo.toml. Don't re-pin it in member crates — that defeats the purpose and creates drift. - Renaming a published crate is a breaking change. Add a deprecated alias crate (
pub use my_new_name::*;) under the old name for one release cycle before removing it.
On-Demand Resources
- Layout Examples — basic module layouts
- Concept Quick Reference
- Workspace Layouts — project-sized root-flat, hybrid, contained, nested, and root-package skeletons
- Virtual vs Root Manifest — the decision in depth, with command-ergonomics comparison
- Mixed Root Package Anti-Pattern — full diagnosis + migration path for rbatis-style layouts
- Workspace Dependencies —
[workspace.package]/[workspace.dependencies]/[workspace.lints]in depth - Dependency Direction — DAG rules, layering, leaky direction anti-patterns
sa-token-rsCase Study — real-world 11-crate virtual workspace with 5-layer DAG, full member-by-member breakdown, DAG verification, and publishing implications- Production-grade workspace boundaries: When splitting protocols, domains, platforms, transports, SDKs, adapters, and binaries, read the relevant sections.
examples/golden-layout/: single-crate CI compilation exampleexamples/golden-workspace/: small root-flat virtual workspace example with three crates
Official References
- The Book ch 7 — Managing Growing Projects
- The Book ch14-03 — Cargo Workspaces
- Rust Reference ch 7 (Items)
- Rust Reference ch 7.2 (Modules)
- Cargo Book — Workspaces
- Cargo Book —
workspace.package - Cargo Book — Workspace dependencies
- RFC 1525 — Cargo Workspaces
- matklad — Large Rust Workspaces (community best-practice reference)
- Rust API Guidelines — Organization (C-HIERARCHY, C-REEXPORT)