Project Structure

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 = true
use 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.