Step 10 of 17
Modules, crates & Cargo
Organize code into modules with deliberate visibility, manage dependencies and features with Cargo, and split large projects into workspaces.
A single main.rs gets you through the first week. After that you need structure: modules to group code and hide implementation details, a clear public API, dependencies from crates.io, and eventually several crates in one repository. Rust's module system trips people up because it doesn't follow the filesystem automatically the way Go packages or JavaScript files do. This lesson explains how it works, how visibility is controlled, and how Cargo manages dependencies, features, profiles and workspaces.
Packages, crates and modules
Three words, three levels:
- A crate is a unit of compilation: one library or one binary. The compiler builds one crate at a time.
- A package is what
Cargo.tomldescribes: one or more crates. At most one library crate (rooted atsrc/lib.rs) and any number of binary crates (src/main.rs, plus each file insrc/bin/). - A module is a namespace inside a crate. Every crate is a tree of modules, rooted at
lib.rsormain.rs.
A common layout puts all the logic in the library and keeps main.rs thin. Tests, benchmarks and other binaries can then use the library too:
todo/
├── Cargo.toml
└── src/
├── main.rs # binary crate: parses args, calls the library
├── lib.rs # library crate root
├── store.rs # module `store`
└── model/
├── mod.rs # (or model.rs next to the folder) module `model`
└── task.rs # module `model::task`Declaring modules
Modules are declared with mod, not discovered. A file only becomes part of the crate when its parent declares it:
// src/lib.rs
pub mod model; // loads src/model.rs or src/model/mod.rs
pub mod store; // loads src/store.rs
mod util { // inline module, in this file
pub fn slugify(s: &str) -> String {
s.to_lowercase().replace(' ', "-")
}
}// src/model.rs (or src/model/mod.rs)
pub mod task; // loads src/model/task.rsIf you create src/helpers.rs and never write mod helpers; anywhere, the compiler ignores the file entirely. That's the most common "why isn't my code found?" moment. For nested modules, the modern convention is src/model.rs plus a src/model/ folder for its children; mod.rs still works and is common in older code.
Paths and use
Items are referred to by path. crate:: starts at the crate root, super:: at the parent module, and self:: at the current module:
// src/store.rs
use crate::model::task::Task; // absolute path from the crate root
use std::collections::HashMap; // from another crate (std)
pub struct Store {
tasks: HashMap<u64, Task>,
next_id: u64,
}
impl Store {
pub fn new() -> Self {
Self { tasks: HashMap::new(), next_id: 1 }
}
pub fn add(&mut self, title: &str) -> u64 {
let id = self.next_id;
self.next_id += 1;
self.tasks.insert(id, Task::new(id, title));
id
}
pub fn get(&self, id: u64) -> Option<&Task> {
self.tasks.get(&id)
}
}use brings a path into scope so you can write the short name. Conventions: import types, traits and macros by their name (use std::collections::HashMap;), but import functions by their parent module and call them qualified (use std::fs; then fs::read_to_string), so it's clear where a function comes from. Group imports with braces, use std::io::{self, Read, Write};, and rename clashes with as: use std::fmt::Result as FmtResult;.
Visibility
Everything is private by default: visible inside the module where it's defined and its descendants. pub opens it up:
| Modifier | Visible to |
|---|---|
| (none) | The current module and its children |
pub(super) | The parent module |
pub(crate) | The whole crate, but not other crates |
pub | Anyone who can reach the enclosing module |
Struct fields have their own visibility, separate from the struct's:
// src/model/task.rs
#[derive(Debug, Clone)]
pub struct Task {
pub id: u64,
pub title: String,
done: bool, // private: only this module can change it directly
}
impl Task {
pub(crate) fn new(id: u64, title: &str) -> Self {
Self { id, title: title.to_string(), done: false }
}
pub fn is_done(&self) -> bool {
self.done
}
pub fn complete(&mut self) {
self.done = true;
}
}A struct with any private field can't be built with a struct literal outside its module, so new is the only way in. That's how you protect invariants: if a Task must never have an empty title, check it in the constructor, keep the field private, and no other code can create an invalid one. Enum variants, by contrast, are all public if the enum is.
pub(crate) is underused. Most items in an application don't need to be visible to other crates, and keeping them pub(crate) lets the compiler warn you about dead code that a plain pub hides.
Re-exports with pub use let you shape the public API independently of the file layout:
// src/lib.rs
mod model;
mod store;
pub use model::task::Task; // users write todo::Task
pub use store::Store; // instead of todo::store::StoreDependencies
Dependencies go in Cargo.toml. Add them with cargo add, which picks the latest version and writes the line for you:
cargo add serde --features derive
cargo add tokio --features full
cargo add --dev pretty_assertions # only for tests and examples[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
pretty_assertions = "1"Version requirements are semver by default: "1.4" means >=1.4.0, <2.0.0. Cargo resolves the newest compatible versions and records the exact ones in Cargo.lock. Commit Cargo.lock for applications and libraries alike (the old advice to skip it for libraries changed in 2023), so CI builds what you tested. cargo update moves to newer compatible versions; cargo tree shows the whole graph and why each crate is there.
Features
Features are named, optional parts of a crate, turned on by whoever depends on it. They're how tokio lets you include only the runtime parts you use, and how serde gates its derive macros.
[features]
default = ["json"]
json = ["dep:serde_json"] # enabling `json` pulls in the optional dependency
metrics = []
[dependencies]
serde_json = { version = "1", optional = true }#[cfg(feature = "json")]
pub fn to_json(task: &Task) -> String {
serde_json::json!({ "id": task.id, "title": task.title }).to_string()
}Features must be additive: enabling one may add functionality but must never remove or change any. Cargo unifies features across the dependency graph, so if two crates depend on yours with different features, both sets get turned on.
Profiles and workspaces
Profiles control how code is compiled. dev (used by cargo build) favours fast compiles; release favours fast code. You can tune them:
[profile.release]
lto = "thin" # link-time optimization across crates
codegen-units = 1 # slower build, better optimization
panic = "abort" # smaller binary; no unwinding
[profile.dev.package."*"]
opt-level = 2 # optimize dependencies even in dev buildsThe last one is a common quality-of-life tweak: your own code stays quick to rebuild, but heavy dependencies (image processing, crypto) run at reasonable speed in debug builds.
A workspace is several packages in one repository that share a Cargo.lock and a target/ directory, so dependencies are compiled once:
# Cargo.toml at the repository root
[workspace]
resolver = "3"
members = ["crates/core", "crates/api", "crates/cli"] # crates/core is package "todo-core"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }# crates/api/Cargo.toml
[package]
name = "api"
version = "0.1.0"
edition = "2024"
[dependencies]
todo-core = { path = "../core" } # a path dependency on another member
serde = { workspace = true }
tokio = { workspace = true }workspace.dependencies pins versions in one place, and members opt in with workspace = true. Split into crates when compile times grow (crates compile in parallel and are cached separately) or when you want a hard boundary: a core crate that can't depend on your web framework by construction. The idea is the same as package by feature and the dependency rule from clean architecture, enforced by Cargo.
Practice
Turn a single-file program into a structured crate.
cargo new --lib bookshelf, then addsrc/main.rsso the package has both a library and a binary.- Create modules
book(aBookstruct with privateisbn: String, publictitleandauthor) andshelf(aShelfthat stores books in aVec). - Make
Book::new(isbn, title, author)returnResult<Book, String>, rejecting ISBNs that aren't 13 digits. MakeShelf::addandShelf::by_author(&self, author: &str) -> Vec<&Book>public, and keep a helpernormalize(author: &str)pub(crate). - Re-export
BookandShelfat the crate root, and use them frommain.rsasbookshelf::Book. - Add an optional feature
serdethat derivesSerializeforBook.
Show solution
# Cargo.toml
[package]
name = "bookshelf"
version = "0.1.0"
edition = "2024"
[features]
serde = ["dep:serde"]
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }// src/lib.rs
mod book;
mod shelf;
pub use book::Book;
pub use shelf::Shelf;// src/book.rs
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Book {
isbn: String,
pub title: String,
pub author: String,
}
impl Book {
pub fn new(isbn: &str, title: &str, author: &str) -> Result<Self, String> {
let digits: String = isbn.chars().filter(|c| *c != '-').collect();
if digits.len() != 13 || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("invalid ISBN-13: {isbn}"));
}
Ok(Self { isbn: digits, title: title.into(), author: author.into() })
}
pub fn isbn(&self) -> &str {
&self.isbn
}
}// src/shelf.rs
use crate::book::Book;
pub(crate) fn normalize(author: &str) -> String {
author.trim().to_lowercase()
}
#[derive(Default)]
pub struct Shelf {
books: Vec<Book>,
}
impl Shelf {
pub fn add(&mut self, book: Book) {
self.books.push(book);
}
pub fn by_author(&self, author: &str) -> Vec<&Book> {
let wanted = normalize(author);
self.books.iter().filter(|b| normalize(&b.author) == wanted).collect()
}
}// src/main.rs
use bookshelf::{Book, Shelf};
fn main() -> Result<(), String> {
let mut shelf = Shelf::default();
shelf.add(Book::new("978-1-7185-0310-6", "The Rust Programming Language", "Steve Klabnik")?);
for b in shelf.by_author("steve klabnik") {
println!("{} ({})", b.title, b.isbn());
}
Ok(())
}main.rs is a separate crate that depends on the library by its package name, so it can only see what lib.rs exports with pub. cfg_attr(feature = "serde", …) applies the derive only when the feature is on; build with cargo build --features serde to check it.
Key takeaways
- A package contains crates (one library, any number of binaries); a crate is a tree of modules.
- Modules are declared with
mod; a file that isn't declared isn't compiled. - Everything is private by default. Use
pub(crate)for crate-internal items, keep fields private to protect invariants, and shape the public API withpub use. - Add dependencies with
cargo add, commitCargo.lock, and audit what you pull in. - Features must be additive; workspaces share one lockfile and build cache across crates and enforce boundaries between them.
Check yourself
Quiz
0/4 correct · 4 left
1.You create src/parser.rs, but code in it never compiles or runs. What's the likely cause?
2.A struct has a public name field and a private id field. What can code in another module do?
3.Why must Cargo features be additive?
4.What is the main benefit of a Cargo workspace?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.