Learn Rust Series (#9) - Modules & Crates
Learn Rust Series (#9) - Modules & Crates
What will I learn
- You will learn the difference between a crate and a module, and when to reach for each;
- how to carve code into modules, split them across files, and navigate them with paths;
- how privacy works, and why Rust's private-by-default rule is a gift rather than a nuisance;
- how to pull in an external dependency with Cargo, and where those crates come from;
- what re-exports and workspaces are for, so a growing project stays tidy in stead of turning into one giant file.
Requirements
- A working Rust toolchain (via rustup) from episode 1;
- A terminal and an editor (VS Code with rust-analyzer remains a fine pick);
- Episodes 1 to 8 read, because today we organize the very structs, enums, traits, and generics we have been building all series long.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates (this post)
Learn Rust Series (#9) - Modules & Crates
Welcome back! For eight episodes now, every program we have written has lived in a single main.rs, and honestly that has been fine. Ownership, pattern matching, structs, error handling, collections, and last time traits and generics -- all of it fits comfortably in one file while you are learning one idea at a time. But real programs do not stay small. The moment you have got fifteen structs, a fistful of traits, and a couple hundred functions, one file becomes a wall of scrolling that nobody, including future-you, wants to read. Today we learn how Rust keeps that from happening: modules for carving one crate into tidy named pieces, and crates for pulling in code other people wrote. This is the episode that turns "I can write a Rust file" into "I can structure a Rust project".
I promised at the end of last episode we would stop the power we built from turning into a single thousand-line mess, and that is exactly the job. There is no new language feature here as brain-bending as ownership -- modules are, at heart, just a naming and visibility system. But get them wrong and a project feels like wading through mud; get them right and everything has an obvious home. So this one is less about mind-blowing and more about the quiet craft of keeping code navigable. Let us clear last time's homework first, then build up the vocabulary.
Solutions to Episode 8's exercises
Three exercises last time, all leaning on traits and generics. Here they are, worked in full.
Exercise 1 asked for a generic smallest, mirroring largest but returning the minimum:
fn smallest<T: PartialOrd + Copy>(items: &[T]) -> T {
let mut min = items[0];
for &n in items {
if n < min {
min = n;
}
}
min
}
fn main() {
println!("{}", smallest(&[5, 2, 9, 1, 7])); // 1
println!("{}", smallest(&[3.3, 0.5, 8.1])); // 0.5
}
This was the point I wanted to land: the entire difference from largest is one character, > becomes <. The generic machinery -- the <T: PartialOrd + Copy> bound, the borrow-in slice, the Copy that lets us pull items[0] out by value -- all of it carries over untouched. That is the payoff of writing against a placeholder type rather than a concrete one: swap the comparison and the same skeleton serves a completely different purpose, for i32, f64, char, or any future type that can be ordered and copied.
Exercise 2 wanted an Area trait with two implementors and a generic print_area:
trait Area {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }
impl Area for Circle {
fn area(&self) -> f64 {
3.14159 * self.radius * self.radius
}
}
impl Area for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn print_area<T: Area>(shape: &T) {
println!("area = {:.2}", shape.area());
}
fn main() {
print_area(&Circle { radius: 2.0 }); // area = 12.57
print_area(&Rectangle { width: 3.0, height: 4.0 }); // area = 12.00
}
Two unrelated shapes, one shared contract, and a generic function that accepts either because both satisfy the Area bound. Notice print_area never needs to know which shape it got -- it only knows the thing in front of it can produce an area(). That is the traits-as-bounds pattern from last episode, put straight to work.
Exercise 3 was the one to linger on: extend Area with a default method so every implementor gets describe() for free:
trait Area {
fn area(&self) -> f64;
fn describe(&self) -> String {
format!("this shape has area {:.2}", self.area())
}
}
struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }
impl Area for Circle {
fn area(&self) -> f64 {
3.14159 * self.radius * self.radius
}
}
impl Area for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
let c = Circle { radius: 1.0 };
let r = Rectangle { width: 2.0, height: 5.0 };
println!("{}", c.describe()); // this shape has area 3.14
println!("{}", r.describe()); // this shape has area 10.00
}
Neither struct writes a single line of describe -- yet both have it, built on top of the area() each one did implement. This is the exact leverage the standard library leans on constantly: define a small required core, then ship a mountain of default behavior derived from it. Keep that mental picture; it explains half of what feels magical about Rust's Iterator. Right, homework cleared. Let us talk about where all this code should actually live.
Crate versus module: the two units of organization
Two words get thrown around and beginners mix them up constantly, so let me pin them down cleanly.
A crate is the unit of compilation and distribution. It is the largest chunk Rust thinks about at once. Every project you have built this series is a crate -- specifically a binary crate, one that compiles to a runnable executable with a main function. There are also library crates, which compile to reusable code with no main, meant to be pulled into other crates. When you eventually publish something for others to use, you publish a library crate. Vec, HashMap, println! -- all of that lives in the standard library, which is just a crate (well, a few) that ships with Rust and is available everywhere.
A module, by contrast, is a unit of organization within a crate. Modules do not compile separately or get distributed on their own; they are how you group and name things inside one crate so the code stays navigable and the visibility stays controlled. One crate can hold dozens of modules. Think of it like this: the crate is the book, and modules are its chapters and sections. You do not ship a chapter to a friend -- you ship the book -- but the chapters are what keep the book readable.
Python folks, a rough analogy: a crate is like an installable package (the thing pip install gives you), and a module is like a .py file or a namespace inside it. The analogy is not exact -- Rust modules do not have to map one-to-one onto files -- but it gets you in the right headspace. Right, let us actually make a module.
Your first module: mod
You declare a module with the mod keyword and a name, then put things inside its braces. Everything defined inside is namespaced under that module's name, reached with the :: path operator:
mod math {
pub fn square(x: i32) -> i32 {
x * x
}
pub fn cube(x: i32) -> i32 {
// functions in the same module can call each other freely
x * square(x)
}
}
fn main() {
println!("{}", math::square(5)); // 25
println!("{}", math::cube(3)); // 27
}
We defined a module math holding two functions, and from main we call them with math::square(...) and math::cube(...). Inside the module, cube calls square with no prefix at all, because they share the same namespace -- siblings see each other directly. From outside the module (that is, from main) we need the math:: prefix, and -- crucial detail -- the functions had to be marked pub. Leave off pub and the compiler slams the door, which brings us to the single most important idea in this whole episode.
Privacy: private-by-default, and why that is a feature
Here is the rule that trips up everyone arriving from Python or JavaScript: in Rust, everything is private by default. A function, struct, field, or module is visible only within the module that defines it (and that module's children) unless you explicitly mark it pub (public). If I had written fn square in stead of pub fn square above, main could not call it -- compile error, flat refusal.
Newcomers often find this annoying at first ("why do I have to type pub everywhere?"), but flip it around and it is a genuine superpower. Private-by-default means the public surface of your code -- the parts other code can touch -- is exactly and only what you deliberately exposed. Everything else is an internal detail you are free to rename, rewrite, or delete without breaking a single caller, because no caller was ever allowed to reach it. In a big codebase that freedom is worth quit some gold. Watch it protect an invariant:
mod bank {
pub struct Account {
pub owner: String,
balance: f64, // NOT pub -- private field, invisible outside this module
}
impl Account {
pub fn new(owner: &str) -> Account {
Account { owner: owner.to_string(), balance: 0.0 }
}
pub fn deposit(&mut self, amount: f64) {
self.balance += amount;
}
pub fn balance(&self) -> f64 {
self.balance
}
}
}
fn main() {
let mut acc = bank::Account::new("scipio");
acc.deposit(100.0);
println!("{} has {}", acc.owner, acc.balance()); // scipio has 100
// acc.balance = -999.0; // would NOT compile: `balance` is private
}
Look at what the privacy bought us. The Account struct is pub and so is its owner field, but balance is deliberately not public. From outside the bank module you simply cannot touch balance directly -- you can only go through deposit and the read-only balance() getter. That means the module guarantees nobody can secretly set an account to a negative balance from the outside, because the only write path is deposit, which you control. This is encapsulation, enforced by the compiler rather than by politely-worded comments. Notice too that fields are private individually: making the struct pub does not automatically expose its fields, which is exactly the granularity you want.
Paths, nesting, and use
Modules can nest, and you address items inside them with paths -- a chain of names joined by ::, much like folders joined by slashes. There are two flavors: an absolute path starts from the crate root with the keyword crate::, and a relative path starts from the current module. Typing long paths over and over gets tedious fast, so the use keyword pulls a name into the current scope so you can refer to it by its short form:
mod shop {
pub mod produce {
pub fn apples() -> u32 {
42
}
}
pub fn stock_report() -> u32 {
// `produce` is a child of `shop`, reached directly from within `shop`
produce::apples()
}
}
use shop::produce; // bring the `produce` module into scope
fn main() {
println!("{}", shop::stock_report()); // 42
println!("{}", produce::apples()); // 42, thanks to `use`
println!("{}", crate::shop::produce::apples()); // 42, the full absolute path
}
Three ways to reach the same apples, all equivalent. Inside stock_report, produce::apples() is a relative path -- produce is a child module, so shop sees it directly. In main, crate::shop::produce::apples() is the absolute path, spelled out from the crate root; it always works no matter where you call it from, which makes it the safe choice when you are unsure. And the use shop::produce; line up top is the ergonomic middle ground: it imports produce once, and from then on produce::apples() reads clean without the shop:: prefix. You will reach for use constantly -- it is how the use std::collections::HashMap; lines from episode 7 worked, bringing HashMap into scope so you did not have to write std::collections::HashMap every single time.
One more path keyword earns its keep: super, which means "the parent module" -- the .. of the module tree. It lets a child reach back up to a sibling of its parent without hard-coding an absolute path:
mod outer {
pub fn top_level() -> &'static str {
"from outer"
}
pub mod inner {
pub fn call_up() -> &'static str {
// `super` steps up to `outer`, then calls its function
super::top_level()
}
}
}
fn main() {
println!("{}", outer::inner::call_up()); // from outer
}
Here inner needs something that lives in its parent outer, and super::top_level() walks one level up to get it. Using super in stead of the absolute crate::outer::top_level() means that if you later move the whole outer module somewhere else, the internal reference still resolves correctly -- the relative link survives the move. It is a small thing, but it is the kind of small thing that keeps refactoring painless.
Splitting modules across files
So far all our modules have lived inline inside one file, which is great for a tutorial but not how you would organize a real project. The beautiful part is that Rust makes the jump to multiple files almost invisible. When you write mod geometry; -- with a semicolon in stead of a block -- Rust goes looking for the module's body in a file named geometry.rs (or geometry/mod.rs) sitting next to the current file. The module system and the filesystem line up, but only because you asked; the mod declaration is still the thing that wires the file into the crate.
So a project might have a src/main.rs that opens with mod geometry;, and a separate src/geometry.rs holding the actual code:
// src/geometry.rs -- this module now lives in its own file
pub fn circle_area(radius: f64) -> f64 {
std::f64::consts::PI * radius * radius
}
pub fn rectangle_area(width: f64, height: f64) -> f64 {
width * height
}
That file is the geometry module. Over in main.rs you would write mod geometry; once to declare it, then call geometry::circle_area(2.0) exactly as if the code were inline -- the paths do not change one bit, only where the text physically lives. This is the mechanism that lets a serious project spread across twenty files while every path in the code stays stable and meaningful. A common rookie confusion, so let me say it plainly: the file existing on disk is not enough -- if no mod geometry; declaration appears anywhere in the crate, Rust never compiles geometry.rs at all. The mod line is the on-switch; the file is just where the body sleeps.
Re-exports: shaping a clean public API with pub use
There is a lovely trick that separates how your code is organized internally from how it is presented externally. You can bury a function three modules deep for your own organizational sanity, then re-export it at the top with pub use, so callers reach it by a short, friendly path and never see your internal layout:
mod deeply {
pub mod nested {
pub mod thing {
pub fn hello() -> &'static str {
"hi from the depths"
}
}
}
}
// re-export: lift the buried function up to the crate root
pub use deeply::nested::thing::hello;
fn main() {
println!("{}", hello()); // short, thanks to the re-export
println!("{}", deeply::nested::thing::hello()); // the long internal path still works
}
The pub use deeply::nested::thing::hello; line says "take that deeply-nested hello and also make it available right here, publicly". Now callers can write hello() without caring that internally it lives three modules down. This is exactly how big libraries give you a clean front door -- you write use rand::Rng; and get a tidy name, while the crate's authors keep their internals organized however suits them behind the scenes. The public API and the internal structure become two separate concerns you can tune independently, which is a genuinely powerful bit of design freedom.
Crates from the outside world: Cargo and dependencies
Everything so far has been one crate carved into modules. The other half of today is pulling in code that someone else wrote. Rust's package registry is crates.io, and Cargo -- the build tool we met in episode 1 -- is what fetches, versions, and compiles those dependencies for you. You do not download anything by hand; you name what you want in your project's Cargo.toml manifest and Cargo does the rest.
Adding a dependency is a one-line edit. Say you want rand, the ubiquitous random-number crate. You add it under [dependencies]:
[package]
name = "my_project"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = "0.8"
The rand = "0.8" line tells Cargo "fetch a compatible 0.8.x version of rand from crates.io". The next time you run cargo build or cargo run, Cargo downloads it (and anything it depends on), compiles the lot, and records the exact versions in a Cargo.lock file so your build is reproducible. From then on, the crate is available in your code just like the standard library:
use rand::Rng;
fn main() {
let mut rng = rand::thread_rng();
let roll: u32 = rng.gen_range(1..=6);
println!("you rolled a {roll}");
}
That use rand::Rng; reaches into an external crate exactly the way use std::collections::HashMap; reached into the standard one -- the mechanism is identical, the crate just came from crates.io in stead of shipping with the compiler. The "0.8" version string follows semantic versioning (semver): the numbers encode what kinds of changes are safe, so Cargo can upgrade you to bug-fixes automatically while refusing to silently drag in a breaking change. We will not go deep on semver today, but the short version is that this is why Rust's dependency story stays sane where other ecosystems descend into version chaos. Do be a little careful out there, mind you -- every dependency is code you are trusting, so lean on well-known, widely-used crates rather than hoovering up every shiny package you stumble across.
Workspaces: many crates, one project
The last piece, and I will keep it brief because you will not need it for a while. Sometimes a project grows so large that even one crate split into modules is not enough -- you want several separate crates that are developed together, maybe a library crate plus a couple of binaries that use it. A workspace is Cargo's answer: a top-level Cargo.toml that ties multiple crates together so they share one Cargo.lock and one target/ build directory, and so cargo build at the top builds them all.
You declare the members and Cargo treats them as one coordinated build:
# top-level Cargo.toml
[workspace]
members = [
"engine", # a library crate
"cli", # a binary crate that depends on engine
]
I am flagging workspaces mostly so the word is not a mystery when you meet it in the wild -- most of your projects for a good while will be a single crate, and that is completely fine. When you do reach the scale where a workspace helps, you will know, because a single crate will start feeling cramped and you will want to split a reusable library out from the program that uses it. That is the moment workspaces exist for.
Try it yourself
Three exercises, gentle to chewier as always. Full solutions open the next episode.
Create a module
temperaturecontaining two public functions:c_to_f(c: f64) -> f64(celsius to fahrenheit, formulac * 9.0 / 5.0 + 32.0) andf_to_c(f: f64) -> f64(the reverse,(f - 32.0) * 5.0 / 9.0). Frommain, call both with ause temperature;at the top, and printc_to_f(100.0)(expect212) andf_to_c(32.0)(expect0).Build a module
counterwith apub struct Counterthat has a privatecount: u32field. Give itpub fn new() -> Counter,pub fn increment(&mut self), andpub fn value(&self) -> u32. Prove the encapsulation works: frommain, create a counter, increment it three times, print the value (expect3), and convince yourself you cannot writecounter.count = 999from outside the module.Take exercise 1's
temperaturemodule and add a nested child moduletemperature::labelswith a public functiondescribe(c: f64) -> &'static strthat returns"freezing"forc <= 0.0,"hot"forc >= 30.0, and"mild"otherwise. Then, at the crate root,pub use temperature::labels::describe;so you can call plaindescribe(35.0)frommain. This exercises nesting, paths, and a re-export all at once.
Exercise 3 is the one that ties the whole episode together -- if you can make that re-export resolve and call describe by its short name, you have understood how internal structure and public surface come apart.
So what did we actually cover?
- A crate is the unit of compilation and distribution (binary crates run, library crates are reused); a module is how you organize code inside one crate. The crate is the book, modules are its chapters.
mod name { ... }defines a module; items inside are reached by::paths, either absolute (crate::...) or relative (via the current module,super, orself).useimports a path so you can use its short name.- Everything is private by default.
pubopts an item -- or an individual struct field -- into the public surface. This lets a module protect its invariants and keep its internals free to change. mod name;(with a semicolon) moves a module's body into its own file, so a project spreads across many files while paths stay stable. Themodline is the on-switch; the file alone does nothing.pub usere-exports a buried item at a shorter path, letting you present a clean public API independent of your internal layout.- External crates come from crates.io; you add them under
[dependencies]inCargo.tomland Cargo fetches, versions (via semver), and compiles them. Workspaces group multiple crates into one coordinated build for when a project outgrows a single crate.
And that is the craft of keeping Rust projects navigable. It is not the flashiest episode in the series -- nobody's mind gets blown by pub the way it does by the borrow checker -- but structure is what lets everything else we have learned scale up from a toy in one file to a program you would not be embarrassed to hand to someone else. You can now split code by responsibility, hide what should stay hidden, expose a deliberate public face, and stand on the shoulders of the whole crates.io ecosystem in stead of writing every last thing yourself. Next time we return to a thread I have left dangling since episode 3 and again in episode 8 -- those little tick-marks tying borrows together that I kept promising we would make explicit. With ownership, traits, generics, and now project structure all in hand, you are ready for it.
Bedankt voor het lezen, en tot de volgende keer! ;-)
Leave Learn Rust Series (#9) - Modules & Crates to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Rust Series (#9) - Modules & Crates
- Learn AI Series (#139) - AI for Code
- Learn Zig Series (#119) - BFS and DFS
- Learn Rust Series (#8) - Traits & Generics
- Learn AI Series (#138) - Multimodal AI
- Learn Zig Series (#118) - Graph Representation
- Learn Rust Series (#7) - Collections
- Learn AI Series (#137) - Foundation Models
- Learn Zig Series (#117) - Binary Search Variations
- Learn Rust Series (#6) - Error Handling