When one file becomes a swamp
You start a Rust project with a single main.rs. It has fifty lines. It's clean. You add a feature. Two hundred lines. You add a configuration parser, a database helper, and a few utility functions. Five hundred lines. You're scrolling for ten seconds just to find fn main. You copy a function from a friend's repo, and now you have two calculate_tax functions that do slightly different things. The file is a swamp. Your brain is holding the entire program in RAM, and you're swapping.
It's time to split the code. Rust handles this with modules. Modules let you organize code into separate files, group related logic, and control what parts of your code are visible to the rest of the world. They turn a tangled script into a structured project.
Modules are drawers, not just files
Think of a module like a drawer in a desk. main.rs is the top of the desk where you do your work. You don't keep every screw, paperclip, and receipt on the surface. You put related things in drawers. One drawer for "Math", one for "Database", one for "User Interface".
Modules do two things. They create namespaces so names don't collide. You can have database::connect and cache::connect without them fighting. They also enforce visibility. Items inside a module are private by default. You have to explicitly mark things as pub to let other modules see them. This keeps your internal helpers hidden and your public API clean.
The mod keyword is how you declare a module. It tells the compiler about a new namespace. You can define the module inline, or you can declare it and put the code in a separate file. For multi-file projects, you almost always use the file-based approach.
The minimal split
Here is the smallest possible multi-file project. You have main.rs and a calculator.rs.
// src/main.rs
// Declare the module. Rust looks for src/calculator.rs.
mod calculator;
fn main() {
// Call the function through the module path.
let result = calculator::add(2, 3);
println!("Result: {}", result);
}
// src/calculator.rs
// pub makes this function visible outside this file.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
The mod calculator; line in main.rs is a declaration, not a definition. You aren't writing the code there. You are telling the compiler: "There is a module named calculator. Go find it." The compiler looks for src/calculator.rs. If it finds the file, it reads the contents and treats them as the body of the calculator module.
If the file is missing, the compiler rejects the build immediately. You get E0583 (file not found for module). The module name must match the file name exactly. No calc.rs, no calculator_module.rs. Just calculator.rs.
Convention aside: Rust 2018 changed how modules map to files. In older Rust, you needed src/calculator/mod.rs. That pattern is legacy. The community standard is named files like src/calculator.rs. Use named files unless you have a specific reason to do otherwise. Named files make the directory structure match the module tree perfectly.
How the compiler finds your code
When you write mod name;, the compiler searches for the file relative to the current file. If main.rs declares mod calculator;, the compiler looks for src/calculator.rs. If src/database.rs declares mod pool;, the compiler looks for src/database/pool.rs.
This relative search creates a tree. The root is main.rs (or lib.rs for libraries). Every mod declaration adds a branch. The file system structure mirrors the module structure.
You can nest modules as deep as you want. mod a; inside a.rs can contain mod b;, which looks for a/b.rs. The compiler walks the tree and builds a single namespace for your crate.
Ah-ha reveal: mod does not execute code. It only defines structure. Modules are just organization. The compiler flattens everything into one crate. mod is a map, not a runtime boundary. Your code runs in one process, regardless of how many files you split it into.
Realistic structure with imports
Real projects use use to bring names into scope. Typing database::pool::connect::start() every time is tedious. use creates a local alias. It doesn't move code. It doesn't copy code. It just lets you type less.
// src/main.rs
// Declare modules at the top.
mod database;
mod models;
// Import specific items to shorten paths.
use models::User;
use database::save;
fn main() {
// Use the imported names directly.
let user = User::new("Alice");
save(user);
}
// src/models.rs
// pub struct exposes the type.
// pub field exposes the field.
pub struct User {
pub name: String,
}
impl User {
// pub method exposes the constructor.
pub fn new(name: &str) -> Self {
User { name: name.to_string() }
}
}
// src/database.rs
// Use crate:: to refer to the root of the crate.
use crate::models::User;
pub fn save(user: User) {
println!("Saving user: {}", user.name);
}
The use statement is purely cosmetic to the compiler. use foo::bar; is exactly the same as typing foo::bar everywhere, except shorter. It doesn't change visibility. You can use a private item if you are inside the same module, but you can't expose it via use unless the item is pub.
Convention aside: The community prefers use crate::... over use super::... for cross-module imports. crate:: always points to the root, so it's stable even if you move files around. super:: points to the parent, which changes if you restructure. Use crate:: for clarity.
Navigating the tree: crate vs super
When you're deep in a module tree, you need to refer to other parts of the crate. Rust gives you two anchors.
crate:: points to the root of the crate. In a binary, that's main.rs. In a library, that's lib.rs. crate::models::User always works, no matter where you are.
super:: points to the parent module. If you're in database::pool, super:: refers to database. super::super:: goes up two levels.
Use crate:: for imports that jump across branches. Use super:: for items that live one level up. Mixing them makes the code harder to read. Pick one style and stick with it. Most projects use crate:: for everything except immediate parent references.
Ah-ha reveal: pub use re-exports items. If you have a module internal::helper that you want to expose as helper, you can write pub use internal::helper; in the parent. This lets you hide the internal structure while presenting a clean public API. Libraries use this all the time to keep their directory structure flexible without breaking user code.
Pitfalls and compiler errors
Multi-file projects introduce new ways to trip up. The compiler catches them, but the errors can be confusing if you don't know what to look for.
File not found. You write mod calculator; but the file is src/calc.rs. The compiler rejects this with E0583 (file not found for module). The name must match the file name. Rename the file or fix the declaration.
Private item. You call calculator::add but add isn't marked pub. The compiler says E0603 (function add is private). Items are private by default. Add pub to the function, struct, or field you want to expose.
Unresolved import. You write use database::save; but database isn't declared in main.rs. The compiler gives E0432 (unresolved import). You can't use a module that doesn't exist in the tree. Declare it with mod first.
Missing pub on struct fields. You create a User struct with pub struct User, but the fields aren't pub. You can construct the struct inside the module, but code outside can't access the fields. You get E0616 (field name is private). Add pub to the fields you want to expose.
Visibility is a boundary. Draw it carefully. Keep implementation details private. Expose only what other modules need. This reduces coupling and makes refactoring safe. If a function is private, you can change its signature without breaking anything else.
Decision: structuring your code
Structure follows responsibility. Group code by what it does, not by file size. Use these rules to decide how to organize your project.
Use mod name; when the code belongs in a separate file and has a clear responsibility. Use mod name { ... } when the module is small and tightly coupled to the parent file. Use use to shorten paths for items you reference frequently. Use pub to expose items to parent modules or other crates. Use pub(crate) to share helpers across your crate without exposing them to library users. Use pub use to re-export items and present a clean public API. Use crate:: for imports that jump across branches. Use super:: for references to the immediate parent module.
Your module tree is your project's map. If the map is confusing, the code is too. Split files when the logic diverges. Keep related code together. Let the compiler enforce the boundaries.