Chapter 45: Crate Architecture, Workspaces, and Semver
Prerequisites
You will understand
- Workspace layout for multi-crate projects
- Semantic versioning and public API stability
- Feature flags and conditional compilation
Reading time
One Repository, Several Deliberate Crate Boundaries
Every Public Promise Radiates Downstream
Step 1 - The Problem
Writing good Rust inside one file is not the same as maintaining a crate other people depend on.
As soon as code becomes public, you inherit new failure modes:
- unstable module boundaries
- accidental public APIs
- breaking changes hidden inside innocent refactors
- feature flags that conflict across dependency graphs
- workspaces that split too early or too late
In less disciplined ecosystems, these problems are often handled by convention and hope. Rust’s tooling nudges you toward stronger release hygiene because the ecosystem depends heavily on interoperable crates.
Step 2 - Rust’s Design Decision
Cargo and the crate system make package structure part of everyday development rather than an afterthought.
Rust also treats semver seriously because public APIs are encoded deeply in types, trait impls, and features. A “small” change can break many downstream crates if you do not reason carefully about what was part of the public contract.
Rust accepted:
- more deliberate package boundaries
- feature and visibility discipline
- explicit release hygiene
Rust refused:
- hand-wavy public API management
- feature flags that arbitrarily remove existing functionality
- pretending a type-level breaking change is minor because the README example still works
Step 3 - The Mental Model
Plain English rule: your crate’s public API is every promise downstream code can rely on, not just the functions you meant people to call.
That includes:
- public items
- visible fields
- trait impls
- feature behavior
- module paths you export
- error types and conversion behavior
Workspaces are about shared development and release structure. They are not automatically proof of better architecture.
Step 4 - Minimal Code Example
[workspace]
members = ["crates/core", "crates/cli"]
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
[package]
name = "core"
version = "0.1.0"
edition = "2024"
[dependencies]
serde.workspace = true
Step 5 - Line-by-Line Compiler and Tooling Walkthrough
Cargo reads the workspace manifest first:
- it discovers member crates
- it resolves shared dependencies and metadata
- it builds a dependency graph across the workspace
- it runs requested commands across members in graph-aware order
When you expose items from lib.rs, you are shaping the crate’s stable face. Re-exporting an internal module path is not just convenience. It is a public commitment if downstream users adopt it.
That is why “just make it pub for now” is such a dangerous habit in library code.
Step 6 - Three-Level Explanation
A crate is a package of Rust code. A workspace is a set of crates developed together. Public APIs need more care than internal code because other people may depend on them.
Split crates when there is a real boundary:
- different release cadence
- independent reuse value
- heavy optional dependencies
- clear architectural separation
Do not split purely for aesthetics. Too many crates create coordination overhead, duplicated concepts, and harder refactors.
Feature flags should be additive. If enabling a feature removes a type, changes meaning, or breaks existing callers, you have created feature-driven semver chaos.
Semver in Rust is subtle because the public contract includes more than function signatures. Changing trait bounds, removing an impl, altering auto trait behavior, narrowing visibility, or changing feature-controlled item availability can all be breaking changes.
This is why tools like cargo-semver-checks exist. The goal is not ceremony. The goal is to catch type-level breaking changes that humans easily miss.
Anatomy of a Strong Crate
my_crate/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── error.rs
│ ├── config.rs
│ ├── parser.rs
│ └── internal/
├── tests/
├── examples/
├── benches/
├── README.md
└── CHANGELOG.md
Common architectural roles:
lib.rs: curate the public API, re-export intentionallyerror.rs: centralize public error surfaceinternal/or private modules: implementation detailstests/: integration tests that use only the public APIexamples/: runnable user-facing patterns
Feature Flags
Feature flags must be additive because dependencies are unified across the graph. If two downstream crates enable different features on your crate, Cargo combines them.
That means features are not build profiles. They are capability additions.
Good feature use:
- optional dependency integration
- extra formats or transports
- heavier convenience layers
Bad feature use:
- mutually incompatible behavior changes
- removing items under a feature
- changing semantics of existing items in surprising ways
What Counts as a Breaking Change?
Typical breaking changes include:
- removing or renaming public items
- changing public function signatures
- adding required trait bounds
- changing enum variants available to users
- making public fields private
- removing trait impls
- changing feature behavior so previously compiling code fails
- changing auto trait behavior such as
SendorSync
Even “harmless” changes like swapping a returned concrete type can be breaking if that type was public and relied on by downstream code.
cargo-semver-checks, CHANGELOG, and Publishing
For libraries, run semver validation before release. cargo-semver-checks helps compare the current crate against a prior release and surfaces API changes with semver meaning.
CHANGELOG.md matters because:
- contributors see what changed
- reviewers can track release intent
- users can assess upgrade impact
Publishing checklist:
- run tests, clippy, and docs
- audit public API changes
- verify feature combinations
- update changelog
- check README examples
- publish from a clean, intentional state
Workspaces in Real Projects
Multi-crate workspaces are common in serious Rust repositories:
tokiosplits runtime pieces and supporting cratesserdeseparates core pieces and derive support- observability stacks split core types, subscribers, and integrations
The pattern to learn is not “many crates is better.” It is:
split when the boundary is real, and keep the public surface of each crate intentionally small.
Step 7 - Common Misconceptions
Wrong model 1: “If a module path is public, I can change it later as an internal refactor.”
Correction: once downstream code imports it, it is part of the public contract unless you re-export compatibly.
Wrong model 2: “Feature flags can represent mutually exclusive modes.”
Correction: Cargo unifies features, so mutually exclusive flags are fragile unless designed very carefully.
Wrong model 3: “A workspace is just a monorepo.”
Correction: it is a Cargo-level coordination mechanism with dependency, command, and release implications.
Wrong model 4: “Semver is just version-number etiquette.”
Correction: semver is an operational promise about what downstream code may keep relying on.
Step 8 - Real-World Pattern
Well-shaped Rust libraries tend to:
- curate public exports from
lib.rs - keep implementation modules private
- isolate proc-macro crates when needed
- treat feature flags as additive integration points
- use integration tests to exercise the public API
That shape appears in major ecosystem projects because it scales maintenance, review, and release hygiene.
Step 9 - Practice Block
Code Exercise
Sketch a workspace for a project with:
- a reusable parsing library
- a CLI
- an async server
Decide which crates should exist and which dependencies belong at the workspace level.
Code Reading Drill
Open a real Cargo.toml and explain:
- what features it exposes
- whether they are additive
- which dependencies are optional
- where the public API likely lives
Spot the Bug
Why is this risky?
[features]
default = ["sqlite"]
postgres = []
sqlite = []
Assume enabling both changes runtime behavior in incompatible ways.
Refactoring Drill
Take a crate with many pub mod exports and redesign lib.rs to expose only the intended high-level API.
Compiler Error Interpretation
If a downstream crate breaks after you “only” added a trait bound, translate that as: “I tightened the public contract, so this may be a semver-breaking change.”
Step 10 - Contribution Connection
After this chapter, you can review and improve:
Cargo.tomlfeature design- workspace dependency sharing
- public re-export strategy
- changelog and release hygiene
- semver-sensitive public API changes
Strong first PRs include:
- tightening accidental public visibility
- making feature flags additive
- adding integration tests that pin public API behavior
- documenting release-impacting changes clearly
In Plain English
A crate is not just code. It is a promise to other code. Rust’s tooling pushes you to treat that promise seriously because once people depend on your types and features, changing them carelessly creates real upgrade pain.
What Invariant Is Rust Protecting Here?
Public APIs, features, and crate boundaries should evolve in ways that preserve downstream correctness and expectations unless a deliberate breaking release says otherwise.
If You Remember Only 3 Things
- Every public item, trait impl, and feature behavior is part of your crate’s contract.
- Workspaces help coordinate related crates, but they do not replace real architectural boundaries.
- Semver in Rust is type-level and behavioral, not just cosmetic version numbering.
Memory Hook
Publishing a crate is pouring concrete, not drawing chalk. Public API lines are easy to widen later and expensive to erase cleanly.
Flashcard Deck
| Question | Answer |
|---|---|
| What is a workspace for? | Coordinating multiple related crates under one Cargo graph and command surface. |
| Why must features usually be additive? | Because Cargo unifies enabled features across the dependency graph. |
| Name one subtle breaking change besides removing a function. | Removing a trait impl or adding a required trait bound. |
What is lib.rs often responsible for? | Curating and presenting the public API surface intentionally. |
| When should you split a project into multiple crates? | When there is a real architectural, dependency, reuse, or release boundary. |
What does cargo-semver-checks help detect? | Public API changes with semver implications. |
| Why do integration tests matter for libraries? | They exercise the public API the way downstream users do. |
Why is pub a stronger commitment than it feels? | Because downstream code may begin depending on anything you expose. |
Chapter Cheat Sheet
| Problem | Tool or practice | Benefit |
|---|---|---|
| Shared dependency versions across crates | [workspace.dependencies] | less duplication and drift |
| Accidental public API sprawl | curated lib.rs re-exports | smaller stable surface |
| Optional ecosystem integration | additive feature flags | composable dependency graph |
| Detect release-breaking API drift | cargo-semver-checks | semver-aware verification |
| Communicate user-facing release impact | CHANGELOG.md | upgrade clarity |