18 - Modules and Crates
📋 Jump to Takeaways🎁 Imagine a 10,000-line main.rs where every function, struct, and constant lives in one scrollable nightmare. Rust's module system lets you split code into logical pieces, with clear boundaries, explicit visibility, and zero header-file chaos. And when you're ready, you can publish those pieces to crates.io for the world to use.
Inline Modules with mod
The simplest module is inline, a named block inside the same file.
mod math {
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
fn secret() -> i32 {
42 // private — only accessible inside `math`
}
}
fn main() {
println!("{}", math::add(2, 3)); // 5
// math::secret(); // ❌ ERROR: function `secret` is private
}Everything inside a module is private by default. You opt into visibility with pub.
pub Visibility
pub controls what's accessible from outside the module. You can make functions, structs, fields, and even entire modules public.
mod user {
pub struct User {
pub name: String,
email: String, // private field
}
impl User {
pub fn new(name: &str, email: &str) -> User {
User {
name: name.to_string(),
email: email.to_string(),
}
}
pub fn email(&self) -> &str {
&self.email
}
}
}
fn main() {
let u = user::User::new("Alice", "[email protected]");
println!("{}", u.name); // "Alice" — public field
// println!("{}", u.email); // ❌ ERROR: field `email` is private
println!("{}", u.email()); // "[email protected]" — via public method
}This gives you encapsulation without classes or access modifiers lists, just pub or not.
use Statements
Typing full paths gets tedious. The use keyword brings items into scope.
mod geometry {
pub mod shapes {
pub fn circle_area(r: f64) -> f64 {
std::f64::consts::PI * r * r
}
}
}
use geometry::shapes::circle_area;
fn main() {
println!("{:.2}", circle_area(3.0)); // 28.27
}Unlike Go's import, use is purely a shorthand. You can always write the full path (std::f64::consts::PI) without any use statement. In Go, a package doesn't exist in your scope until you import it. In Rust, everything is always accessible by its full path — use just saves you typing.
You can also rename imports to avoid conflicts. These are just import examples, not runnable code:
use std::fmt::Result as FmtResult;
use std::io::Result as IoResult;File-Based Modules
In real projects, each module lives in its own file. When you write mod math; the compiler looks for math.rs (or math/mod.rs).
src/
├── main.rs
├── math.rs
└── network/
├── mod.rs
└── tcp.rs// src/main.rs
mod math;
mod network;
fn main() {
println!("{}", math::add(1, 2)); // 3
network::tcp::connect();
}// src/math.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}// src/network/mod.rs
pub mod tcp;// src/network/tcp.rs
pub fn connect() {
println!("Connected!"); // "Connected!"
}The directory name matches the module name, and mod.rs declares its sub-modules.
File Structure Conventions
Rust supports two conventions for nested modules:
Old style (mod.rs):
src/network/mod.rs → mod network
src/network/tcp.rs → mod tcp (inside network)New style (Rust 2018+):
src/network.rs → mod network
src/network/tcp.rs → mod tcp (inside network)Both work. The preceding file-based modules example uses the old style. Most new projects use the 2018+ convention to avoid many files named mod.rs.
The Crate Root: lib.rs vs main.rs
Every Rust package has a crate root:
src/main.rs, binary crate (produces an executable)src/lib.rs, library crate (produces reusable code)
You can have both in one package.
// src/lib.rs — your library's public API
pub mod auth;
pub mod database;
pub fn version() -> &'static str {
"1.0.0"
}// src/main.rs — uses the library
use my_app::version;
fn main() {
println!("App version: {}", version()); // "App version: 1.0.0"
}How does main.rs refer to lib.rs? By the package name in Cargo.toml. Look at the name field under [package]. That's what you write in your use statements, with hyphens replaced by underscores.
So if your Cargo.toml has name = "my-cool-lib", you'd write use my_cool_lib::something; in src/main.rs.
If both live in the same package, you don't need a [dependencies] entry. Cargo links the library to the binary automatically.
External Crates and Cargo.toml
To use code from crates.io, add it to Cargo.toml:
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] } # Features are optional capabilities a crate offers. "derive" enables #[derive(Serialize)]. Without it, you'd implement serialization manually.
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"
clap = { version = "4", features = ["derive"] }You can also add crates from the command line instead of editing the file manually:
cargo add serde --features derive
cargo add tokio --features fullThen use them in your code. You can import multiple items from the same path with curly braces:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Config {
host: String,
port: u16,
}
fn main() {
let config = Config { host: "localhost".into(), port: 8080 }; // .into() converts &str to String (shorthand for String::from)
let json = serde_json::to_string(&config).unwrap();
println!("{}", json); // {"host":"localhost","port":8080}
}Cargo downloads, compiles, and links dependencies automatically.
Common Crates to Know
These are the "standard library extensions" most Rust developers reach for:
| Crate | Purpose |
|---|---|
serde |
Serialization/deserialization (JSON, TOML, YAML) |
tokio |
Async runtime (networking, timers, I/O) |
anyhow |
Ergonomic error handling for applications |
thiserror |
Derive Error for library error types |
clap |
Command-line argument parsing |
reqwest |
HTTP client |
tracing |
Structured logging and diagnostics |
rand |
Random number generation |
You don't need to memorize these, just know they exist so you don't reinvent wheels.
Re-exporting with pub use
pub use re-exports an item at whatever level you write it. If you place it at the crate root (src/lib.rs), the item becomes top-level. If you place it inside a nested module, it flattens things one level up. It's a shortcut that says "make this item also available from here."
// src/lib.rs
mod helpers {
pub mod validators {
pub fn is_valid_email(s: &str) -> bool {
s.contains('@')
}
}
}
// Re-export so users write `my_crate::is_valid_email` instead of
// `my_crate::helpers::validators::is_valid_email`
pub use helpers::validators::is_valid_email;This is how a user of your published crate would write their code. It lives in a separate project that lists your crate as a dependency:
// Consumer code
use my_crate::is_valid_email;
fn main() {
println!("{}", is_valid_email("[email protected]")); // true
}This keeps your public API clean while your internals stay organized however makes sense.
Workspaces
When your project grows into multiple crates, a workspace ties them together. They share a single target/ directory and Cargo.lock.
# Cargo.toml (workspace root)
[workspace]
members = [
"core",
"api",
"cli",
]my_project/
├── Cargo.toml (workspace)
├── core/
│ ├── Cargo.toml
│ └── src/lib.rs
├── api/
│ ├── Cargo.toml
│ └── src/main.rs
└── cli/
├── Cargo.toml
└── src/main.rsEach member is an independent crate. They can depend on each other:
# api/Cargo.toml
[dependencies]
core = { path = "../core" }If you're coming from Go, this is the equivalent of go.work. Multiple modules developed together locally, resolving to each other without publishing. The root Cargo.toml with [workspace] is your go.work file, and each member's Cargo.toml is their go.mod.
Members don't have to depend on every other member. If proj1 only needs corelib1 and corelib2, it declares just those in its [dependencies]. They all share one lockfile and build cache, but each crate only pulls in what it asks for.
One key difference from Go: workspaces require all crates to live in the same repo. If your projects are in separate repos, you can't use a workspace. Instead, publish the shared crates (to crates.io or a private registry) or use git dependencies. For local development across repos, [patch] lets you temporarily override a published crate with a local path:
# proj1/Cargo.toml — override during local dev
[patch.crates-io]
corelib1 = { path = "../corelib1" }Workspaces keep compile times fast and dependencies consistent across related crates.
Cargo Tooling
Cargo isn't just a build tool. It ships with formatters, linters, test runners, and doc generators out of the box.
cargo fmt # format all code (like gofmt / prettier)
cargo clippy # lint — catches common mistakes and suggests idiomatic Rust
cargo test # run all unit and integration tests
cargo doc --open # generate HTML docs from your /// comments and open in browser
cargo bench # run benchmarks (nightly, or with criterion crate on stable)cargo fmt uses rustfmt under the hood. It's opinionated and non-configurable by default (like gofmt). You can tweak it with a rustfmt.toml file, but most teams just use the defaults.
cargo clippy is the one you'll love most. It catches things the compiler doesn't warn about:
$ cargo clippy
warning: using `push_str` with a single-character string literal
--> src/main.rs:5:5
|
= help: consider using `push` with a character literal
warning: this `if let` can be simplified with `.is_some()`Think of clippy as a senior Rust developer reviewing every line. Run it before every commit.
Key Takeaways
moddeclares modules, inline or file-based- Everything is private by default; use
pubto expose items usebrings paths into scope to avoid repetition- File-based modules map to
filename.rsorfilename/mod.rs src/main.rsis the binary root;src/lib.rsis the library root- External crates go in
Cargo.tomlunder[dependencies] pub usere-exports items to create a clean public API- Workspaces manage multi-crate projects with shared builds
cargo fmtformats,cargo clippylints,cargo testtests,cargo docdocuments
🎁 Your code is organized, your crates are published, but there's more power hiding in plain sight. Next up: Closures, anonymous functions that capture variables from their environment. Rust's ownership rules make them zero-cost abstractions, and they're the foundation of iterators, threads, and async code.