Workspace Patterns

Multi-crate project organization

intermediate
workspacecargomonorepo
🎮 Interactive Playground

What are Workspace Patterns?

Cargo workspaces organize multiple related crates under a single directory. They share dependencies, build artifacts, and configuration while maintaining separation of concerns.

The Problem

Large Rust projects face challenges:

  • Dependency duplication: Same crate versions across multiple projects
  • Build inefficiency: Rebuilding shared code multiple times
  • Version drift: Different projects using incompatible versions
  • Code organization: Unclear boundaries between components

Example Code

# 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();
}

Why This Works

  1. Dependency deduplication: Single source of truth for versions
  2. Incremental builds: Changed crates rebuild, others cached
  3. Clear boundaries: Each crate has explicit responsibilities
  4. Unified testing: cargo test --workspace tests everything

Workspace Structure Patterns

| 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 |

Cargo Commands

# 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

⚠️ Anti-patterns

# 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

Exercises

  1. Add a myapp-test-utils crate for shared test helpers
  2. Implement feature flags that work across workspace crates
  3. Create a build script that generates version info for all crates
  4. Set up release automation with workspace-aware versioning

🎮 Try it Yourself

🎮

Workspace Patterns - Playground

Run this code in the official Rust Playground