For seven parts, your adversary was the compiler. From this part on, the adversary is the project itself: code grows from three files to three hundred, and “where does this live,” “who may see it,” and “how do we split it” start costing more time than “how do I write it.” Most languages hand you four or five tools at this point — a build system, a package manager, a linter, a doc generator, a test runner, each with its own agenda; Rust hands you one command, cargo, and an ecosystem that shares the same conventions. Part 1 called the toolchain “a solved problem”; this part unfolds that claim into your daily working style: how modules organize, how visibility opens, how big projects split, how conditional compilation works, and the commands tutorials never dwell on but your workday runs on.
Crates and the module tree: files don’t join the build by existing
First, disambiguate three words that get mushed together. A package is the unit governed by Cargo.toml — the unit of publishing and building. A crate is a compilation unit — at most two per package: a binary crate (rooted at src/main.rs) and a library crate (rooted at src/lib.rs), which may coexist, and that’s the idiomatic layout. A module is the tree-shaped organization inside a crate, its nodes wired by mod declarations.
That last word deserves emphasis: files don’t join the compilation automatically. Creating src/net.rs does nothing until some ancestor writes mod net; — the module tree is declared, not directory-scanned. This explicit wiring is the same philosophy as Part 3’s lifetimes and Part 7’s use<>: important relationships get written down.
A minimal idiomatic layout you can copy:
// src/lib.rs — the library's front door
pub mod config; // public module: part of the library's API
mod net; // private module: implementation detail
pub fn run() {
net::connect(&config::load());
}
// src/config.rs
pub struct Config { pub url: String }
pub fn load() -> Config {
Config { url: "https://api.internal".into() }
}
fn secret_sauce() {} // invisible outside this module — private by default
// src/net.rs
use crate::config::Config;
pub(crate) fn connect(cfg: &Config) { // usable anywhere in this crate, invisible outside
println!("connecting to {}", cfg.url);
}
// src/main.rs — the binary is a thin shell over the library
fn main() {
mytool::run();
}
Visibility is a three-rung ladder: private (default) → pub(crate) → pub. The engineering habit: open the minimum rung that compiles — every rung upward is a promise made to the outside. Calling mytool::net::connect from the binary is flatly rejected (error[E0603]: module net is private), which is exactly what you want: the library’s boundary is guarded by the compiler, and internal refactors can’t silently break downstream. For paths, two prefixes cover daily life: crate:: starts from the current crate’s root (use it for all in-library references), and use pulls long paths into scope.
Workspaces: one repository, many crates
When one package can’t hold it anymore — a service split into protocol layer, core logic, and CLI shell; a fleet of microservices sharing internal libraries — you’re in workspace territory. The organizing principle in one sentence: crates stay small and focused; the workspace keeps them in lockstep.
# shop/Cargo.toml — the workspace root; it may hold no code at all
[workspace]
resolver = "3"
members = ["crates/core", "crates/api"]
[workspace.dependencies]
mytool = { path = "../mytool" }
Three benefits that pay out immediately:
- One
Cargo.lock, one dependency graph. Dependency versions resolve uniformly across every member crate — no phantom split where api builds against serde 1.0.188 while core builds against 1.0.190. - One shared
target/. The build cache is shared by the whole family: core compiles once, api reuses it. In a large project that’s the difference between minutes and seconds. [workspace.dependencies]pins versions once. Members writemytool = { workspace = true }to inherit the root’s definition; a version bump touches one line.
Daily operations all run from the root: cargo run -p api (run a named member), cargo test --workspace (test everything), cargo build --release. What’s the signal that a module deserves to become a workspace crate? When it has its own error types, its own test suite, and a boundary of “nobody should care if the implementation changes” — every instinct parts 4 through 7 taught you, magnified into project structure.
Feature flags: switches that only turn things on
Conditional compilation in Rust is declarative. Define switches in Cargo.toml, wire them in code with #[cfg]:
[features]
default = ["json"]
json = []
verbose = []
#[cfg(feature = "verbose")]
pub fn log(msg: &str) { println!("[verbose] {msg}"); }
#[cfg(not(feature = "verbose"))]
pub fn log(_msg: &str) {}
Run all three builds — cargo build, cargo build --features verbose, cargo build --no-default-features — and note that cfg-gated code with the switch off doesn’t participate in compilation at all, not even syntax checking. Dependencies can be optional too (the metrics = ["dep:atomic"] style), entering the dependency graph only when the feature is on.
The rule that matters most: features are additive — they may add capability, never remove it. Why? Because a crate compiles exactly once per dependency graph — when two of your dependencies each enable different features of the same library, cargo takes the union. If a feature could switch something off, union semantics collapse. Two practices follow: keep default minimal (every feature in default is compile time every user pays, wanted or not); and add cargo check --all-features to CI as a safety net for the bugs that only appear when two features are on together.
Workday cargo: the commands tutorials skip
cargo checkis your daily driver, notcargo build. check stops after type checking and skips codegen, running an order of magnitude faster. The coding loop is: edit → check → edit. Save build for when you actually need to run something.cargo clippyandcargo fmtgo into CI, no exceptions. clippy is the other personality of Part 1’s “pedantic mentor” — hundreds of lints teaching you idioms;cargo fmtends every style argument forever. One line each in CI buys back tons of review friction.cargo testhas filters.cargo test configruns only tests with “config” in the name;cargo test -- --nocapturereleases swallowedprintln!output. And code blocks inside doc comments run as tests (doc tests) — your examples can never silently rot.- The dependency trio.
cargo add serde(no more hand-editing Cargo.toml),cargo tree(who dragged this boulder into my dependency graph),cargo update(upgrade within semver ranges and rewrite the lock). - Benchmarks and releases are always
--release. The debug-to-release optimization gap in Rust easily reaches 10–100× — benchmarking a debug build is the most common own-goal in the language, bar none.
Practice, then Part 9
- Refactor any earlier part’s exercise into the lib + bin layout: all logic into
lib.rs,main.rsreduced to “parse arguments, callrun().” Feel what the thin shell buys you — logic that can now be tested and reused by other crates. - Manufacture an E0603: call the library’s private module directly from
main.rsand read the full error; then fix it once withpuband once withpub(crate), feel the difference in what each promises, and leave the smallest rung that suffices. - Add a
verbosefeature to your project: gate a piece of logging behind#[cfg], run all three build combinations. Then write three lines of comment above[features]: what’s in default, why, and what the additive rule means for this project — documentation for the you of six months from now.
Part 9 returns to the depths of the language to cash the check Part 2 wrote: concurrency. How the two marker traits Send and Sync make data races unwritable at compile time, how scoped threads solve the lifetime puzzle of “threads borrowing stack data,” Arc<Mutex<T>> as the multi-threaded counterpart of Rc<RefCell<T>>, and the “share memory by communicating” philosophy behind channels. The borrow checker’s most glorious victory is next.