Rust Workspaces: Multi-Package Projects
Rust workspaces solve a common problem: managing multiple related packages without the overhead of separate repositories. When you're building a non-trivial application, you'll quickly find that...
Key Insights
- Workspaces let you manage multiple related Rust packages in a single repository with unified dependency resolution and shared build artifacts, reducing compilation time and ensuring consistency across packages.
- Use path dependencies to reference workspace members and the
workspace.dependenciestable to centralize version management, eliminating duplicate dependency declarations across crates. - Structure workspaces with clear separation between binaries, libraries, and shared utilities—this encourages modular design and makes it easier to extract packages later if needed.
Introduction to Workspaces
Rust workspaces solve a common problem: managing multiple related packages without the overhead of separate repositories. When you’re building a non-trivial application, you’ll quickly find that splitting functionality into multiple crates makes sense. An API server, database layer, shared types, and CLI tools all benefit from separation, but managing them as independent projects creates friction.
A workspace is Rust’s answer to the monorepo approach. It’s a single repository containing multiple packages that share a Cargo.lock file, build directory, and dependency resolution. This means when two packages depend on serde 1.0.193, Cargo compiles it once, not twice. Your incremental builds are faster, your dependencies stay synchronized, and you can make atomic changes across package boundaries.
Use workspaces when packages are tightly coupled and evolve together. If you’re building a web application with separate API, worker, and shared library components, a workspace makes sense. Don’t use workspaces for completely independent projects that happen to share some code—publish shared functionality as a proper crate instead.
Setting Up a Workspace
Creating a workspace starts with a root Cargo.toml that doesn’t define a package itself. Instead, it declares workspace members and shared configuration.
[workspace]
members = [
"api",
"database",
"shared",
"worker",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Your Name <you@example.com>"]
license = "MIT"
[workspace.dependencies]
tokio = { version = "1.35", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio"] }
anyhow = "1.0"
The members array lists relative paths to each package. Cargo will look for a Cargo.toml in each directory. The resolver = "2" line enables the newer dependency resolver, which you should use for all modern projects.
The workspace.package table defines metadata that member crates can inherit, reducing duplication. The workspace.dependencies table is even more powerful—it centralizes version declarations for dependencies used across multiple packages.
Create the workspace directory structure:
mkdir my-project && cd my-project
touch Cargo.toml # Add workspace config above
cargo new --lib shared
cargo new --bin api
cargo new --lib database
cargo new --bin worker
You now have a workspace with four packages: two libraries and two binaries.
Organizing Member Crates
Each workspace member is a normal Rust package with its own Cargo.toml. The key difference is that member crates can inherit workspace-level configuration and reference each other.
Your directory structure should look like this:
my-project/
├── Cargo.toml # Workspace root
├── Cargo.lock # Shared lock file
├── target/ # Shared build directory
├── api/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs
├── database/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
├── shared/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── worker/
├── Cargo.toml
└── src/
└── main.rs
Here’s what a member crate’s Cargo.toml looks like:
# api/Cargo.toml
[package]
name = "api"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
shared = { path = "../shared" }
database = { path = "../database" }
tokio.workspace = true
serde.workspace = true
anyhow.workspace = true
axum = "0.7"
Notice the .workspace = true syntax for inheriting workspace-level values. This keeps individual package configurations minimal while maintaining consistency. The path dependencies reference other workspace members, creating explicit relationships between packages.
Managing Dependencies
Workspace dependency management has two main patterns: shared external dependencies and internal path dependencies.
For external dependencies, use the workspace dependencies table to centralize versions:
# Root Cargo.toml
[workspace.dependencies]
tokio = { version = "1.35", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio"] }
Then inherit them in member crates:
# database/Cargo.toml
[dependencies]
tokio.workspace = true
sqlx.workspace = true
serde.workspace = true
shared = { path = "../shared" }
This ensures all packages use identical versions. When you need to upgrade tokio, change it once in the root Cargo.toml.
For dependencies specific to one package, declare them normally:
# api/Cargo.toml
[dependencies]
axum = "0.7" # Only used by API
tower = "0.4"
Path dependencies create the internal package graph. The database crate might depend on shared for type definitions, while api depends on both:
// api/src/main.rs
use shared::models::User;
use database::queries;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = database::connect().await?;
let users = queries::get_all_users(&pool).await?;
println!("Found {} users", users.len());
Ok(())
}
Cargo handles the build order automatically. It compiles shared first, then database, then api.
Building and Running Workspace Projects
Workspace commands operate on all members by default, but you can target specific packages.
Build everything:
cargo build
cargo build --release
Build a specific package:
cargo build -p api
cargo build -p database --release
Run a specific binary:
cargo run -p api
cargo run -p worker -- --config production.toml
Test across the workspace:
cargo test # All packages
cargo test -p shared # One package
cargo test --workspace # Explicit all packages
Check all code without building:
cargo check --workspace
Clean the shared build directory:
cargo clean
The --workspace flag is useful in CI pipelines to be explicit. The -p flag (or --package) lets you work on individual packages during development without waiting for unrelated builds.
For binaries, specify which one to run if you have multiple:
cargo run -p api --bin api-server
You can also use workspace-level scripts in your root Cargo.toml:
[workspace.metadata.scripts]
dev = "cargo run -p api"
test-all = "cargo test --workspace"
Though these require additional tools like cargo-make to execute.
Best Practices and Common Patterns
Start with a shared or common crate for types and utilities used across packages. This prevents duplication and creates a clear dependency direction.
// shared/src/models.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub email: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
}
// shared/src/lib.rs
pub mod models;
pub mod error;
pub mod config;
pub use models::*;
Now both api and worker can import these types without duplication:
// api/src/handlers.rs
use shared::{User, ApiResponse};
pub async fn get_user(id: i64) -> ApiResponse<User> {
// Implementation
}
Avoid circular dependencies. If api depends on database and database depends on api, you have a design problem. Extract shared types to a lower-level crate that both can depend on.
Version all workspace members together for internal projects. Use the same version number across packages and bump them in lockstep. This simplifies release management:
[workspace.package]
version = "0.2.0"
For publishable libraries, consider independent versioning if packages have different stability levels.
Keep binary crates thin. Move business logic into library crates that binaries depend on. This makes code testable and reusable:
api/
├── api-server/ # Binary crate
│ └── src/
│ └── main.rs # Just startup code
└── api-lib/ # Library crate
└── src/
├── lib.rs
├── handlers/
└── routes/
Use feature flags to conditionally compile workspace members:
[workspace]
members = [
"core",
"api",
"cli",
]
[features]
default = ["api"]
api = []
cli = []
This lets users install only what they need.
Conclusion
Workspaces are essential for managing complexity in Rust projects. They provide unified dependency management, faster builds through shared artifacts, and a clear structure for organizing related packages. The combination of workspace-level dependency tables and path dependencies creates a powerful system for building modular applications.
Start with workspaces early if you anticipate multiple packages. The upfront structure cost is minimal, and refactoring a single package into a workspace later is straightforward. Use them to enforce architectural boundaries, share common code efficiently, and maintain consistency across your codebase.
The key is balance: don’t create too many tiny packages, but don’t let packages grow too large either. A well-structured workspace makes large Rust projects maintainable and a pleasure to work with.