Rust Workspace Production By Example
Production web applications invariably evolve from single-service monoliths into multi-service systems. Scaling cleanly requires normalizing behaviors, maximizing code reuse, and strictly decoupling domain logic from transport layers. This guide explores how to leverage Cargo Workspaces to transform a monolithic Rust application into a modular, multi-crate production architecture.
1. Why Do We Need Cargo Workspaces? (From Monolith to Multi-Service)
A typical production architecture starts as a unified codebase packaging models, store layers, context handling, events, RPC handlers, authentication, utilities, and an Axom-based web layer into a single binary. However, scaling demands a multi-service approach:
- Separation of Concerns: Splitting distinct business and technical domains into isolated compilation units.
- Independent Deployment: Managing different services (end-user web servers, DevOps internal servers, background AI or data fetching workers) via Kubernetes, AWS Lambda, or Wasm targets.
- Code Reuse: Enforcing shared behavior across multiple binaries without duplicating logic.
Architectural Evolution
2. Core Directory Structure & Workspace Layout
Organizing code cleanly under a dedicated crates/ directory keeps application libraries separate from third-party dependencies and root configurations.
Directory Breakdown
crates/libs/: Reusable application libraries. By convention, libraries are prefixed withlib-(e.g.,lib-auth,lib-core) so imports (use lib_auth::...) immediately distinguish internal application logic from external crates.crates/services/: Runnable executables serving specific traffic interfaces (e.g., end-user web server, internal metrics server).crates/tools/: Development and deployment utilities (e.g., cryptographic key generation).crates/macros/: Custom procedural macros (added as the application grows).
3. Configuring the Root Cargo.toml
A workspace Cargo.toml at the project root coordinates all sub-crates, shares lint configurations, and enforces a unified compilation lockfile (Cargo.lock).
[workspace]
resolver = "2"
members = [
"crates/libs/*",
"crates/services/*",
"crates/tools/*",
]
[workspace.lints.rust]
unsafe_code = "forbid"
# unused_variables = "allow" # Uncomment during experimental dev, commit with strict lints
Key Configuration Insights
- Explicit Resolver (
resolver = "2"): Because root-level workspaces lack a[package]table, Cargo does not automatically default to edition 2021 feature resolvers. You must explicitly specifyresolver = "2". - Workspace Lints (Rust 1.74+): Centralizing lints like
unsafe_code = "forbid"in the rootCargo.tomlensures uniform safety standards across every sub-crate without scattering configuration boilerplate. Unless you have 15+ years of C++ experience, forbidding unsafe code prevents memory safety bugs.
4. Sub-Crate Cargo.toml Design & Dependency Paths
Each sub-crate maintains its own lightweight Cargo.toml declaring only the exact dependencies it requires.
[package]
name = "lib-rpc"
version = "0.1.0"
edition = "2021"
publish = false
doc-scrape-examples = false
[lints]
workspace = true
[dependencies]
lib-utils = { path = "../lib-utils" }
serde = { workspace = true }
tokio = { workspace = true }
Pro-Tip: Uniform Path References
To make dependency paths copy-pasteable across different sub-directories (whether a crate sits in crates/libs/lib-rpc or crates/services/web-server), use relative path hops pointing back to the workspace root:
# Example of portable path declaration
lib-utils = { path = "../../libs/lib-utils" }
- Minimize Dependencies: Only import what a specific crate needs. A common refactoring workflow is copying broader dependencies and pruning them one by one until compilation fails, leaving a maximally lean dependency footprint.
5. Dependency Direction Rules & Avoiding Circular References
In a Cargo workspace, circular dependencies are strictly forbidden. Cargo will throw a compilation error if Crate A depends on Crate B while Crate B depends on Crate A.
Unidirectional Dependency Flow
To maintain a clean architecture, enforce a strict, acyclic dependency hierarchy:
[lib-utils] (Lowest level: logging, error types, formatting)
^
| depends on
[lib-auth] & [lib-rpc]
^
| depends on
[lib-core] (Domain models, context `Ctx`, store layers)
^
| depends on
[web-server] (Transport layer: Axon, HTTP handlers, extractors)
- Rule: Higher-level layers (like
web-server) can depend on lower-level libraries (lib-core,lib-rpc,lib-auth,lib-utils), but lower-level libraries must never depend on the web or transport layer.lib-coremust remain entirely web-independent.
6. Solving the Orphan Rule: The New Type Pattern
The Problem
In Rust, the Orphan Rule dictates that you cannot implement an external trait (from-request parts from the Axom web framework) on an external type (Ctx from lib-core) if both the trait and the type are defined outside of your current crate (web-server).
If you instead implement FromRequest directly inside lib-core, you force lib-core to depend on Axum and the web layer—violating decoupling principles.
The Solution: New Type Wrapper Pattern
To bridge external traits and external types without breaking modularity, wrap the core type in a local new-type wrapper within the web crate:
// Inside web-server crate (e.g., auth extraction module)
use axum::extract::FromRequestParts;
use lib_core::model::Ctx;
/// New-type wrapper following naming convention: `[TypeName]W`
pub struct CtxW(pub Ctx);
#[axum::async_trait]
impl<S> FromRequestParts<S> for CtxW
where
S: Send + Sync,
{
type Rejection = Error; // Web-specific error type
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// Authentication extraction logic...
let ctx = /* extracted and validated Ctx */;
Ok(CtxW(ctx))
}
}
Unwrapping the Core Type
When service or RPC layers require the bare Ctx rather than the web-wrapped CtxW, unwrap it cleanly during handler execution:
async fn handle_rpc_call(CtxW(ctx): CtxW, Payload(data): Payload<Data>) -> Result<Json<Value>> {
// Pass pure un-wrapped Ctx to business logic
let result = rpc_handler(ctx, data).await?;
Ok(Json(result))
}
7. Operational Tooling, Environment Variables, & Execution
Splitting a monolith into named workspace crates changes how you run, test, and configure your application.
Running Specific Workspace Crates
Use the -p (package) flag to target specific binaries or tools within the workspace:
# Run the key generation tool
cargo run -p gen-key
# Run the end-user web server service
cargo run -p web-server
# Run integration tests using a specific example
cargo test -p web-server --example quick
Adjusting Tracing & Log Filters (RUST_LOG)
Because crate names change when split into sub-crates (e.g., app::auth becomes lib_auth), update your environment variables or Cargo.toml configuration files accordingly:
# Old monolithic logging filter
RUST_LOG=debug,app=trace cargo run
# New modular workspace logging filter
RUST_LOG=debug,lib_core=trace,lib_auth=trace,web_server=trace cargo run -p web-server
8. Migration Strategy: Monolith to Workspace
When migrating an existing monolithic Rust application to a workspace layout, follow a bottom-up migration path (starting with crates that have the fewest dependencies):
- Iterative Verification: Extract one module at a time, move it into
crates/libs/, update path dependencies, and runcargo checkafter each step to ensure incremental compilation success.
Summary and Key Takeaways
- Production Scalability: Monolithic Rust web applications naturally evolve into multi-service systems managed by orchestrators like Kubernetes, requiring clean code organization.
- Workspace Resolution: Always specify
resolver = "2"in the rootCargo.tomlwhen building workspace layouts without a top-level package. - Centralized Lints: Enforce global safety standards (such as
unsafe_code = "forbid") using[workspace.lints.rust]in Rust 1.74+. - Portable Dependencies: Use consistent relative path hops (
path = "../../libs/lib-name") so dependency lines remain copy-pasteable across different sub-directories. - Unidirectional Flow: Maintain a strict acyclic dependency graph where transport and web layers depend on core libraries, but core libraries never depend on web frameworks.
- New Type Pattern: Overcome Rust's Orphan Rule by wrapping external core types (e.g.,
Ctx) in local new-type wrappers (e.g.,CtxW) when implementing external web framework traits likeFromRequestParts. - Targeted Execution: Use
cargo run -p [crate-name]to execute specific services, tools, or examples within the workspace. - Bottom-Up Migration: When refactoring a monolith, migrate from the bottom up—starting with
lib-utilsand concluding with the web transport layer.