As we settle into 2025, the days of monolithic, single-crate Rust applications are largely behind us in the enterprise space. Whether you are building a microservices mesh, a cross-platform CLI, or a high-performance data pipeline, code organization is paramount.
When your src/lib.rs starts exceeding 5,000 lines, or when you need to share domain logic between a server and a WASM frontend, you need a strategy. Enter Cargo Workspaces.
In this guide, we will move beyond the basics. We won’t just create folders; we will architect a scalable monorepo using modern Cargo features like Dependency Inheritance, ensuring your build times stay low and your codebase remains maintainable.
Why Workspaces Matter #
A Cargo Workspace is a set of packages that share the same Cargo.lock and output directory (target/).
Why is this critical for senior developers?
- Shared Dependencies: You compile
tokioorserdeonce, and all your binaries use those artifacts. This drastically reduces compile times and disk usage. - Version Consistency: It forces all your internal tools to use the same version of third-party crates, avoiding the “dependency hell” of mismatched types.
- Logical Separation: It enforces strict boundaries between modules. You cannot accidentally use a private function from the API layer in your database layer if they are separate crates.
The Architecture Strategy #
Before we code, let’s visualize the project structure we are about to build. We will create a “Finance Suite” with three components:
finance_core: The shared business logic (library).finance_api: A REST API server (binary).finance_cli: A command-line tool for admin tasks (binary).
Prerequisites #
To follow along, ensure you have:
- Rust 1.75+: We will rely on workspace dependency inheritance, which is now stable and standard.
- IDE: VS Code (with rust-analyzer) or RustRover.
Step 1: Initialize the Workspace Root #
The root of a workspace doesn’t contain source code; it contains configuration. Create a new directory and a Cargo.toml.
mkdir finance-suite
cd finance-suite
touch Cargo.tomlEdit Cargo.toml. Note that we are using [workspace] instead of [package].
# Cargo.toml (Root)
[workspace]
resolver = "2"
members = [
"crates/*",
"apps/*"
]
# MODERN RUST BEST PRACTICE:
# Define dependencies here to enforce versions across the entire repo.
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
anyhow = "1.0"Step 2: Create the Shared Library (finance_core)
#
We will place our library code in a crates folder. This is a common convention to separate reusable libraries from executable applications.
mkdir crates
cd crates
cargo new --lib finance_coreNow, let’s configure crates/finance_core/Cargo.toml to use the inherited dependencies we defined in the root.
# crates/finance_core/Cargo.toml
[package]
name = "finance_core"
version = "0.1.0"
edition = "2021"
[dependencies]
# By setting workspace = true, we inherit the version from the root Cargo.toml
serde = { workspace = true }
anyhow = { workspace = true }Let’s add some dummy logic to crates/finance_core/src/lib.rs:
// crates/finance_core/src/lib.rs
use serde::{Serialize, Deserialize};
use anyhow::Result;
#[derive(Serialize, Deserialize, Debug)]
pub struct Transaction {
pub id: u64,
pub amount: f64,
pub currency: String,
}
pub fn process_transaction(tx: Transaction) -> Result<String> {
// Simulate complex logic
if tx.amount < 0.0 {
return Err(anyhow::anyhow!("Negative amount not allowed"));
}
Ok(format!("Processed transaction {} for {} {}", tx.id, tx.amount, tx.currency))
}Step 3: Create the Consumers (api and cli)
#
Now we build the executables in an apps directory. These will depend on finance_core via a local path.
cd ../.. # Back to root
mkdir apps
cd apps
cargo new --bin finance_api
cargo new --bin finance_cliConfiguring the API #
Edit apps/finance_api/Cargo.toml. This is where the magic happens: we link a local path dependency alongside external workspace dependencies.
# apps/finance_api/Cargo.toml
[package]
name = "finance_api"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { workspace = true }
anyhow = { workspace = true }
# Path dependency linking to our sibling crate
finance_core = { path = "../../crates/finance_core" }Write the API code in apps/finance_api/src/main.rs:
// apps/finance_api/src/main.rs
use finance_core::{Transaction, process_transaction};
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
println!("Starting Finance API...");
let tx = Transaction {
id: 101,
amount: 250.00,
currency: "USD".to_string(),
};
// Use logic from the core library
let result = process_transaction(tx)?;
println!("API Result: {}", result);
Ok(())
}Configuring the CLI #
The process is identical for the CLI. It reuses the exact same logic.
# apps/finance_cli/Cargo.toml
[package]
name = "finance_cli"
version = "0.1.0"
edition = "2021"
[dependencies]
finance_core = { path = "../../crates/finance_core" }Architectural Comparison #
Why go through this trouble instead of just making separate Git repositories or one giant main.rs?
| Feature | Single Crate (Monolith) | Cargo Workspace (Monorepo) | Multiple Repos (Polyrepo) |
|---|---|---|---|
| Compilation | Fast initially, slow as project grows. | Optimized. Shared artifacts via common target/. |
Slowest. Dependencies recompiled for every repo. |
| Dependency Sync | Easy (one Cargo.toml). |
Structured. workspace.dependencies keeps versions aligned. |
Nightmare. serde 1.0.1 here, 1.0.2 there. |
| Refactoring | Easy but risky (tight coupling). | Safe. Compiler enforces crate boundaries. | Difficult. Requires publishing updates across repos. |
| CI/CD Pipeline | Simple. | Flexible. Can test changed crates only (smart diffs). | Complex orchestration required. |
Running the Workspace #
In the root directory, standard Cargo commands now operate on the entire workspace context.
1. Build Everything #
cargo buildNote: You will see serde and anyhow compiled only once, even though two apps use them.
2. Run a Specific Member #
To run the API:
cargo run -p finance_api3. Test the Whole Suite #
cargo testThis runs unit tests in finance_core and integration tests in finance_api/finance_cli.
Best Practices & Common Pitfalls #
1. The [workspace.lints] Feature
#
Rust 1.74 introduced workspace-level lints. Instead of adding # ![deny(clippy::unwrap_used) ] to every single file, add it to your root Cargo.toml:
# Cargo.toml (Root)
[workspace.lints.clippy]
unwrap_used = "deny"Then inherit it in members: [lints] workspace = true. This ensures code quality standards are uniform across 20+ microservices.
2. Handling Circular Dependencies #
Workspaces prevent circular dependencies at compile time.
- Problem: Crate A uses Crate B, and Crate B uses Crate A.
- Solution: Extract the shared types (interfaces) into a third crate, usually named
commonortypes.finance_core-> depends onfinance_typesfinance_api-> depends onfinance_typesandfinance_core
3. Docker Optimization #
When building Docker images for workspace members, do not copy the whole source code immediately.
Use cargo-chef. It is a tool specifically designed to cache dependencies for Cargo workspaces in Docker builds. Without it, changing one line of code in finance_cli invalidates the Docker cache for finance_api’s dependencies, leading to 10-minute build times.
Conclusion #
Cargo Workspaces are the backbone of professional Rust development. They provide the perfect balance between the simplicity of a monolith and the modularity of microservices.
By utilizing Dependency Inheritance (workspace.dependencies), you reduce maintenance overhead. By sharing the target directory, you slash compile times.
Next Steps for Your Project:
- Audit your current
Cargo.tomlfiles. Are you repeating version numbers? Move them to the root. - Look into
cargo-release. It’s a tool that automates versioning and publishing for workspaces, handling the tedious task of bumping versions in path dependencies automatically.
Happy coding, and keep that compile time low!