The Rust Engineering Handbook / Chapter 39
Workspaces and Multi-Crate Architecture
Decide when a module earns a crate boundary by pricing its API, dependency, feature, build, release, and ownership consequences.
Price the split before accepting the diff
A relay service has grown to 70,000 lines in one library crate. A design review proposes five new crates: relay-model, relay-storage, relay-routing, relay-observability, and relay-utils. The pull request is tidy. Each directory receives a manifest, imports become explicit, and five teams can be named as owners.
That is not yet an architecture argument.
The relevant question is not whether the source can be separated. It is whether each new boundary enforces a useful dependency direction strongly enough to pay for its permanent costs. Every crate adds a compilation unit, a package API between dependents, a feature and dependency edge, more metadata to load, and another place where compatibility assumptions can accumulate. It may also reduce invalidation, permit parallel work, isolate platform policy, or make a component independently reusable. Those benefits depend on graph shape and change patterns; the directory count does not establish them.
Review the proposed split as a set of edges:
| Proposed package | Contract it would own | Likely dependents | Evidence that favors a crate | Evidence that favors a module |
|---|---|---|---|---|
relay-model |
wire-neutral domain vocabulary | routing, storage, API | stable types used by several packages | representations still change with every storage edit |
relay-storage |
persistence capability and error translation | service only | backend/platform policy is independently tested | only one implementation and broad domain reach-through |
relay-routing |
routing decision from validated input | service, simulator | reusable deterministic policy with a small API | tightly coupled to process configuration |
relay-observability |
telemetry adapters | every package | independently optional integration surface | invites global initialization and reverse dependencies |
relay-utils |
no coherent invariant | every package | none | common-code gravity creates a cycle magnet |
The default is still a private module. Reverse that default when the candidate boundary has a narrow contract, a direction you want Cargo to reject when violated, and a measurable or operational reason for independent compilation policy. Reject relay-utils immediately: a crate named after reuse rather than responsibility usually centralizes low-level and high-level concerns until the dependency graph points both ways.
A workspace coordinates packages; it does not merge them
A Cargo workspace is a set of packages managed from a common root. It gives common commands a package-selection domain, one root Cargo.lock, and a shared output directory by default. A workspace can also centralize selected package metadata, dependency declarations, and lints. It does not make member crates one namespace, relax visibility, permit dependency cycles, or give all members the same public API.
The chapter fixture uses a virtual workspace: the root manifest has [workspace] but no [package].
[workspace]
members = [
"apps/relayd",
"crates/relay-core",
"crates/relay-protocol",
"crates/relay-service",
]
default-members = ["apps/relayd"]
exclude = ["vendor/tracefmt-v1", "vendor/tracefmt-v2"]
resolver = "3"
members defines the coordinated package set. Globs can be useful, but an explicit list is easier to audit when experimental tools, incompatible examples, or vendored source live under the same repository. Path dependencies beneath the workspace root can become members automatically; exclude makes the fixture’s two local source packages deliberately external to the member set.
default-members controls what an unqualified package command at the workspace root selects. It is an ergonomic default, not a verification boundary. Continuous integration should say --workspace when it means every member, or enumerate an intentional subset. Otherwise a green root cargo test can mean only the application package passed.
A non-virtual root can be both a package and a workspace. That shape suits a small application whose root package is meaningful. A virtual root makes the coordination layer explicit and prevents source targets from accumulating beside the workspace policy. Because a virtual root has no package edition from which Cargo can infer a resolver, set resolver explicitly. This Rust 2024 fixture uses resolver version 3 and declares an MSRV of Rust 1.85.
Cargo searches parent directories for a workspace root when invoked inside a member. The package.workspace key can point to a root when the normal ancestor search is not sufficient. This discovery behavior is a tooling convenience; repository tooling should still pass --manifest-path or set a known working directory when ambiguity would be dangerous.
Inherit policy without pretending packages are identical
Workspace inheritance removes repeated declarations while leaving each member a distinct package:
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
publish = false
[workspace.dependencies]
relay-core = { path = "crates/relay-core", version = "0.1.0" }
relay-protocol = { path = "crates/relay-protocol", version = "0.1.0" }
[workspace.lints.rust]
unsafe_code = "forbid"
A member opts into inherited fields:
[package]
name = "relay-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
relay-protocol.workspace = true
[lints]
workspace = true
This centralization has three good uses. It prevents version or license drift when packages share one release policy. It creates one review point for internal dependency locations and baseline lints. It makes exceptional packages visible because they decline inheritance or override a supported field.
Do not centralize facts that are not shared. A workspace can contain tools, libraries, services, and generated-code packages with different release audiences and MSRVs. Forcing one version or publication setting onto them can turn convenient inheritance into false coupling. Workspace dependency declarations also do not add a dependency to every member. A member must opt in, and may add supported features to that inherited dependency. Read the member manifest to learn its actual edges.
Root-only policy matters. Profiles and patch declarations are interpreted at the workspace root. Placing an apparently local release profile in a member manifest does not create a package-specific optimization regime. If packages need genuinely incompatible build or resolver policy, separate workspaces may be the honest boundary.
The crate graph must remain a DAG
Each package dependency induces one or more crate compilation edges. Cargo requires the package dependency graph to be acyclic. If relay-core depends on relay-service, while relay-service already depends on relay-core, neither workspace membership nor a facade can legalize the cycle.
The fixture’s intended direction is:
relay-protocol -> relay-core -> relay-service -> relayd
└──────────────────────> relay-service
Here arrows mean “is depended on by”: relay-service imports relay-core and relay-protocol, while relayd imports relay-service. Reading arrows consistently prevents architecture diagrams from reversing the build relation halfway through a review.
Cycles usually reveal one of four design problems:
- Vocabulary is owned too high. Move narrow shared types or traits to a lower-level package such as
relay-protocol, but do not dump every domain type there. - Policy calls infrastructure directly. Invert the dependency with a capability trait owned by the policy side, then implement it in the adapter side.
- Two packages are one reason to change. Merge them and restore a module boundary until a real seam emerges.
- Process wiring leaked downward. Keep construction, global telemetry setup, environment parsing, and shutdown orchestration in the binary or application layer.
Splitting a “common” crate is not an automatic repair. If both sides depend on a common package that knows their concrete types, the cycle has only moved conceptually. The lower package must own stable vocabulary or an abstraction that does not import either higher layer.
Inspect Figure 39-1 from left to right. The graph is one-directional even though all packages share build coordination. The lockfile and target directory surround the graph operationally; they do not erase the API, build, or feature cost of each arrow.

Figure 39-1. A workspace shares coordination artifacts, but every directed crate edge remains an API, compilation, and configuration commitment.
A crate-boundary decision needs a reversing condition
Use a decision matrix to make the default and the evidence explicit:
| Decision pressure | Start with a module when… | Split a crate when… | Evidence that can reverse the choice |
|---|---|---|---|
| Encapsulation | restricted visibility enforces the seam | illegal dependency direction must fail across a compilation boundary | dependency and visibility audit |
| Reuse | only one package consumes the code | several real packages need a stable narrow API | caller inventory and API-change history |
| Build performance | edits cross the proposed seam frequently | stable lower crates avoid repeated downstream work or enable useful parallelism | clean and incremental timing traces |
| Platform policy | all code shares targets and dependencies | one component supports no_std, Wasm, native code, or distinct targets |
target matrix and manifest diff |
| Feature policy | behavior changes together | an optional integration should not enter unrelated builds | resolved feature graph |
| Release policy | code ships as one product | external consumers require a separately versioned package | consumer and compatibility plan |
| Ownership | review rules can own modules | API review between groups is a deliberate governance gate | ownership map and escalation record |
The default starting point is the module. The dimension most likely to reverse it is an independently useful contract—especially a dependency direction, target constraint, or reusable API that ordinary module privacy cannot enforce. File count is absent because navigation is not an architectural constraint.
Record the decision as a hypothesis. “Split routing into relay-routing because its API has changed twice in six months, three binaries reuse it, and 80% of edits to adapters should not invalidate it” is testable. “Split routing because it is large” has no success criterion.
Build graphs create opportunities and invalidation
A crate boundary can improve build behavior, but two mechanisms pull in opposite directions.
First, independent crates give Cargo and rustc more scheduling units. On a clean build, unrelated branches of a sufficiently broad graph may compile in parallel. On an incremental build, an unchanged dependency may reuse prior artifacts while a changed leaf rebuilds alone. A narrow stable protocol crate can therefore reduce repeated work across several applications.
Second, each crate adds fixed work: manifest and metadata processing, rustc invocation, dependency metadata, possible code generation, and link inputs. A long chain limits parallelism because each node waits on the preceding node’s metadata. Changing the public output of a low-level crate can invalidate every downstream package. Generics and macros can shift work into dependents, so a small source diff may still trigger broad compilation.
Do not predict the outcome from crate count. Measure at least:
- a clean workspace build after removing the relevant target artifacts;
- a no-op build;
- an incremental edit confined to a leaf package;
- an implementation-only edit in a lower package;
- a public API or public generic change in a lower package;
- the critical path and concurrency shown by Cargo build timings;
- peak memory and link time on representative developer and CI machines.
Keep toolchain, target, profile, feature set, cache state, hardware, and command constant. Compare medians across repeated runs, but do not turn noisy workstation timings into a universal claim. Build performance is one input to the split, not proof that the conceptual boundary is sound.
A shared target/ directory enables artifact reuse among members built with compatible settings. Different compiler flags, target triples, profiles, or feature selections can produce distinct artifacts within it. The shared directory is not evidence that every member compiled once, nor is it safe to let mutually untrusted jobs write to the same cache without considering cache integrity and isolation.
Cross-crate APIs are harder than private module seams
Inside one crate, a private refactor can change types and paths together. Across crates, even unpublished internal packages compile against a public Rust surface because pub(crate) stops at the crate boundary. That surface deserves API design:
- own domain types on the side whose invariant they express;
- avoid returning adapter representations from lower-level policy packages;
- keep constructors capable of establishing invariants;
- use traits for genuine dependency inversion, not one-trait-per-function ceremony;
- keep error translation at a boundary where callers can act on the categories;
- test the API from a dependent crate rather than relying only on unit tests.
An internal package can change atomically with all workspace callers, so it need not promise external SemVer stability. It still incurs migration cost across the repository and may become an accidental public surface through copied examples, generated bindings, or downstream Git dependencies. Mark non-published packages with publish = false, document intended callers, and distinguish “internal” from “unreviewed.”
A published crate adds a different contract. Its package name, version, enabled-by-default behavior, public types, trait implementations, MSRV, license metadata, and dependency exposure affect consumers that do not update atomically. A path dependency can carry both path and version: local workspace builds use the path, while the version provides a registry requirement when publishing. Publication requires an acyclic release order in which dependencies are available before dependents.
Do not split a public crate merely so teams can release independently. If the crates always change together and expose each other’s types, multiple versions create choreography without autonomy. A single facade can present one product vocabulary while internal packages remain separate, but it must curate rather than wildcard-export the decomposition.
Features flow along dependency edges
Feature selection belongs to packages and propagates through dependency relationships. A workspace is not a collection of hermetic feature universes. If two members in one resolution enable different additive features of the same dependency version, Cargo may build that package with their combined requested features according to the active resolver and command graph.
That matters when introducing a crate boundary:
- a new edge can carry optional dependencies into more build graphs;
- a facade can unintentionally enable expensive defaults for every caller;
- a feature used as a mutually exclusive backend switch can fail when requests combine;
- build scripts, proc macros, and target dependencies can complicate which feature request applies where;
- testing one package alone may not represent the feature set selected by a workspace-wide command.
Keep features additive: enabling one should add capability rather than remove or silently replace another. Use default-features = false deliberately at an edge when the dependency documents that configuration, then name the features the caller requires. Inspect the resolved graph with cargo tree -e features rather than inferring it from one manifest.
Chapter 41 develops the resolver and SemVer consequences in detail. The architectural obligation here is to include feature flow in the boundary price. A crate split is incomplete until reviewers can state which edge selects each optional capability and which commands test the supported combinations.
Repository topology and ownership are separate decisions
One repository and one workspace often fit tightly coordinated Rust systems. Atomic changes can update a lower API and all callers, one CI command can validate the graph, and a root lockfile can define an application snapshot. The cost is broad checkout and CI scope, plus governance pressure when many teams share root policy.
Several repositories can fit genuinely independent products, security domains, release schedules, or access controls. That topology replaces atomic source changes with published versions, compatibility windows, upgrade automation, and integration testing. Moving a package to another repository does not remove its dependency; it converts a source edge into a versioned coordination edge.
Nested or multiple workspaces in one repository are appropriate when packages require incompatible toolchains, target policy, dependency resolution, or release lifecycles. They also reduce workspace-wide command coverage. Supply one root orchestration command and make each lockfile and verification boundary explicit.
Team ownership should follow invariants and review competence, not mechanically dictate crate count. A CODEOWNERS rule can protect a module. A crate can still have several owners. Useful governance includes:
- an owner for each public cross-crate contract;
- review rules for new dependency edges and root manifest changes;
- an architecture test or metadata query that rejects forbidden directions;
- a deprecation and migration policy for high-fan-out APIs;
- build-cost ownership for proc macros, native dependencies, and default features;
- escalation when organizational reporting lines conflict with technical dependency direction.
Avoid mirroring an org chart. Teams reorganize faster than durable domain boundaries, and bidirectional business coordination does not justify a Cargo cycle.
Failure patterns in multi-crate systems
Crate confetti. Dozens of tiny packages add invocations and APIs but enforce no useful direction. Merge packages that always change and release together; retain modules for navigation.
The common sink. common, shared, or utils accumulates types from every layer. Replace it with invariant-owned vocabulary or move helpers to their only real caller.
The nominal boundary. Two crates expose each other’s representations and change in lockstep. Either redesign a narrow interface or admit they are one component.
The long build chain. A strictly layered graph looks clean but serializes compilation. Look for stable vocabulary that can sit low, independent branches that can remain peers, and unnecessary facade hops.
The feature sluice. A high-level package enables a broad default feature set on a low-level dependency, and the capability appears in unrelated binaries. Audit cargo tree -e features from each product root.
The published internal. A package is published for convenience without consumer, SemVer, MSRV, documentation, or deprecation policy. Keep it non-published until an external contract exists.
The CI default-members gap. Root CI tests only the default application. Use explicit --workspace, target, and feature matrices for the contract being claimed.
The org-chart DAG. Packages map to teams, but runtime and domain dependencies still cross in both directions through callbacks and shared types. Model technical ownership first; express human review separately.
Review a boundary as an architecture record
For every proposed crate, record:
- the invariant and reasons to change it owns;
- its callers and dependencies, with arrow direction defined;
- the public types, traits, errors, and side effects at each edge;
- why a private module is insufficient;
- internal or published status and the versioning consequence;
- target, MSRV, feature, build-script, and native-dependency policy;
- clean and incremental build hypotheses plus measurement commands;
- owner, API reviewers, and forbidden dependency directions;
- a merge-back condition if evidence does not support the split.
This record changes a cosmetic refactor into a falsifiable design decision. A boundary can be useful even when it makes a clean build slower, provided it enforces a high-value contract. It can be harmful even when one benchmark improves, if it creates a sprawling public API and release choreography.
Exercise: split the monolith and estimate the bill
Level: Review board. A single relay-service package contains domain messages, routing, PostgreSQL adapters, Kafka adapters, HTTP administration, metrics export, and two binaries. Fifteen engineers work in three teams. Clean CI builds are slow, but most developer edits touch routing and one adapter. A firmware simulator wants to reuse routing without networking or allocation-heavy dependencies.
Constraints:
- use stable Rust 2024 and support the declared MSRV;
- the runtime service and simulator must not form a dependency cycle;
- global telemetry initialization remains in process wiring;
- do not assume a crate per team;
- no package may be published without a named external consumer and compatibility policy;
- quantitative conclusions require a measurement plan rather than invented timings.
Deliver:
- a before-and-after package DAG with arrows defined;
- a crate-boundary decision matrix for at least six candidate components;
- the public API at every accepted edge, including error and ownership choices;
- a cycle analysis showing how simulator reuse avoids importing service wiring;
- a clean/no-op/leaf-edit/lower-implementation/public-API build experiment with controlled variables;
- an incremental invalidation estimate stated as a hypothesis, not a result;
- internal versus published status,
publishpolicy, and release order for every package; - a feature-flow map for database, Kafka, metrics, and simulator capabilities;
- repository and workspace topology with one lockfile decision per product boundary;
- owners, API reviewers, forbidden edges, and one condition that would cause you to merge a proposed crate back into a module.
Evaluate the design by contract strength, DAG clarity, API surface, build evidence, feature containment, migration cost, and operational ownership. Multiple splits can be valid. Reject any answer based primarily on source-file count, team count, or an unsupported promise that more crates compile faster.
Durable conclusions
- A workspace coordinates packages with common commands, a root lockfile, and a shared target directory; it does not merge namespaces or permit cycles.
- Inherited metadata and dependency declarations centralize real shared policy, but members remain distinct packages with explicit edges.
- Start with a private module. Add a crate when an independently useful API, dependency direction, target policy, reuse boundary, or measured build behavior earns the cost.
- A crate graph is a DAG. Repair cycles by moving vocabulary downward, inverting capabilities, merging false boundaries, or returning process policy to the application layer.
- More crates can enable parallel or incremental reuse and can also add fixed work or a longer critical path. Measure representative edits.
- Internal cross-crate APIs still impose migration and review costs; published crates add consumer-facing version, MSRV, dependency, and release contracts.
- Features and optional dependencies flow along edges. Workspace membership does not make configuration hermetic.
- Repository boundaries and team ownership are governance choices layered on the technical DAG, not substitutes for it.
The workspace graph now states which packages may depend on which. The next question is more dynamic: given those manifest edges, which exact package versions and sources enter a build, and which changes may the application or library accept?
Sources and verification notes
- Cargo Reference, Workspaces, The Manifest Format, and Build Cache.
- Cargo Reference, Features, Specifying Dependencies, and SemVer Compatibility.
- Cargo commands,
cargo build,cargo metadata,cargo tree, and Build Timings. - Rust 2024 Edition Guide, Rust-version-aware Cargo resolver.
- Executable source:
examples/rust-engineering-handbook/part-07/workspace-resolution-lab/, a dependency-free virtual workspace with four Rust 2024 members andrust-version = "1.85". - Fixture provenance: Rust 1.97.0 formatting, workspace/all-target compilation, tests, doctests, Clippy, binary execution, metadata/tree inspection, and locked offline resolution; Rust 1.85.0 MSRV compilation and tests.
Continue reading
Full table of contents