Multi-crate project organization
Cargo workspaces organize multiple related crates under a single directory. They share dependencies, build artifacts, and configuration while maintaining separation of concerns.
Large Rust projects face challenges:
# Cargo.toml (workspace root)
[workspace]
resolver = "2"
members = [
"crates/core",
"crates/api",
"crates/cli",
"crates/db",
"crates/common",
]
# Shared dependencies across workspace
[workspace.dependencies]
tokio = { version = "1.35", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
anyhow = "1.0"
tracing = "0.1"
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }
axum = "0.7"
clap = { version = "4.4", features = ["derive"] }
# Shared package metadata
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Your Team <team@example.com>"]
license = "MIT"
repository = "https://github.com/example/project"
# crates/core/Cargo.toml
[package]
name = "myapp-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
# crates/api/Cargo.toml
[package]
name = "myapp-api"
version.workspace = true
edition.workspace = true
[dependencies]
myapp-core = { path = "../core" }
myapp-db = { path = "../db" }
myapp-common = { path = "../common" }
tokio.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
# crates/cli/Cargo.toml
[package]
name = "myapp-cli"
version.workspace = true
edition.workspace = true
[[bin]]
name = "myapp"
path = "src/main.rs"
[dependencies]
myapp-core = { path = "../core" }
myapp-common = { path = "../common" }
clap.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
// crates/core/src/lib.rs
//! Core domain logic for the application
mod domain;
mod services;
mod error;
pub use domain::*;
pub use services::*;
pub use error::*;
/// Core configuration
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub api_host: String,
pub api_port: u16,
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
Ok(Config {
database_url: std::env::var("DATABASE_URL")
.map_err(|_| ConfigError::MissingEnv("DATABASE_URL"))?,
api_host: std::env::var("API_HOST")
.unwrap_or_else(|_| "127.0.0.1".to_string()),
api_port: std::env::var("API_PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse()
.map_err(|_| ConfigError::InvalidPort)?,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Missing environment variable: {0}")]
MissingEnv(&'static str),
#[error("Invalid port number")]
InvalidPort,
}
// crates/core/src/domain.rs
use serde::{Deserialize, Serialize};
/// User domain model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: UserId,
pub email: Email,
pub name: String,
pub role: UserRole,
}
/// Strongly typed user ID
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UserId(pub i64);
/// Validated email
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Email(String);
impl Email {
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
let email = value.into();
if email.contains('@') && email.contains('.') {
Ok(Email(email))
} else {
Err(ValidationError::InvalidEmail)
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum UserRole {
Admin,
User,
Guest,
}
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("Invalid email format")]
InvalidEmail,
}
// crates/api/src/lib.rs
//! HTTP API layer
use axum::{Router, routing::get};
use myapp_core::{Config, User, UserId};
use std::sync::Arc;
mod handlers;
mod middleware;
mod error;
pub use error::ApiError;
/// Application state shared across handlers
pub struct AppState {
pub config: Config,
// pub db: myapp_db::Database,
}
/// Build the API router
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/health", get(handlers::health))
.route("/users", get(handlers::list_users).post(handlers::create_user))
.route("/users/:id", get(handlers::get_user))
.with_state(state)
}
// crates/api/src/handlers.rs
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use myapp_core::{User, UserId};
use std::sync::Arc;
use crate::{AppState, ApiError};
pub async fn health() -> &'static str {
"OK"
}
pub async fn list_users(
State(_state): State<Arc<AppState>>,
) -> Result<Json<Vec<User>>, ApiError> {
// Would fetch from database
Ok(Json(vec![]))
}
pub async fn get_user(
State(_state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> Result<Json<User>, ApiError> {
// Would fetch from database
Err(ApiError::NotFound(format!("User {} not found", id)))
}
pub async fn create_user(
State(_state): State<Arc<AppState>>,
Json(_payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<User>), ApiError> {
// Would create in database
Err(ApiError::Internal("Not implemented".to_string()))
}
#[derive(serde::Deserialize)]
pub struct CreateUserRequest {
pub email: String,
pub name: String,
}
// crates/cli/src/main.rs
use clap::{Parser, Subcommand};
use myapp_core::Config;
use anyhow::Result;
#[derive(Parser)]
#[command(name = "myapp")]
#[command(about = "My Application CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Start the API server
Serve {
#[arg(short, long, default_value = "8080")]
port: u16,
},
/// Run database migrations
Migrate,
/// Create a new user
CreateUser {
#[arg(short, long)]
email: String,
#[arg(short, long)]
name: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Serve { port } => {
println!("Starting server on port {}", port);
// Start API server
}
Commands::Migrate => {
println!("Running migrations...");
// Run migrations
}
Commands::CreateUser { email, name } => {
println!("Creating user: {} <{}>", name, email);
// Create user
}
}
Ok(())
}
// crates/common/src/lib.rs
//! Shared utilities across crates
pub mod tracing_setup;
pub mod time;
/// Initialize tracing subscriber
pub fn init_tracing() {
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.init();
}
cargo test --workspace tests everything| Pattern | Structure | Use Case |
|---------|-----------|----------|
| Flat | All crates in crates/ | Most projects |
| Nested | Grouped by domain | Large monorepos |
| Binary + Library | src/ + lib/ crates | CLI with library |
| Plugin | Core + plugins | Extensible systems |
# Build entire workspace
cargo build --workspace
# Test specific crate
cargo test -p myapp-core
# Run specific binary
cargo run -p myapp-cli -- serve
# Check all crates
cargo check --workspace
# Generate docs for all
cargo doc --workspace --open
# DON'T: Duplicate dependency versions
# crates/api/Cargo.toml
[dependencies]
tokio = "1.34" # Different version!
# crates/cli/Cargo.toml
[dependencies]
tokio = "1.35" # Creates two builds of tokio
# DO: Use workspace dependencies
[dependencies]
tokio.workspace = true
// DON'T: Tight coupling between crates
// crates/api/src/lib.rs
use myapp_db::internal_schema; // Exposes internals
// DO: Define clear public interfaces
// crates/db/src/lib.rs
pub use repository::UserRepository; // Only public API
myapp-test-utils crate for shared test helpersRun this code in the official Rust Playground