A Cargo workspace groups multiple crates under a single Cargo.lock and shared target/ directory. Each crate has its own Cargo.toml and its own dependency list, which means the compiler enforces boundaries between crates: if crates/domain/Cargo.toml does not list sqlx, no code in that crate can import it. This is not a convention. It is a compilation error.
Splitting a project into workspace crates gives you faster incremental builds (changing one crate does not recompile unrelated ones), enforced dependency boundaries, and a clear map of what depends on what.
Workspace layout
Use a virtual manifest, a root Cargo.toml that contains [workspace] but no [package]. All application crates live under crates/:
my-app/
Cargo.toml # virtual manifest (workspace root)
Cargo.lock
.cargo/
config.toml # cargo aliases (xtask)
crates/
server/ # binary: composition root
web/ # library: Axum handlers, routing, middleware
db/ # library: SQLx queries and database access
domain/ # library: shared types, business logic
config/ # library: environment variable parsing
jobs/ # library: Restate durable execution handlers
xtask/ # binary: build automation (dev, migrate, ci)
migrations/ # SQLx migration files
compose.yaml # backing services (Postgres, Valkey, etc.)
.env # local environment variables
The flat crates/* layout is the simplest approach. Cargo’s crate namespace is flat, so hierarchical folder structures (like crates/libs/ and crates/services/) add visual complexity that does not map to anything Cargo understands. Put everything under crates/ and use the crate names to communicate purpose.
What each crate does
server is the binary crate and the composition root. It depends on every other crate and wires them together at startup: builds the database pool, constructs the Axum router, starts the HTTP listener. This is the only crate that sees the full dependency graph.
web contains Axum handlers, route definitions, middleware configuration, and Maud templates. It depends on domain for shared types and on db for data access. All HTTP-facing code lives here.
db owns all database access. SQLx queries, connection pool management, and result-to-type mappings belong in this crate. It depends on domain for the types that queries return.
domain holds types and logic shared across the application: entity structs, error enums, validation rules, and any business logic that is not tied to a specific framework. This crate should have minimal dependencies. It does not depend on Axum, SQLx, or any infrastructure crate.
config parses environment variables into typed configuration structs at startup. It depends on serde and dotenvy, not on framework crates.
jobs contains Restate service and workflow handlers for durable background work. It depends on domain and db, but not on web. Jobs are triggered by HTTP handlers but execute independently.
xtask is the build automation crate. The Development Environment section covers its setup in detail.
Root Cargo.toml
The workspace root defines shared settings, dependency versions, and lint configuration for all members.
[workspace]
members = ["crates/*"]
resolver = "3"
[workspace.package]
edition = "2024"
version = "0.1.0"
rust-version = "1.85"
[workspace.dependencies]
# Async runtime
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
# Web framework
axum = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "compression-gzip"] }
tower-sessions = "0.14"
# HTML templating
maud = { version = "0.26", features = ["axum"] }
# Serialisation
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Database
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio", "postgres", "macros", "migrate",
] }
# Error handling
thiserror = "2"
anyhow = "1"
# Observability
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Config
dotenvy = "0.15"
# HTTP client
reqwest = { version = "0.12", default-features = false, features = [
"rustls-tls", "json",
] }
# Internal crates
app-server = { path = "crates/server" }
app-web = { path = "crates/web" }
app-db = { path = "crates/db" }
app-domain = { path = "crates/domain" }
app-config = { path = "crates/config" }
app-jobs = { path = "crates/jobs" }
[workspace.lints.rust]
unsafe_code = "forbid"
rust_2018_idioms = { level = "warn", priority = -1 }
unreachable_pub = "warn"
[workspace.lints.clippy]
enum_glob_use = "warn"
implicit_clone = "warn"
dbg_macro = "warn"Workspace dependencies
The [workspace.dependencies] table defines dependency versions once. Member crates reference them with workspace = true:
# crates/web/Cargo.toml
[package]
name = "app-web"
edition.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
axum.workspace = true
maud.workspace = true
tower.workspace = true
tower-http.workspace = true
tower-sessions.workspace = true
serde.workspace = true
tracing.workspace = true
app-domain.workspace = true
app-db.workspace = true
Members can add features on top of the workspace definition. Features are additive: you can add but not remove them.
# crates/db/Cargo.toml
[dependencies]
sqlx = { workspace = true, features = ["uuid", "time"] }Workspace lints
The [workspace.lints] table shares lint configuration across all crates. Each member opts in with [lints] workspace = true. The example above forbids unsafe code project-wide and enables several useful Clippy lints.
Workspace package metadata
[workspace.package] avoids repeating edition, version, and rust-version in every crate. Members inherit with edition.workspace = true, and so on. Only unpublished, internal crates should share a version this way. If you publish crates to crates.io, give them independent version numbers.
The dependency graph
The crate dependency graph for this layout looks like this:
server ──→ web ──→ domain
│ │
│ └────→ db ──→ domain
│
├──────→ db
├──────→ config
├──────→ jobs ──→ domain
│ │
│ └────→ db
└──────→ domain
domain sits at the bottom with no framework dependencies. db depends on domain and sqlx. web depends on domain, db, and axum. server depends on everything and wires it all together.
This graph is enforced by Cargo.toml files. If someone adds an axum import to the domain crate, the compiler rejects it. No linting rules or code review discipline required.
The domain crate
Keep domain lean. It holds types that multiple crates need: entity structs, ID types, error enums, validation logic. It depends on serde for serialisation and thiserror for error types. It does not depend on Axum, SQLx, Maud, or Tokio.
# crates/domain/Cargo.toml
[package]
name = "app-domain"
edition.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true
A typical domain crate:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
pub id: i64,
pub name: String,
pub email: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateContact {
pub name: String,
pub email: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ContactError {
#[error("contact not found")]
NotFound,
#[error("email already exists")]
DuplicateEmail,
}
Other crates import these types. The db crate maps SQL rows to Contact. The web crate uses CreateContact to deserialise form submissions. Neither crate defines these types itself, so there is a single source of truth.
The server crate
The binary crate has one job: connect everything and start listening.
# crates/server/Cargo.toml
[package]
name = "app-server"
edition.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
tokio.workspace = true
axum.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
app-web.workspace = true
app-db.workspace = true
app-config.workspace = trueuse anyhow::Result;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let config = app_config::load()?;
let db = app_db::connect(&config.database_url).await?;
let app = app_web::router(db);
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
tracing::info!("listening on {}", config.listen_addr);
axum::serve(listener, app).await?;
Ok(())
}
This is deliberately thin. Route definitions, middleware, and handler logic live in the web crate. The server crate only constructs dependencies and passes them in.
default-members
Set default-members in the workspace root to control which crates cargo build and cargo run operate on by default:
[workspace]
members = ["crates/*"]
default-members = ["crates/server"]
resolver = "3"
With this setting, cargo run starts the server without needing -p app-server. The xtask crate and other library crates only compile when explicitly requested or pulled in as dependencies.
When to split into more crates
Start with fewer crates than you think you need. A single lib crate alongside server and xtask is a reasonable starting point. Split when you have a concrete reason:
- Compile times. A change in one module triggers recompilation of unrelated code. Splitting into separate crates isolates the rebuild radius.
- Dependency sprawl. A module pulls in heavy dependencies that most of the codebase does not need. Moving it to its own crate keeps those dependencies contained.
- Independent deployment. A Restate worker or CLI tool needs to share domain types with the web server but should not pull in Axum.
- Team boundaries. Different people or teams own different parts of the system and want clear interfaces between them.
Do not split pre-emptively. Each new crate adds a Cargo.toml to maintain and a boundary to design. Split when the cost of staying in one crate (slow builds, tangled dependencies) exceeds the cost of the boundary.
Feature unification
Cargo unifies features of shared dependencies across all workspace members. If the web crate enables sqlx/postgres and the jobs crate enables sqlx/uuid, both features are active everywhere. Features are additive, so this usually works fine. It becomes a problem only if two crates need genuinely incompatible configurations of the same dependency, which is rare in practice.
Resolver 3 (the default with edition = "2024") already avoids unifying features across different target platforms, which eliminates the most common source of unexpected feature activation.