Workspace Tips for Growing Projects, Part 1
Introduction
This guide revisits a workspace tree I scribbled down as a personal note in 2023, updating it against
the Cargo Reference and Rust Book chapter 14-03. The old version contained broken link syntax and used
imprecise terminology, such as calling directories under src/ "internal packages." Here we first
establish the correct terminology and hierarchy in current Cargo, then cover how to organize a workspace
around this repository's preferred functional-core direction: pure logic in library crates, with side
effects confined to entry-point shells.
Terms first: workspace, package, crate, module
Hierarchy is the most common source of confusion in workspace discussions. According to the official documentation, the terms mean the following.
- Workspace: a unit for managing one or more packages together. Members share one
Cargo.lockat the workspace root and onetarget/output directory, and commands such ascargo check --workspacecan be applied to all members at once. - Package: a unit defined by one
Cargo.toml. It contains one or more crate targets; a package can have at most one library crate and multiple binary crates. - Crate: a compilation unit. A binary crate's default root is
src/main.rs, and a library crate's default root issrc/lib.rs. - Module: a namespace that organizes code inside a crate. A source directory such as
src/interfaces/is a module, not an "internal package."
A package requires its own Cargo.toml. A directory by itself is only a module, and a module connects
to its parent module tree through mod.rs (or a file named after the directory). Calling source
directories packages, as the old document did, obscures Cargo's actual sharing and inheritance
mechanisms, so the terms must remain distinct. The broken link syntax is corrected as well.
[text][URL] does not render without a reference-link definition. Write an inline link like
Rust Book 14-03: Cargo Workspaces.
Root package vs virtual workspace
When the root Cargo.toml contains both [workspace] and [package], that package is the workspace's
root package. This suits a structure with one clear "main" package and supporting libraries. A root
manifest with only [workspace] and no [package] is called a virtual manifest, and the arrangement
is a virtual workspace. It suits managing all packages as peers in separate directories.
There are two major behavioral differences. First, a virtual workspace has no package.edition from
which to infer the resolver version, so resolver must be specified explicitly. Second, when
cargo build is run from the root without a package-selection flag, a root-package setup builds only
the root package, whereas a virtual workspace builds all members.
Why specify resolver = "3"
The resolver version determines dependency feature-unification behavior. Edition 2021 packages infer
resolver "2" by default and edition 2024 packages infer resolver "3", but a virtual workspace has
no [package] from which to infer an edition. If resolver is omitted there, Cargo warns and assumes
older behavior, so explicitly setting resolver = "3" at the root is safer for a new workspace. Even
in a root-package setup, making it explicit prevents behavior from changing unexpectedly when the
edition is upgraded.
members and default-members
members lists the package directories included in the workspace and supports glob patterns such as
crates/*. path dependencies inside the workspace directory automatically become members, and
specific paths can be removed with exclude. default-members determines the members targeted when
a command is run at the root without -p or --workspace. Without it, the root package is the default
in a root-package setup, while every member is the default in a virtual workspace. If CI habitually
runs only cargo build, its result depends on this default target; pinning the intended default with
default-members is more predictable.
Inheritance with workspace.package and workspace.dependencies
The root [workspace.package] table defines metadata keys that members may inherit, including
version, edition, authors, license, rust-version, and repository. Each member opts in per key,
for example with version.workspace = true. [workspace.dependencies] defines shared dependency
versions once, and members inherit them with workspace = true. Features declared here are combined
additively with features listed in a member's [dependencies]; optional cannot be declared in this
table and must be decided by the member. Both features work on Rust 1.64 and later. Lint settings can
be inherited the same way through [workspace.lints] on 1.74 and later. Hardcoding version numbers in
every member causes drift, so define internal workspace dependencies and common external crates once
at the root whenever possible.
profile and patch belong only at the root
The [profile.*], [patch], and [replace] sections are recognized only in the workspace root
manifest. Cargo warns and ignores them when they appear in a member's Cargo.toml. This is the first
place to look when release optimization settings or a temporary dependency replacement do not take effect.
Recommended structure: thin binary shell and library core
This repository's default direction is types → core logic → adapters → entry points, with side effects restricted to the boundary layer. A workspace can express this as two packages.
core(library package): pure domain types and logic. Functions receive input and return output without accessing files, networks, or environment variables, so most tests finish here.cli(binary package):src/main.rsis only the entry point. Keep it as a thin shell responsible for argument parsing, configuration loading, calling the core, and printing results; do not put business logic in it.
This separation makes the core independently testable and reusable. If another binary such as a TUI or server is added later, it can use the same core unchanged. The package boundary also makes the location of side effects visible and easier to review.
The old version of this document presented main as though it were a recommended name for the
entry-point package and directory, but that was the author's historical preference rather than a
universal recommendation. Cargo convention derives package and binary names from their functions;
a name that reveals the role, as in cargo run -p cli, is better.
Project tree
my-project/
├── Cargo.toml # virtual workspace root
├── Cargo.lock # shared by the entire workspace
├── target/ # build output shared by the entire workspace
└── crates/
├── core/ # library package — pure logic
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # library crate root
│ ├── domain.rs # module
│ └── pipeline/
│ ├── mod.rs # module
│ └── steps.rs
└── cli/ # binary package — entry-point shell
├── Cargo.toml
└── src/
└── main.rs
Root Cargo.toml
[workspace]
resolver = "3"
members = ["crates/*"]
default-members = ["crates/cli"]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
rust-version = "1.85"
[workspace.dependencies]
my-core = { path = "crates/core" }
anyhow = "1"
thiserror = "2"
[profile.release]
lto = true
crates/core/Cargo.toml
[package]
name = "my-core"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
thiserror.workspace = true
crates/cli/Cargo.toml
[package]
name = "my-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
my-core.workspace = true
anyhow.workspace = true
Package-level commands
Almost every Cargo command in a workspace accepts package-selection flags.
cargo build -p my-cli— build only a specific package.cargo run -p my-cliworks the same way.cargo test -p my-core— for a fast iteration over core tests only.cargo check --workspace— type-check all members.- Run at the root without flags — targets
default-members. - Run inside a member directory — automatically selects that directory's package.
Common mistakes
- Calling a source directory a package — a directory inside
src/is a module, not a package. A package is a unit with aCargo.toml. - Putting
[profile]or[patch]in a member manifest — they are ignored. Keep them only at the root. - Omitting
resolverfrom a virtual workspace — there is no edition to infer, so Cargo warns and assumes older behavior. Specifyresolver = "3". - Trying
optional = truein[workspace.dependencies]— this is not allowed. Optionality is declared by the member that uses the dependency. - Putting two libraries in one package — a package can contain at most one library crate. Split the package if two are needed.
- Hardcoding version and edition in every member — this begins drift. Standardize them through
[workspace.package]inheritance. - Running
cargo testat the root and seeing only some tests run — this isdefault-membersbehavior. Add--workspaceto target everything.
When to split (growth triggers)
For a small project, modules inside a single package are enough. A workspace is an option that carries manifest-management cost. Consider splitting when the following signals appear.
- Build time —
cargo build --timingsmeasurements show that independently changing code causes a large recompilation scope. Crate boundaries can limit change propagation, but splitting alone does not guarantee improvement: dependent crates still recompile when a public API or compilation fingerprint changes. - Reuse boundary — you want to use logic from another binary or an external project. Splitting it
into a library crate creates a
pathdependency or publishing unit. - Dependency isolation — you want a heavy dependency such as
tokioconfined to one binary while keeping the core light. - Test speed — you need a fast loop that runs only pure core tests.
- Responsibility boundary — ownership or review paths differ by domain.
Migration checklist
Use the following sequence to move a single package to the structure above.
- Add
[workspace]at the root and defineresolver = "3",members, anddefault-members. - Move the existing logic to
crates/core, uselib.rsas the crate root, and connect directories as modules. - Move
main.rstocrates/cli, leaving only core calls and I/O in its body. - Collect shared dependency versions in
[workspace.dependencies], with members inheriting them throughworkspace = true. - Standardize
version,edition,license, andrust-versionthrough[workspace.package]. - Check that no
[profile]or[patch]remains in a member, and move any such section to the root. - Verify that
cargo check --workspaceandcargo test --workspacepass. - If CI or a script targeted a specific package, check its
-pflag and working directory.
Related documents
Internal documents
- Rust Workspace Versioning — commands for raising workspace member versions
with
cargo-edit. It works well together with inherited[workspace.package]version. - Rust Advanced — About Async Traits — an example of an asynchronous trait implementation that belongs in the core.
Official documentation
- Cargo Reference — Workspaces — source for this document's terminology, inheritance, and root-only section rules.
- Rust Book 14-03 — Cargo Workspaces — an introduction to the package, crate, and module hierarchy.
- Cargo Reference — Dependency Resolution — feature-unification behavior controlled by resolver versions.