background-shape
Cargo Workspaces for Backend Services
March 11, 2022 · 6 min read · by Muhammad Amal programming

TL;DR — A Cargo workspace = root Cargo.toml listing member crates. Crates can be binaries (cmd/*) or libraries (crates/*). Shared deps in [workspace.dependencies] (Rust 1.64) or repeated per-crate (Rust 1.59). Workspace builds and tests every crate; per-crate targets keep iteration fast.

After the error handling post, the next thing to figure out is how to lay out a real project. Rust’s answer is the Cargo workspace: one repo, multiple related crates, shared dependencies, atomic build.

This post is the workspace layout I’m using for the Rust services I’m building this month. It mirrors the Go monorepo pattern from January but with Rust idioms.

The shape of a workspace

my-backend/
├── Cargo.toml              # workspace root
├── Cargo.lock              # one lockfile for the whole workspace
├── cmd/
│   ├── billing/
│   │   ├── Cargo.toml
│   │   └── src/main.rs
│   ├── notifications/
│   │   ├── Cargo.toml
│   │   └── src/main.rs
│   └── api-gateway/
│       ├── Cargo.toml
│       └── src/main.rs
└── crates/
    ├── shared/             # types + utilities shared across services
    │   ├── Cargo.toml
    │   └── src/lib.rs
    ├── billing-core/       # billing domain logic, independent of HTTP layer
    │   ├── Cargo.toml
    │   └── src/lib.rs
    └── notifications-core/
        ├── Cargo.toml
        └── src/lib.rs

cmd/* = binaries (have src/main.rs). crates/* = libraries (have src/lib.rs). Same convention as Go’s cmd/ for entry points.

Workspace root Cargo.toml

[workspace]
resolver = "2"
members = [
    "cmd/billing",
    "cmd/notifications",
    "cmd/api-gateway",
    "crates/shared",
    "crates/billing-core",
    "crates/notifications-core",
]

[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true

[profile.dev]
opt-level = 0
debug = true

A few specifics worth highlighting.

resolver = "2". The Rust 2021 edition’s feature resolver. Handles per-target features correctly (e.g., a dep only used in tests doesn’t pollute prod feature set). If you’re on Rust 1.51+, set this. Old workspaces without it inherit weird feature merging behaviour.

profile.release tuning. lto = "thin" enables thin link-time optimization — meaningful perf win, modest compile-time cost. codegen-units = 1 further increases optimization potential at the cost of slower compiles (production builds only). strip = true strips symbols from the final binary; ~30% smaller release binaries.

profile.dev. Default is fine for inner-loop dev. Keep it fast.

Per-crate Cargo.toml — binaries

# cmd/billing/Cargo.toml
[package]
name = "billing"
version = "0.1.0"
edition = "2021"

[dependencies]
billing-core = { path = "../../crates/billing-core" }
shared = { path = "../../crates/shared" }

tokio = { version = "1.17", features = ["macros", "rt-multi-thread", "signal"] }
axum = "0.4"
tower-http = { version = "0.2", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

[dev-dependencies]
reqwest = { version = "0.11", features = ["json"] }

The binary crate pulls in the workspace-local libraries via path = "../../crates/...". External deps are listed normally.

Per-crate Cargo.toml — libraries

# crates/billing-core/Cargo.toml
[package]
name = "billing-core"
version = "0.1.0"
edition = "2021"

[dependencies]
shared = { path = "../shared" }

sqlx = { version = "0.5", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "0.8", features = ["v4", "serde"] }
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"

[dev-dependencies]
tokio = { version = "1.17", features = ["macros", "rt-multi-thread"] }

Library crates declare what they need; no Tokio runtime in here because the lib itself doesn’t choose a runtime. Tests that need async pull Tokio in [dev-dependencies].

Sharing common dependencies

In Rust 1.59 (current), you repeat dependency versions across crates. It’s repetitive but unambiguous. Use a workspace-level lockfile (automatic with workspaces) so Cargo resolves a single version across crates.

Rust 1.64 (later in 2022) introduces [workspace.dependencies]:

# Rust 1.64+ — not yet stable in March 2022
[workspace.dependencies]
tokio = { version = "1.17", features = ["macros", "rt-multi-thread"] }
serde = { version = "1.0", features = ["derive"] }

Then per-crate:

[dependencies]
tokio = { workspace = true }
serde = { workspace = true }

For now (March 2022) we live with the repetition.

Cargo commands in a workspace

# Build all crates
cargo build

# Build one crate (faster — only its deps)
cargo build -p billing

# Test all crates
cargo test

# Test one crate
cargo test -p billing-core

# Run a binary
cargo run -p billing

# Check all crates without producing binaries (fastest)
cargo check

# Release build, one binary
cargo build --release -p billing

# Format all crates
cargo fmt --all

# Lint all crates
cargo clippy --all-targets -- -D warnings

-p <crate> is the most important flag for daily work. Without it, every cargo invocation tries to build the whole workspace, which is slow for a multi-crate setup.

Dependency layering

The discipline that keeps a workspace healthy:

  • shared depends on nothing of yours
  • *-core crates depend on shared and external deps only
  • cmd/* binaries depend on their corresponding *-core crate + shared + transport deps (axum, tokio)

In a diagram:

cmd/billing ──→ billing-core ──→ shared
            └→ shared

cmd/notifications ──→ notifications-core ──→ shared

Forbidden:

  • billing-core depending on notifications-core (cross-service via shared events, not direct dep)
  • shared depending on billing-core (cycles)
  • cmd/billing depending on notifications-core (no cross-service binaries)

When you find yourself wanting to break this, you usually need to move something into shared instead.

Workspace tests at the top level

For integration tests that exercise multiple crates, add a top-level tests/ directory:

my-backend/
├── tests/
│   └── integration/
│       ├── Cargo.toml      # member of workspace
│       └── tests/
│           └── billing_notifications_flow.rs

tests/integration/Cargo.toml lists billing-core, notifications-core, shared as dependencies and runs cross-cutting tests via cargo test -p integration.

For unit tests that test only one crate, keep them inline next to the code (#[cfg(test)] mod tests { ... }).

Compile-time tips

Rust’s compile times are real. A few tricks that help in a workspace:

cargo check instead of cargo build for editor feedback. Skips codegen. 2–4× faster for inner-loop “does this compile?”

Run tests on the crate you changed. cargo test -p billing-core instead of cargo test. Massive time saver.

Use sccache for cross-build caching. Caches rustc outputs in a shared local or remote store. Helps in CI especially.

Avoid one giant crate. Cargo parallelises by crate. A workspace of 10 small crates compiles faster than one huge crate, all else equal.

Cold compiles still take minutes. Live with it. Find a way to keep the editor’s cargo check warm.

Common Pitfalls

Putting *-core logic in cmd/*. Then it’s not reusable, can’t be tested without spinning up the binary, and bloats the binary’s build graph.

Cyclic deps within the workspace. Cargo will refuse to compile. Refactor to push shared types into shared.

Different versions of the same external dep across crates. Workspace lockfile usually resolves to one version, but if features differ you get separate compiles. Keep dep versions consistent.

No [profile.release] tuning. Default release profile is fine but not great. The LTO + single-codegen-unit + strip combo is worth the setup once.

Forgetting resolver = "2". You’ll hit weird feature unification problems. Always set it for new workspaces.

Treating the workspace lockfile like a Go go.mod. It’s not per-crate; it’s shared. Touching one crate’s version requirements can move other crates’ resolved versions.

Wrapping Up

A workspace with cmd/* binaries and crates/* libraries, layered with discipline, scales to any number of services in one repo. Compile times are slower than Go but tolerable with cargo check and per-crate scoping. Monday’s post: async Rust with Tokio — the runtime everything in this stack actually runs on.