When main.rs becomes a swamp
You are building a CLI tool that scrapes data, processes it, and saves it to a database. It started as a single main.rs file. Now you are at 800 lines. You add a new column to the database schema, and suddenly the scraper crashes because the struct definitions are buried under the HTTP client code. You spend twenty minutes scrolling to find the bug. This is the big ball of mud phase.
Rust has a built-out system to stop this from happening. Modules let you carve your codebase into logical chunks, hide implementation details, and keep the compiler from letting unrelated parts of your code step on each other. Modules are your first line of defense against complexity.
Modules are a tree, not just folders
A module is a namespace. It groups related code together and controls who can see what. In Rust, modules form a tree. The root is either lib.rs for a library or main.rs for a binary. Every other module lives somewhere in that tree.
Think of a module as a room in a house. Some rooms are open to guests. Some are locked closets. You cannot walk through a wall to get to the pantry from the living room. You have to go through the kitchen. Rust enforces these walls at compile time. The compiler is your bouncer. It checks IDs at every door.
The mod keyword declares a module. The pub keyword controls visibility. By default, everything is private. You must opt-in to visibility. This design prevents accidental leaks. If a function is not marked pub, code outside the module cannot call it. The compiler rejects the attempt.
Minimal example: one module, one file
Start with a library crate. The root is src/lib.rs. You declare a module and put its code in a separate file.
// src/lib.rs
// Declare the module. Rust looks for src/math.rs.
// `pub` makes the module visible to crates that depend on this one.
pub mod math;
pub fn run() {
// Access the public function via the module path.
let sum = math::add(2, 3);
println!("Sum: {}", sum);
// This line would cause a compile error.
// `secret` is private to the `math` module.
// math::secret();
}
// src/math.rs
/// Adds two integers.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// Helper function. Not exposed outside this module.
fn secret() {
println!("I am hidden.");
}
When you write pub mod math; in lib.rs, the compiler searches for src/math.rs. If it finds the file, the contents become the math module. The pub on the module declaration means other crates can see my_crate::math. If you omit pub, only code inside lib.rs can access the module.
Inside math.rs, pub fn add makes the function visible to anyone who can see the module. fn secret has no pub, so it is private. Code outside math cannot call it. Encapsulation is the default. Opt-in to visibility.
Inline modules versus file modules
You can define a module inline using braces.
// src/lib.rs
mod tiny {
pub fn helper() {
println!("Tiny helper.");
}
}
This works fine for tiny modules. As soon as the module grows, move it to a file. The compiler allows both styles, but you cannot mix them. If you have mod math; in lib.rs, you cannot also have mod math { ... } in the same file. The compiler will reject the duplicate definition.
The convention is clear. Use inline modules only for test harnesses or extremely small helpers. Move to a file before you hit the scroll bar.
Realistic project structure
Large projects need more than just pub and private. You often have internal plumbing that multiple modules share but external users should never touch. You also want to present a clean public API.
// src/lib.rs
// Top-level modules.
pub mod api;
pub mod db;
// Models are internal to the crate, not part of the public API.
mod models;
// Re-export a type so users don't need to dig into internal modules.
// This makes `my_crate::User` available instead of `my_crate::models::User`.
pub use models::User;
// src/api.rs
// Bring types into scope to avoid long paths.
use crate::models::User;
use crate::db;
/// Handle a request to create a user.
pub fn create_user(name: String) -> User {
// Call internal database logic.
let user = db::save_user(name);
user
}
// src/db.rs
use crate::models::User;
/// Save user to database.
// `pub(crate)` means visible anywhere in this crate, but not to external users.
pub(crate) fn save_user(name: String) -> User {
// Simulate DB save.
User { name }
}
// src/models.rs
/// Represents a user in the system.
pub struct User {
pub name: String,
}
Notice pub use models::User; in lib.rs. This is a re-export. It lets users write my_crate::User instead of my_crate::models::User. It keeps the public API clean. Re-exports are a courtesy. They save your users from digging through your internal directory structure.
Notice pub(crate) in db.rs. This is a visibility modifier that exposes the function to the entire crate but hides it from external crates. It is perfect for internal plumbing that multiple modules need but users shouldn't touch. The community convention is to use pub(crate) for shared internal helpers. It signals intent clearly.
Paths and imports
Rust supports absolute and relative paths. crate:: starts from the root of the current crate. super:: goes up one level. self:: refers to the current module.
// src/api.rs
// Absolute path from root. Survives refactoring if you move this file.
use crate::models::User;
// Relative path. Breaks if you move this file to a different module.
// use super::models::User;
The use keyword creates an alias. It does not move code. It does not change visibility. If you use a private item, you can only use it within the scope of the use statement. You cannot expose it to outsiders just by importing it.
Use crate:: for clarity. It survives refactoring. When you move a file, crate:: paths still resolve correctly. Relative paths break when the hierarchy changes.
Pitfalls and compiler errors
Modules introduce specific errors. Knowing them saves time.
If you try to access a private module from outside, the compiler rejects you with E0603 (module is private).
// src/api.rs
// BAD: Trying to access a private module from outside.
// use crate::models; // E0603: module `models` is private
// GOOD: Access via re-export.
use crate::User;
If you misspell a path or import something that doesn't exist, you get E0432 (unresolved import). The compiler tells you exactly which path failed.
If you try to access a path that exists but is not visible due to privacy rules, you may see E0433 (failed to resolve). This often happens when you forget pub on a struct field or method.
Another common trap is the inline versus file conflict. If you define mod foo { } in lib.rs and also create src/foo.rs, the compiler complains about duplicate definitions. Delete one or the other.
Trust the error codes. They tell you exactly which wall you hit.
Decision matrix
Use pub mod when you want to expose a module to crates that depend on yours.
Use mod without pub when the module is internal scaffolding that external users should never see.
Use pub(crate) when a function or struct is needed by multiple modules inside your crate but shouldn't leak into the public API.
Use pub(super) when you need to expose an item only to the parent module, often for testing or tight coupling between a module and its direct container.
Use use to shorten paths within a file; it does not change visibility.
Use pub use to re-export an item and make it part of your public API under a different path.
Use src/module.rs for simple modules. Use src/module/mod.rs only when you have a complex hierarchy and prefer the explicit directory structure, though src/module.rs is the modern default.
Visibility is a contract. Define it once, and the compiler enforces it everywhere.