Separating Modules into Different Files

Learn how to split Rust modules across multiple files, organize a project directory structure, and manage visibility when modules grow beyond a single file.

A Rust module groups related code — functions, structs, traits — under a single name. When that group outgrows a single file, you need a mechanical way to split it without breaking the namespace. Rust maps the module system onto the filesystem with a small set of rules. Once you understand those rules, moving code between files becomes a routine, safe refactor.

The Core Rule — mod Declares a Module, the Filesystem Optionally Supplies It

When you write mod math; (with a semicolon, no body), the compiler looks for the module’s contents in a separate file or directory. The exact search order is:

  1. math.rs (a single file)
  2. math/mod.rs (a directory with the old‑style entry point)

If neither exists, compilation fails. The compiler does not scan your src/ folder for .rs files — only files explicitly pointed to by a mod declaration become part of the crate.

Files Are Not Modules Until Declared:

Adding a math.rs file to src/ and writing pub fn add() {} inside it does nothing by itself. Without mod math; somewhere already compiled (e.g. in main.rs or lib.rs), the file is invisible — it is not parsed and its errors are not reported.

This is the foundation that every other file‑splitting technique builds on. The rest of the document shows how to use it in practice, from a single flat file all the way to nested directory trees.

Splitting a Top‑Level Module into Its Own File

The simplest case: you have a module defined inline in main.rs and you want to move it to a separate file.

Before — Inline Module

mod math {
    pub fn add(a: i32, b: i32) -> i32 {
        a + b
    }
    fn helper() {
        // private helper
    }
}
fn main() {
    println!("{}", math::add(2, 3));
}

After — Separate File

1

Create the new file

Move the module’s content (without the surrounding mod math { } wrapper) into src/math.rs:

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}
fn helper() {
    // still private to the math module
}
2

Replace the inline definition with a declaration

In src/main.rs, delete the entire mod math { ... } block and write only the declaration:

mod math;
fn main() {
    println!("{}", math::add(2, 3));
}

The two versions produce the identical module hierarchy. The only difference is that math’s source now lives in a separate file. All existing paths — math::add, crate::math::add — continue to work.

The important subtlety: src/math.rs does not contain mod math; again. The module’s name is declared by the mod math; in main.rs. The file itself is just the body of that module.

If It Compiles, You Did It Right:

Run cargo check after the split. If you see no errors, the mapping is correct. The compiler will find math.rs and wire everything together.

Moving Submodules into Subdirectories

A module that itself contains submodules can be split into a directory. Two conventions exist and both are valid in edition 2021.

Convention A — mod.rs (older, still widely used)

src/
  math/
    mod.rs
    add.rs
    sub.rs
  main.rs

mod.rs serves as the entry point for the math module. The parent file (main.rs) still declares mod math;, which now finds the math/ directory and uses mod.rs inside it.

mod math;
fn main() {
    println!("{}", math::add::add(2, 3));
}
pub mod add;
pub mod sub;
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}
pub fn sub(a: i32, b: i32) -> i32 {
    a - b
}

Here math/mod.rs declares two child modules (add and sub). Those children live in math/add.rs and math/sub.rs. The hierarchy: crate::math::add::add, crate::math::sub::sub.

Convention B — module file next to a directory of the same name (newer)

src/
  math.rs
  math/
    add.rs
    sub.rs
  main.rs

math.rs is the module itself, and math/ is a directory that holds its submodules. The compiler resolves mod add; inside math.rs by looking for math/add.rs — exactly as if math/ were the module’s root directory.

mod math;
fn main() {
    println!("{}", math::add::add(2, 3));
}
pub mod add;
pub mod sub;
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}
pub fn sub(a: i32, b: i32) -> i32 {
    a - b
}

Choose mod.rs when you want the directory itself to clearly be the module. Some developers find it easier to spot the module root because the file is named mod.rs.

Both conventions produce identical namespaces. The choice does not affect visibility, privacy, or build times.

Visibility Across File Boundaries

When you split a module into multiple files, you change privacy boundaries. The old single‑file module was one privacy zone: all items inside could see each other regardless of pub. After the split, each file becomes its own submodule. Items that were previously private to the original module are now private to their submodule, and sibling files cannot see them.

Consider this original inline module:

mod network {
    struct Connection {
        id: u64,
    }
    impl Connection {
        fn new(id: u64) -> Connection {
            Connection { id }
        }
    }
    pub fn connect() -> Connection {
        Connection::new(42)
    }
}

If we naively split this into network/mod.rs and network/connection.rs:

pub mod connection;
use connection::Connection;
pub fn connect() -> Connection {
    Connection::new(42)
}
pub struct Connection {  // must be pub to be used from sibling module
    pub id: u64,          // field must be pub if accessed directly
}
impl Connection {
    pub fn new(id: u64) -> Connection {  // must be pub
        Connection { id }
    }
}

This compiles. But notice every piece that was touched from another file had to become pub. The Connection struct, its id field, and new are now public within the crate (or wherever they are re‑exported). This is a deliberate design: each file is a module, so Rust enforces encapsulation between files.

A Single File Is Not a Visibility Escape Hatch:

There is no way to say “make everything in this file visible to one sibling file only.” The granularity is the module. If two sets of items are so tightly coupled that they need mutual access to private details, they likely belong in the same module — and therefore the same file. Use pub(crate) or pub(super) to expand visibility only as far as needed, but you cannot bypass module boundaries entirely.

The pub(super) visibility modifier is often the right tool after a split. It makes an item visible to the parent module without exposing it to the rest of the crate.

pub(super) struct Connection {  // visible to network/mod.rs and its other submodules
    pub(super) id: u64,
}
impl Connection {
    pub(super) fn new(id: u64) -> Connection {
        Connection { id }
    }
}

Now connect in network/mod.rs can still use Connection, but code outside the network module cannot.

The Facade Pattern — Hiding Implementation Details with pub use

Sometimes the file‑system layout you want for maintainability does not match the API you want to expose. For instance, you might put every adapter type in its own file for sanity, but users of your crate should see a flat adapters module, not a deeply nested tree.

This is the facade pattern: declare submodules as private, then re‑export their public items from the parent module with pub use.

mod cache;
mod retry;
pub use cache::Cache;
pub use retry::Retry;
pub struct Cache { /* ... */ }
pub struct Retry { /* ... */ }

Consumers see crate::adapters::Cache and crate::adapters::Retry without ever knowing that cache and retry are separate submodules. Inside the crate, other modules can also use crate::adapters::Cache directly.

pub use Creates Alternative Paths:

When you write pub use cache::Cache;, the item Cache becomes reachable through the original path (crate::adapters::cache::Cache) and the new path (crate::adapters::Cache). Both are valid, but your public documentation should guide users toward the intended one.

This pattern is common in crates with many small types (like futures, tokio, or std). It decouples the development layout from the public API.

How the Compiler Resolves mod Declarations — A Precise Mental Model

When the compiler encounters mod math; in a file located at src/foo/bar.rs, it resolves the module’s source in this order:

  1. src/foo/bar/math.rs — a file directly beside the parent module.
  2. src/foo/bar/math/mod.rs — a directory with a mod.rs entry point.

If neither exists, you get a compilation error. The file system layout must mirror the module tree declared by mod statements. This rule is absolute: no amount of use will pull in a file that hasn’t been declared with mod.

The crate:: prefix in paths refers to the root module, which for a binary is src/main.rs and for a library is src/lib.rs. Every mod declaration extends the tree starting from that root.

Beginners often confuse mod and use. Think of it this way: mod grafts a new branch onto the module tree; use creates a shortcut to something already on the tree. Without mod, there is no branch. Without use, you must walk the full path.

Common Mistakes and Their Fixes

Adding a File and Expecting It to Just Work

pub fn useful() {}
fn main() {
    helpers::useful();  // error: failed to resolve: use of undeclared crate or module `helpers`
}

Fix: Add mod helpers; in main.rs. The compiler never guesses which files to include.

Re‑declaring the Module Inside the File

mod math {           // This creates a new, inner module named math inside the already‑named math module
    pub fn add() {}
}

The outer mod math; in main.rs already named the module. The file’s content should be the module body directly, not another mod math { } wrapper.

Fix: Remove the inner mod math {}. Use only pub fn add() {} at the top level of the file.

Forgetting to Make Items pub for Cross‑File Access

struct Connection { id: u64 }  // private by default
use connection::Connection;  // error: struct `Connection` is private

Fix: Add pub or pub(super) to Connection. When splitting an existing module, audit every item that is used across file boundaries.

Using mod.rs and a Same‑Name File Simultaneously

Having both src/utils.rs and src/utils/mod.rs in the same crate leads to a conflict. The compiler will either pick one (and ignore the other) or raise an error. Keep one convention per module.

Organizing a Real Project — A Worked Example

Imagine you start with a monolithic src/main.rs that contains a network module and a database module.

mod network {
    pub fn connect(addr: &str) { /* ... */ }
    fn encrypt(data: &[u8]) -> Vec<u8> { /* ... */ }
}
mod database {
    pub fn query(sql: &str) { /* ... */ }
}
fn main() {
    network::connect("127.0.0.1");
    database::query("SELECT 1");
}

Refactored structure using the modern convention:

src/
  main.rs
  network.rs
  database/
    mod.rs
    connection.rs
mod network;
mod database;
fn main() {
    network::connect("127.0.0.1");
    database::query("SELECT 1");
}
pub fn connect(addr: &str) { /* ... */ }
fn encrypt(data: &[u8]) -> Vec<u8> { /* ... */ }
pub mod connection;
pub fn query(sql: &str) { /* ... */ }
pub struct Pool { /* ... */ }

The network module is simple enough to stay in one file (network.rs). The database module has been given room to grow by placing it in a directory, with connection.rs as a submodule. Nothing outside database knows about connection unless it is re‑exported.

Summary

Splitting a module into files is not a language feature that introduces new concepts — it is a mapping from mod declarations to the filesystem. The entire technique rests on three rules:

  • A mod name; without a body tells the compiler to look for the module in name.rs or name/mod.rs, relative to the current file.
  • Every file that becomes part of the crate must be reachable from the crate root through a chain of mod declarations. No file is included automatically.
  • A file is a module with its own privacy boundary. Moving code across files often requires raising visibility with pub, pub(super), or pub(crate).

When the internal layout diverges from the public API, use pub use to present a clean facade without leaking directory structure to consumers.

Armed with these rules, you can start with a single main.rs and grow the project organically — splitting modules only when a file becomes too large, and re‑exporting only when the public interface needs to be polished.