Modules, Crates & Workspaces
mod, pub, and use turn a growing Rust project into one with a deliberate internal API, and Cargo workspaces extend that same discipline across multiple crates.
Every Rust program is organized as a tree of modules, starting from an implicit root: the crate itself. The mod keyword adds a node to that tree — mod front_of_house { ... } declares a module named front_of_house, and everything inside the braces (or, as the project grows, inside a separate file) belongs to it. This isn't just cosmetic folder-like grouping: the module tree is also the unit that Rust's privacy rules and path resolution both operate on, so understanding the tree is a prerequisite for understanding both.
Unlike many languages where items are accessible by default and you opt into hiding them, Rust defaults every item — functions, structs, enums, even struct fields individually — to private, visible only within the module that defines it and that module's descendants. You opt into visibility explicitly with pub. This inversion is deliberate: it forces you to decide, at the moment you write an item, whether it's part of the module's public API or an implementation detail, rather than accidentally exposing internals and finding out years later that half your codebase depends on them. pub on a struct doesn't even make its fields public by default — pub struct Rectangle { width: f64, height: f64 } is a publicly nameable type with two fields nobody outside the module can read or write directly, which is exactly how you'd force callers through a validating constructor instead.
use doesn't change what's visible; it just saves you from typing a long path every time you want to name something. Rust's convention makes a deliberate distinction depending on what you're importing: for functions, you typically bring the parent module into scope and call hosting::add_to_waitlist(), so the call site still hints that the function lives elsewhere — for structs, enums, and traits, you typically bring the type itself into scope, writing use std::collections::HashMap; so you can just write HashMap::new() afterward. Paths can be written from the crate root (an absolute path) or relative to the current module using self or super to step up one level — the same three ways you can refer to any item, whether inside a use statement or directly in code.
A crate is a binary or a library, and the difference is about more than just whether it has a fn main. A binary crate (src/main.rs) compiles to an executable and is the thing that actually runs; a library crate (src/lib.rs) compiles to something meant to be linked into other crates and exposes its API through pub items, with no main of its own. A single package can contain both — one library crate plus one or more binary crates that depend on it — and this is the pattern experienced Rust projects converge on almost immediately: put the real logic in lib.rs where it's unit-testable and reusable, and keep main.rs a thin shell that parses arguments, wires things together, and calls into the library.
As a single file grows past a few hundred lines, splitting it into modules keeps related code together and unrelated code apart. mod garden; in main.rs, with no braces, tells the compiler that the module's contents live in a separate file — either src/garden.rs, or, if garden itself needs submodules, src/garden/mod.rs (older convention) or a src/garden/ directory alongside garden.rs (the layout most projects use today). The gotcha worth remembering: dropping a new .rs file into src/ does nothing on its own. Rust doesn't scan directories looking for source files to compile — every module has to be declared with mod somewhere reachable from the crate root, or the file is simply never compiled, which is a confusing silence to debug the first time you hit it.
Once a project outgrows a single crate — say, a core library, a CLI that depends on it, and an integration-test crate that exercises both — a Cargo workspace lets you manage them together instead of as unrelated directories. A top-level Cargo.toml with a [workspace] section and a members list ties the crates together under one Cargo.lock and one shared target/ directory, so a dependency used by two crates in the workspace is resolved to a single version and compiled once instead of twice. Each member crate still has its own Cargo.toml and behaves like a normal crate on its own; the workspace just coordinates them.
Put together, mod, pub, and use are how a Rust codebase grows an internal API instead of turning into one enormous file where everything can reach everything. That boundary is enforced by the compiler at zero runtime cost, the same way trait bounds and lifetimes are — Rust would rather catch a broken abstraction boundary at compile time than let it become a debugging problem in production. With this vocabulary in place, you're ready for the next lesson: reading and writing the kind of real-world Rust code — organized across modules, exposing a deliberate public surface — that smart pointers show up in constantly.
mod front_of_house {pub mod hosting {pub fn add_to_waitlist() {println!("added to waitlist");}}}mod back_of_house {pub fn fix_incorrect_order() {// `super::` steps back up to the parent module — this works the// same way in every Rust edition.super::front_of_house::hosting::add_to_waitlist();}}fn main() {front_of_house::hosting::add_to_waitlist();back_of_house::fix_incorrect_order();}
A small module tree built with mod and pub, called both from the crate root and from a sibling module using super:: to step back up.
use std::collections::HashMap;fn count_words(text: &str) -> HashMap<&str, u32> {let mut counts = HashMap::new();for word in text.split_whitespace() {*counts.entry(word).or_insert(0) += 1;}counts}fn main() {let counts = count_words("the quick brown fox jumps over the lazy dog the fox runs");println!("{:?}", counts);}
use brings a standard-library path into scope so the rest of the function can write the short name instead of the full path every time.
mod shapes {// The struct is public, but its fields are not: outside code can// only build one through `new`, which can enforce invariants.pub struct Rectangle {width: f64,height: f64,}impl Rectangle {pub fn new(width: f64, height: f64) -> Rectangle {Rectangle { width, height }}pub fn area(&self) -> f64 {self.width * self.height}}}fn main() {let rect = shapes::Rectangle::new(3.0, 4.0);println!("area: {}", rect.area());// This line would fail to compile if uncommented: `width` is private.// println!("{}", rect.width);}
A public struct with private fields: outside code can only construct a Rectangle through the pub new constructor, which is how item-level pub and field-level privacy work together for encapsulation.
🧠 Check your understanding
0/1 · 0/1 answered1. You add a new file src/tables.rs to a binary crate, expecting its public functions to become available elsewhere in the project. Nothing changes — the compiler doesn't even seem to notice the file. What's missing?