In the previous tutorial, we learned Rust collections. As projects grow, putting everything in one file becomes unmanageable. In this tutorial, we learn modules – Rust’s system for organizing code into logical units.

Good project structure makes code easier to read, test, and maintain. Rust’s module system is simple once you understand the rules. Let us learn them step by step.

What is a Module?

A module is a named container for functions, structs, enums, and other items. Modules control:

  • Organization – group related code together
  • Visibility – decide what is public and what is private
  • Namespacing – avoid name conflicts between different parts of your code

Inline Modules

The simplest module is defined inline with the mod keyword:

mod math {
    pub fn add(a: i64, b: i64) -> i64 {
        a + b
    }

    fn internal_helper() -> i64 {
        42  // This is private — only accessible inside the math module
    }
}

fn main() {
    println!("{}", math::add(2, 3));  // OK: add is pub
    // println!("{}", math::internal_helper());  // ERROR: private
}

By default, everything in a module is private. Add pub to make items accessible from outside the module.

Visibility Rules

mod models {
    pub struct User {
        pub name: String,      // Public field
        pub email: String,     // Public field
        age: u32,              // Private field
    }

    impl User {
        pub fn new(name: &str, email: &str, age: u32) -> Self {
            Self {
                name: name.to_string(),
                email: email.to_string(),
                age,
            }
        }

        pub fn age(&self) -> u32 {
            self.age  // Public getter for private field
        }
    }
}

fn main() {
    let user = models::User::new("Alex", "alex@example.com", 25);
    println!("{}", user.name);   // OK: public field
    println!("{}", user.age());  // OK: public method
    // println!("{}", user.age); // ERROR: private field
}

Visibility levels:

  • No keyword – private to the current module
  • pub – public to everyone
  • pub(crate) – public within the crate, but not to external users
  • pub(super) – public to the parent module only

Why Private Fields Matter

Private fields enforce invariants. If age is private, the only way to set it is through User::new(), where you can add validation:

pub fn new(name: &str, email: &str, age: u32) -> Result<Self, String> {
    if age > 150 {
        return Err("Age must be 150 or less".to_string());
    }
    Ok(Self { name: name.to_string(), email: email.to_string(), age })
}

No code outside the module can set age to an invalid value.

The use Keyword

use brings items into scope so you do not need the full path every time:

mod models {
    pub struct User { pub name: String }
    pub struct Task { pub title: String }
}

use models::{User, Task};

fn main() {
    let _user = User { name: "Alex".to_string() };
    let _task = Task { title: "Test".to_string() };
}

Common use Patterns

// Import one item
use models::User;

// Import multiple items
use models::{User, Task};

// Import everything (use sparingly)
use models::*;

// Rename to avoid conflicts
use models::User as AppUser;

// Re-export — make an item available from a different path
pub use models::User;

File-Based Modules

As your project grows, you move modules to separate files. Rust has simple rules for this.

Project Structure

src/
├── main.rs          (or lib.rs for libraries)
├── math.rs          (the math module)
├── models.rs        (the models module)
└── utils/
    ├── mod.rs       (declares sub-modules)
    ├── strings.rs
    └── validation.rs

Declaring Modules

In src/main.rs (or src/lib.rs):

mod math;      // Looks for src/math.rs
mod models;    // Looks for src/models.rs
mod utils;     // Looks for src/utils/mod.rs (because it is a directory)

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

This tells Rust to look for each module in either:

  • src/math.rs – a single file
  • src/math/mod.rs – a directory with sub-modules

The Module File

src/math.rs contains the actual code:

pub fn add(a: i64, b: i64) -> i64 {
    a + b
}

pub fn multiply(a: i64, b: i64) -> i64 {
    a * b
}

pub fn divide(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

Notice: no mod keyword in the file itself. The file is the module. The mod declaration in the parent file connects them.

Directory Modules

For src/utils/mod.rs:

pub mod strings;
pub mod validation;

Each pub mod strings; tells Rust to look for src/utils/strings.rs.

Re-Exports

Re-exports let users import from a simpler path. In src/lib.rs:

pub mod math;
pub mod models;

// Re-exports — users can import directly from the crate root
pub use models::User;
pub use models::Task;

Without re-exports:

use my_crate::models::User;

With re-exports:

use my_crate::User;

Re-exports make your public API cleaner. Users do not need to know your internal module structure.

Nested Modules

Modules can contain other modules:

mod config {
    pub struct AppConfig {
        pub app_name: String,
        pub debug: bool,
    }

    impl AppConfig {
        pub fn new() -> Self {
            Self {
                app_name: "My App".to_string(),
                debug: false,
            }
        }
    }

    pub mod defaults {
        pub const MAX_RETRIES: u32 = 3;
        pub const TIMEOUT_SECS: u64 = 30;
    }
}

fn main() {
    let config = config::AppConfig::new();
    println!("App: {}", config.app_name);
    println!("Max retries: {}", config::defaults::MAX_RETRIES);
}

Accessing Parent Items with super

mod services {
    use super::config::AppConfig;

    pub struct Logger {
        prefix: String,
    }

    impl Logger {
        pub fn new(config: &AppConfig) -> Self {
            Self {
                prefix: config.app_name.clone(),
            }
        }

        pub fn log(&self, message: &str) -> String {
            format!("[{}] {}", self.prefix, message)
        }
    }
}

super refers to the parent module. super::config accesses the config module that is a sibling of services.

lib.rs vs main.rs

A Rust project can have both:

  • src/lib.rs – library code (reusable)
  • src/main.rs – binary code (the application)
src/
├── lib.rs       (library  defines modules, exports public API)
├── main.rs      (binary  uses the library)
└── math.rs

The binary imports from the library using the crate name:

// In src/main.rs
use my_project::math;

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

The crate name comes from Cargo.toml (with hyphens converted to underscores).

Using External Crates

Add dependencies in Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rand = "0.8"

Then use them in your code:

use serde::{Serialize, Deserialize};
use rand::Rng;

#[derive(Serialize, Deserialize, Debug)]
struct Config {
    name: String,
    port: u16,
}

fn main() {
    let mut rng = rand::thread_rng();
    let port: u16 = rng.gen_range(3000..9000);
    println!("Random port: {}", port);
}

Find crates at crates.io. Check download counts and recent updates to pick well-maintained crates.

Cargo Workspace

For large projects, use a workspace with multiple crates:

# Cargo.toml at the workspace root
[workspace]
members = [
    "core",
    "api",
    "cli",
]
my-project/
├── Cargo.toml        (workspace)
├── core/
│   ├── Cargo.toml    (library crate)
│   └── src/lib.rs
├── api/
│   ├── Cargo.toml    (binary crate)
│   └── src/main.rs
└── cli/
    ├── Cargo.toml    (binary crate)
    └── src/main.rs

Each member is an independent crate that can depend on others:

# api/Cargo.toml
[dependencies]
core = { path = "../core" }

Benefits of workspaces:

  • Share a single target directory (faster builds)
  • Share a single Cargo.lock (consistent dependencies)
  • Test everything with cargo test --workspace

Essential Cargo Tools

# Format your code
cargo fmt

# Lint your code (find common mistakes)
cargo clippy

# Generate documentation
cargo doc --open

# Check for security vulnerabilities in dependencies
cargo audit

# Build for release (optimized)
cargo build --release

These tools are part of the Rust toolchain. Use cargo fmt and cargo clippy on every project.

Testing Modules

Each module can have its own tests:

// In src/math.rs
pub fn add(a: i64, b: i64) -> i64 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
    }
}

use super::* imports everything from the parent module. Tests have access to private functions too, since they are defined inside the module.

Run tests:

cargo test                # All tests
cargo test math           # Tests in the math module

Common Mistakes

Mistake 1: Forgetting pub

mod math {
    fn add(a: i64, b: i64) -> i64 { a + b }  // Private!
}

fn main() {
    // math::add(2, 3);  // ERROR: function is private
}

Mistake 2: Missing mod Declaration

Creating a file is not enough. You must declare the module:

// src/main.rs
// WRONG: just having src/math.rs is not enough
// RIGHT: you need this line:
mod math;

Mistake 3: Circular Dependencies

// models.rs uses something from services.rs
// services.rs uses something from models.rs
// This creates a circular dependency!

Fix it by separating shared types into their own module that both can depend on.

Module Organization Guidelines

When to Create a New Module

  • When a file grows beyond 200-300 lines
  • When functions are logically related (all validation, all database queries)
  • When you want to control visibility (hide implementation details)

Naming Conventions

  • Module names: snake_case (e.g., user_service, http_client)
  • File names match module names: user_service.rs
  • Directories for modules with sub-modules: user_service/mod.rs

Common Project Layout

src/
├── lib.rs           (or main.rs)
├── models/          (data structures)
│   ├── mod.rs
│   ├── user.rs
│   └── task.rs
├── services/        (business logic)
│   ├── mod.rs
│   └── user_service.rs
├── handlers/        (API handlers)
│   ├── mod.rs
│   └── user_handler.rs
└── db/              (database layer)
    ├── mod.rs
    └── queries.rs

Summary

ConceptSyntaxPurpose
Inline modulemod name { }Group code in the same file
File modulemod name;Load code from a file
PublicpubMake items accessible
Crate-publicpub(crate)Accessible within crate only
Importuse path::ItemBring items into scope
Re-exportpub use path::ItemExpose items from a shorter path
Parent accesssuper::Access parent module
Crate rootcrate::Access from crate root
Workspace[workspace]Multiple crates in one project

Source Code

View source code on GitHub ->

What’s Next?

We now know how to organize Rust projects with modules, control visibility, use external crates, and set up workspaces. Next, we learn testing in Rust – unit tests, integration tests, assertions, and test organization. Rust has testing built into the language, and it is one of the best testing experiences in any programming language.

Next: Rust Tutorial #18: Testing in Rust