Skip to main content

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.lock at the workspace root and one target/ output directory, and commands such as cargo check --workspace can 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 is src/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.

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.rs is 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-cli works 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

  1. Calling a source directory a package — a directory inside src/ is a module, not a package. A package is a unit with a Cargo.toml.
  2. Putting [profile] or [patch] in a member manifest — they are ignored. Keep them only at the root.
  3. Omitting resolver from a virtual workspace — there is no edition to infer, so Cargo warns and assumes older behavior. Specify resolver = "3".
  4. Trying optional = true in [workspace.dependencies] — this is not allowed. Optionality is declared by the member that uses the dependency.
  5. Putting two libraries in one package — a package can contain at most one library crate. Split the package if two are needed.
  6. Hardcoding version and edition in every member — this begins drift. Standardize them through [workspace.package] inheritance.
  7. Running cargo test at the root and seeing only some tests run — this is default-members behavior. Add --workspace to 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 timecargo build --timings measurements 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 path dependency or publishing unit.
  • Dependency isolation — you want a heavy dependency such as tokio confined 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.

  1. Add [workspace] at the root and define resolver = "3", members, and default-members.
  2. Move the existing logic to crates/core, use lib.rs as the crate root, and connect directories as modules.
  3. Move main.rs to crates/cli, leaving only core calls and I/O in its body.
  4. Collect shared dependency versions in [workspace.dependencies], with members inheriting them through workspace = true.
  5. Standardize version, edition, license, and rust-version through [workspace.package].
  6. Check that no [profile] or [patch] remains in a member, and move any such section to the root.
  7. Verify that cargo check --workspace and cargo test --workspace pass.
  8. If CI or a script targeted a specific package, check its -p flag and working directory.

Internal documents

Official documentation