When a script becomes a system
You started with a single main.rs. It parsed a config file, fetched data from an API, and printed a report. It worked. Then you needed a web server that used the same parser and API client. You copied the files into a new folder. You changed a function signature in the CLI to add a timeout parameter. The web server broke because you forgot to update the copy. You are now maintaining two versions of the same logic. One change requires two edits. Bugs hide in the drift between copies.
This is the moment you stop writing a script and start building a project. Rust gives you a tool for this exact problem: the Cargo workspace. A workspace lets you split your code into multiple crates that share dependencies, build together, and link to each other without copy-pasting. You get a single build command, a single lockfile, and a clear dependency graph.
Crates, modules, and the workspace
Rust organizes code at two levels. Modules live inside a crate. Crates live inside a workspace.
Modules are like chapters in a book. They help you organize code within a single compilation unit. You use mod and use to navigate modules. Modules cannot be shared across crates. If you put code in a module, it belongs to that crate.
Crates are like books. A crate is a unit of compilation. It produces a binary, a library, or a test harness. Crates have a Cargo.toml and a src directory. Crates can depend on other crates. When you import code from another crate, you are importing a book, not a chapter.
A workspace is a collection of crates that you build together. It is defined by a root Cargo.toml with a [workspace] section. The workspace tells Cargo which crates belong to the project and how they relate. Cargo builds the dependency graph, resolves versions, and compiles everything in the right order.
Think of a workspace as a factory floor. Each crate is a department. The library crate produces shared components. The binary crate assembles the final product. The workspace coordinates the workflow so departments don't step on each other's toes.
Setting up the workspace
You create a workspace by initializing a root directory and adding member crates. Cargo provides a command to scaffold this structure.
Run cargo init --workspace my-project to create the root. Then add crates inside.
# Create the workspace root
cargo init --workspace my-project
cd my-project
# Add a library crate for shared logic
cargo new --lib core
# Add a binary crate for the executable
cargo new --bin app
This creates a directory structure that Cargo understands.
my-project/
├── Cargo.toml # Workspace root
├── Cargo.lock # Generated lockfile
├── core/ # Library crate
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── app/ # Binary crate
├── Cargo.toml
└── src/
└── main.rs
The root Cargo.toml lists the members. Cargo uses this list to know which crates to build.
# my-project/Cargo.toml
[workspace]
members = ["core", "app"]
The binary crate depends on the library crate using a path dependency. Path dependencies link crates within the workspace.
# app/Cargo.toml
[package]
name = "app"
version = "0.1.0"
edition = "2021"
[dependencies]
# Link to the local library crate.
# Cargo resolves this path relative to this Cargo.toml file.
core = { path = "../core" }
The library crate exports its public API. The binary crate imports it.
// core/src/lib.rs
/// Parses a configuration string into a structured result.
///
/// Returns an error if the input is empty or malformed.
pub fn parse_config(input: &str) -> Result<Config, ConfigError> {
if input.is_empty() {
return Err(ConfigError::Empty);
}
// Simulate parsing logic.
Ok(Config { raw: input.to_string() })
}
/// Represents a parsed configuration.
pub struct Config {
raw: String,
}
/// Errors that can occur during configuration parsing.
pub enum ConfigError {
Empty,
}
// app/src/main.rs
use core::parse_config;
fn main() {
// Import the function from the library crate.
let config = match parse_config("debug=true") {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to parse config: {:?}", e);
std::process::exit(1);
}
};
println!("Config loaded successfully.");
}
Run cargo build at the workspace root. Cargo builds core first, then app. It links them together and produces the binary.
Convention aside: Use cargo init --workspace instead of manually creating the root Cargo.toml. The command sets up the correct structure and avoids typos in the workspace definition.
Centralizing dependencies
As your workspace grows, you will add external crates like serde or tokio. Multiple crates in the workspace often need the same dependency. If you specify the version in each crate's Cargo.toml, you risk version drift. One crate gets serde 1.0.150, another gets serde 1.0.190. This wastes build time and can cause subtle compatibility issues.
Workspaces support centralized dependency management. You define the dependency once in the root Cargo.toml and reference it from members.
# my-project/Cargo.toml
[workspace]
members = ["core", "app"]
# Centralize dependency versions.
# All members can inherit these settings.
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
Members inherit the dependency using the .workspace = true syntax.
# core/Cargo.toml
[dependencies]
# Inherit version and features from workspace.
serde.workspace = true
# app/Cargo.toml
[dependencies]
core = { path = "../core" }
serde.workspace = true
tokio.workspace = true
This ensures every crate uses the exact same version and feature set. When you update serde, you change one line in the root. All crates get the bump.
Convention aside: Put all third-party dependencies in [workspace.dependencies]. Keep crate-specific overrides minimal. This makes auditing dependencies easier and keeps the workspace consistent.
The build graph and the lockfile
Cargo builds a dependency graph for the entire workspace. It determines the order of compilation based on dependencies. If app depends on core, Cargo builds core first. If core changes, Cargo rebuilds core and then rebuilds app.
The workspace shares a single Cargo.lock file at the root. This file pins the exact versions of all dependencies, including transitive dependencies. It guarantees reproducible builds. Every developer and every CI runner gets the same dependency tree.
If you run cargo update, Cargo updates the lockfile for the whole workspace. You can update specific packages with cargo update -p serde.
Convention aside: Commit Cargo.lock to version control. Even for library crates in a workspace, the lockfile ensures that tests and examples run with consistent dependencies. The only exception is a crate published to crates.io that has no binaries or examples; in that case, the lockfile is optional. In a workspace, always commit it.
Pitfalls and compiler traps
Workspaces introduce new failure modes. Watch for these.
Circular dependencies are forbidden. If core depends on app and app depends on core, Cargo rejects the build.
The compiler reports this with a "cyclic dependency detected" error. Cargo cannot resolve the build order. Circular dependencies usually mean your abstraction is inverted. Extract the shared logic into a third crate that both core and app depend on.
Naming conflicts with the standard library can cause confusion. Rust has a built-in crate named core. If you name your crate core, you shadow the standard library. Code that uses core::option::Option might break or behave unexpectedly.
Avoid naming your crates core, std, or alloc. Use a project-specific prefix like my_project_core or a descriptive name like shared_lib.
Path dependencies do not work when you publish to crates.io. Path dependencies point to local files. If you publish a crate that uses path = "../core", the published crate will fail to build for consumers because the path does not exist on their machine.
The solution is to keep path dependencies for local development and switch to registry versions for publishing. The binary crate usually stays private and never gets published. The library crate gets published with a version number. Other projects depend on the published version, not the path.
Convention aside: Use cargo publish --dry-run to check if your crate is ready for crates.io. It validates metadata and dependencies without uploading.
Choosing your structure
Pick the right structure based on your project's needs.
Use a Cargo workspace when you have multiple related crates that share dependencies or logic. Use a single binary crate when your project is a standalone tool with no shared logic to extract. Use a library crate when you want to share code between multiple binaries or with other projects. Use path dependencies to link local crates within a workspace during development. Use versioned dependencies from crates.io when you are consuming external libraries or publishing your own. Use workspace.dependencies when you want to centralize dependency management and ensure version consistency across crates.
Start simple. One crate is easier to manage than a workspace. Split only when the compiler or your sanity demands it. If you find yourself copy-pasting code between files, extract a module. If you find yourself copy-pasting code between projects, extract a crate. If you find yourself maintaining multiple crates, use a workspace.
A workspace is a build graph, not a file structure. Cargo cares about dependencies, not folders. Organize your crates by responsibility, not by arbitrary hierarchy.