0x55aa
โ† Back to Blog

Rust's Module System: Organizing Code Without Losing Your Mind ๐Ÿฆ€๐Ÿ“ฆ

โ€ข12 min read

Rust's Module System: Organizing Code Without Losing Your Mind ๐Ÿฆ€๐Ÿ“ฆ

Hot take: If you've been organizing Rust projects like it's JavaScript with random files everywhere, you're about to learn why the Rust module system is actually genius (even if it seems weird at first)! ๐Ÿ”ฅ

I know what you're thinking. You come from Python with its import whatever, or JavaScript with its require('./file'), and Rust's module system seems... different. Confusing, even!

But here's the thing: Rust's module system isn't just different - it's designed to scale. From tiny CLI tools to million-line codebases, the same patterns work. And Cargo? It's what npm WISHES it could be! ๐Ÿš€

The Module Mindset Shift ๐Ÿง 

In most languages:

// JavaScript - one file = one module
// math.js
export function add(a, b) { return a + b; }

// main.js
import { add } from './math.js';

In Rust:

// Modules are LOGICAL, not necessarily physical!
// A module can span multiple files OR one file can have multiple modules!

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

use math::add;

The difference: Files are NOT modules by default! You DECLARE modules explicitly. Once you get this, everything clicks! ๐Ÿ’ก

Your First Rust Project: The Basics ๐Ÿ“

Create a new project:

cargo new my_project
cd my_project

You get this structure:

my_project/
โ”œโ”€โ”€ Cargo.toml       # Like package.json, but better
โ”œโ”€โ”€ Cargo.lock       # Like package-lock.json
โ””โ”€โ”€ src/
    โ””โ”€โ”€ main.rs      # Your entry point

That's it! No configuration. No webpack. No babel. No build scripts. Just code and go! ๐ŸŽ‰

Cargo.toml (your project manifest):

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"  # Which Rust edition to use

[dependencies]
# Add external crates here
serde = "1.0"
tokio = { version = "1", features = ["full"] }

Run your project:

cargo run       # Compile + run
cargo build     # Just compile
cargo test      # Run tests
cargo check     # Fast compile check (no binary)

No npm install. No webpack config. No build pipeline. Just works! โœจ

Module Visibility: Who Can See What? ๐Ÿ‘€

The default: EVERYTHING is private!

mod server {
    fn handle_request() {  // Private by default!
        println!("Handling request");
    }

    pub fn start() {  // Public - can be used outside this module
        handle_request();  // Can call private function from same module
    }
}

fn main() {
    server::start();  // โœ… Works - start() is public
    // server::handle_request();  // โŒ Error - handle_request() is private!
}

The rule: Everything is private unless you say pub. Opposite of JavaScript where everything is exported by default!

Why it's genius: Prevents accidental API exposure. Your internal functions stay internal! ๐Ÿ”’

File-Based Modules: The Proper Way ๐Ÿ“‚

Small project - everything in main.rs:

// src/main.rs
mod utils {
    pub fn greet(name: &str) {
        println!("Hello, {}!", name);
    }
}

fn main() {
    utils::greet("World");
}

Growing project - split into files:

Option 1: The classic way

src/
โ”œโ”€โ”€ main.rs
โ”œโ”€โ”€ utils.rs
โ””โ”€โ”€ models.rs
// src/main.rs
mod utils;   // Declares: "there's a module in utils.rs"
mod models;  // Declares: "there's a module in models.rs"

fn main() {
    utils::greet("World");
    let user = models::User::new(1, "Alice");
}

// src/utils.rs
pub fn greet(name: &str) {
    println!("Hello, {}!", name);
}

// src/models.rs
pub struct User {
    pub id: u64,
    pub name: String,
}

impl User {
    pub fn new(id: u64, name: String) -> Self {
        User { id, name }
    }
}

The key: mod utils; in main.rs tells Rust "look for utils.rs". No relative paths needed! ๐ŸŽฏ

Directory-Based Modules: Organizing Complex Code ๐Ÿ—๏ธ

When a module gets big, split it into a directory:

src/
โ”œโ”€โ”€ main.rs
โ””โ”€โ”€ database/
    โ”œโ”€โ”€ mod.rs       # The "index" file for the database module
    โ”œโ”€โ”€ connection.rs
    โ””โ”€โ”€ queries.rs
// src/main.rs
mod database;  // Looks for database/mod.rs

fn main() {
    database::connection::connect();
    database::queries::fetch_users();
}

// src/database/mod.rs (the entry point)
pub mod connection;  // Declares connection.rs as a submodule
pub mod queries;     // Declares queries.rs as a submodule

// Re-export for convenience
pub use connection::connect;
pub use queries::fetch_users;

// src/database/connection.rs
pub fn connect() {
    println!("Connecting to database...");
}

// src/database/queries.rs
pub fn fetch_users() {
    println!("Fetching users...");
}

Now users can do:

// Long form
database::connection::connect();

// Or thanks to re-exports:
database::connect();  // Cleaner!

The pattern: mod.rs is the "index.js" of Rust. It defines what's in the module! ๐Ÿ“‹

The Modern Way: Inline Module Files ๐Ÿ†•

Since Rust 2018, there's a cleaner way:

src/
โ”œโ”€โ”€ main.rs
โ”œโ”€โ”€ database.rs      # The "index" file
โ””โ”€โ”€ database/
    โ”œโ”€โ”€ connection.rs
    โ””โ”€โ”€ queries.rs
// src/database.rs (replaces database/mod.rs)
pub mod connection;
pub mod queries;

pub use connection::connect;
pub use queries::fetch_users;

Why it's better:

  • No more confusion between database.rs and database/mod.rs
  • Clearer structure
  • File names match module names exactly

This is the RECOMMENDED way in modern Rust! ๐ŸŽ–๏ธ

Use Statements: Import Like a Boss ๐Ÿ“ฅ

Don't do this:

// Bad - repetitive!
std::collections::HashMap
std::collections::HashSet
std::collections::BTreeMap

Do this:

use std::collections::{HashMap, HashSet, BTreeMap};

let map = HashMap::new();  // Clean!

Import everything from a module:

use std::io::*;  // Imports all public items

// Use with caution - can be unclear where things come from

Rename imports:

use std::collections::HashMap as Map;

let users = Map::new();  // HashMap renamed to Map

Re-export for cleaner APIs:

// Internal module structure
mod internal {
    pub mod deep {
        pub mod nested {
            pub fn helper() {}
        }
    }
}

// Re-export at top level
pub use internal::deep::nested::helper;

// Users see:
my_crate::helper();  // Not my_crate::internal::deep::nested::helper()!

The power: Hide implementation details, expose clean APIs! ๐ŸŽญ

Cargo Workspaces: Monorepo Done Right ๐Ÿข

When your project grows, split it into multiple crates:

my_workspace/
โ”œโ”€โ”€ Cargo.toml       # Workspace manifest
โ”œโ”€โ”€ server/
โ”‚   โ”œโ”€โ”€ Cargo.toml
โ”‚   โ””โ”€โ”€ src/
โ”‚       โ””โ”€โ”€ main.rs
โ”œโ”€โ”€ client/
โ”‚   โ”œโ”€โ”€ Cargo.toml
โ”‚   โ””โ”€โ”€ src/
โ”‚       โ””โ”€โ”€ main.rs
โ””โ”€โ”€ shared/
    โ”œโ”€โ”€ Cargo.toml
    โ””โ”€โ”€ src/
        โ””โ”€โ”€ lib.rs

Root Cargo.toml:

[workspace]
members = [
    "server",
    "client",
    "shared",
]

# Shared dependencies across all crates
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = "1.0"

server/Cargo.toml:

[package]
name = "server"
version = "0.1.0"

[dependencies]
shared = { path = "../shared" }  # Local dependency!
tokio = { workspace = true }     # Use workspace version

Now you can:

# Build everything
cargo build

# Run specific crate
cargo run -p server

# Test everything
cargo test

# One shared target/ directory for all crates!

Why workspaces rock:

  • Share dependencies across projects
  • One target/ directory (saves TONS of disk space!)
  • Test everything together
  • Version management in one place

Real example: Rust itself is a workspace with 100+ crates! ๐Ÿคฏ

Library vs Binary Crates ๐Ÿ“š

Binary crate (has main()):

src/
โ””โ”€โ”€ main.rs  # Entry point with fn main()
cargo run  # Compiles and runs the binary

Library crate (no main()):

src/
โ””โ”€โ”€ lib.rs  # Entry point, exports public API
cargo build  # Compiles the library
# Other crates can depend on it!

BOTH library AND binary:

src/
โ”œโ”€โ”€ lib.rs   # Library code
โ”œโ”€โ”€ main.rs  # Binary that uses the library
โ””โ”€โ”€ bin/
    โ”œโ”€โ”€ tool1.rs  # Additional binary
    โ””โ”€โ”€ tool2.rs  # Another binary
// src/lib.rs
pub fn do_thing() {
    println!("Doing the thing!");
}

// src/main.rs
use my_crate::do_thing;  // Use your own library!

fn main() {
    do_thing();
}

// src/bin/tool1.rs
use my_crate::do_thing;

fn main() {
    println!("Tool 1");
    do_thing();
}

Build specific binaries:

cargo build --bin tool1   # Just build tool1
cargo run --bin tool2     # Run tool2

The pattern: One library, many binaries using it! ๐ŸŽฏ

Testing Modules: Where to Put Tests? ๐Ÿงช

Unit tests - in the same file:

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

#[cfg(test)]  // Only compile in test mode
mod tests {
    use super::*;  // Import from parent module

    #[test]
    fn test_add() {
        assert_eq!(add(2, 2), 4);
    }
}

Integration tests - in tests/ directory:

src/
โ””โ”€โ”€ lib.rs
tests/
โ”œโ”€โ”€ integration_test.rs
โ””โ”€โ”€ common/
    โ””โ”€โ”€ mod.rs  # Shared test utilities
// tests/integration_test.rs
use my_crate::some_function;  // Use as external user would

#[test]
fn it_works() {
    assert!(some_function());
}

Run tests:

cargo test                    # All tests
cargo test test_add          # Specific test
cargo test --lib             # Only unit tests
cargo test --test integration_test  # Specific integration test

The wisdom: Unit tests live with code. Integration tests live in tests/. Best of both worlds! โœ…

Real-World Project Structure ๐Ÿ—๏ธ

Here's what a real Rust web app looks like:

my_web_app/
โ”œโ”€โ”€ Cargo.toml
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ main.rs              # Entry point
โ”‚   โ”œโ”€โ”€ lib.rs               # Library exports
โ”‚   โ”œโ”€โ”€ config.rs            # Configuration
โ”‚   โ”œโ”€โ”€ routes/
โ”‚   โ”‚   โ”œโ”€โ”€ mod.rs          # Routes entry point
โ”‚   โ”‚   โ”œโ”€โ”€ users.rs
โ”‚   โ”‚   โ””โ”€โ”€ posts.rs
โ”‚   โ”œโ”€โ”€ models/
โ”‚   โ”‚   โ”œโ”€โ”€ mod.rs
โ”‚   โ”‚   โ”œโ”€โ”€ user.rs
โ”‚   โ”‚   โ””โ”€โ”€ post.rs
โ”‚   โ”œโ”€โ”€ services/
โ”‚   โ”‚   โ”œโ”€โ”€ mod.rs
โ”‚   โ”‚   โ”œโ”€โ”€ auth.rs
โ”‚   โ”‚   โ””โ”€โ”€ email.rs
โ”‚   โ”œโ”€โ”€ db/
โ”‚   โ”‚   โ”œโ”€โ”€ mod.rs
โ”‚   โ”‚   โ”œโ”€โ”€ connection.rs
โ”‚   โ”‚   โ””โ”€โ”€ migrations.rs
โ”‚   โ””โ”€โ”€ utils/
โ”‚       โ”œโ”€โ”€ mod.rs
โ”‚       โ””โ”€โ”€ validation.rs
โ”œโ”€โ”€ tests/
โ”‚   โ”œโ”€โ”€ api_tests.rs
โ”‚   โ””โ”€โ”€ integration_tests.rs
โ””โ”€โ”€ benches/                 # Benchmarks!
    โ””โ”€โ”€ performance.rs

Organized, scalable, maintainable! ๐Ÿ“

Cargo Features: Conditional Compilation ๐ŸŽš๏ธ

Define features in Cargo.toml:

[features]
default = ["json"]  # Enabled by default
json = ["serde_json"]
xml = ["quick-xml"]
all = ["json", "xml"]

[dependencies]
serde_json = { version = "1.0", optional = true }
quick-xml = { version = "0.31", optional = true }

Use features in code:

#[cfg(feature = "json")]
pub fn parse_json(data: &str) -> Result<Value, Error> {
    serde_json::from_str(data)
}

#[cfg(feature = "xml")]
pub fn parse_xml(data: &str) -> Result<Document, Error> {
    quick_xml::Reader::from_str(data).read()
}

Build with features:

cargo build                      # Default features
cargo build --features xml       # Enable xml feature
cargo build --no-default-features  # No features
cargo build --all-features       # Everything!

Why it's genius: Users only compile what they need. Faster builds, smaller binaries! ๐Ÿš€

Common Patterns You'll Love โค๏ธ

Pattern 1: The Prelude Pattern

// src/prelude.rs - Common imports
pub use crate::models::*;
pub use crate::services::*;
pub use crate::error::Error;

// Now in any file:
use crate::prelude::*;  // Get everything you need!

Pattern 2: Error Module

// src/error.rs
use std::fmt;

#[derive(Debug)]
pub enum Error {
    NotFound,
    DatabaseError(String),
    // ... more errors
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        // Implementation
    }
}

// Re-export as Result<T>
pub type Result<T> = std::result::Result<T, Error>;

Pattern 3: Module Re-exports

// src/lib.rs - Clean public API
mod internal_stuff;  // Keep private

// Only expose what users need
pub use internal_stuff::PublicThing;
pub mod public_module;

// Hide implementation details!

The Build Process: Zero Config Magic โœจ

What happens when you run cargo build:

  1. Reads Cargo.toml - Understands your dependencies
  2. Downloads crates - From crates.io (Rust's package registry)
  3. Compiles dependencies - Once, caches forever
  4. Compiles your code - With all optimizations
  5. Links everything - Creates final binary

No webpack. No babel. No build.gradle. No CMakeLists.txt. Just Cargo.toml and you're done! ๐ŸŽ‰

Release builds:

cargo build --release  # Optimized, slow compile, FAST runtime

The difference:

  • Debug: Fast compile, slow runtime, huge binary (good for development)
  • Release: Slow compile, lightning runtime, tiny binary (good for production)

Benchmarks:

cargo bench  # Run benchmarks with optimizations

Cargo Commands You'll Use Daily โš™๏ธ

# Development
cargo new project_name       # New project
cargo init                   # Init in existing directory
cargo add serde             # Add dependency (like npm install)
cargo remove serde          # Remove dependency

# Building
cargo build                  # Debug build
cargo build --release       # Optimized build
cargo check                 # Fast check (no binary)
cargo run                   # Build + run
cargo run --release         # Optimized run

# Testing
cargo test                  # Run all tests
cargo test test_name        # Run specific test
cargo test -- --nocapture   # Show println! output

# Publishing
cargo publish               # Publish to crates.io
cargo doc                   # Generate documentation
cargo doc --open           # Generate + open docs

# Maintenance
cargo clean                 # Delete target/ directory
cargo update                # Update dependencies
cargo tree                  # Show dependency tree
cargo clippy                # Linting (catches common mistakes!)
cargo fmt                   # Format code (like prettier!)

Cargo is ALL your tools in one! No separate linter, formatter, test runner, or package manager! ๐Ÿ› ๏ธ

When Modules Solve Real Problems ๐Ÿ› ๏ธ

Problem: Circular dependencies

Bad:

// models.rs
use crate::services::UserService;  // โŒ

// services.rs
use crate::models::User;  // โŒ

// Circular dependency! Won't compile!

Good:

// models.rs - Pure data
pub struct User {
    pub id: u64,
    pub name: String,
}

// services.rs - Business logic
use crate::models::User;

pub struct UserService;

impl UserService {
    pub fn create_user(name: String) -> User {
        User { id: 1, name }
    }
}

// One-way dependency! โœ…

The lesson: Keep models pure, let services depend on models. Never the other way! ๐Ÿ“

The Bottom Line ๐ŸŽฏ

Rust's module system and Cargo aren't just good - they're GREAT:

  1. Explicit modules - No magic imports, clear structure
  2. Privacy by default - Prevents accidental API exposure
  3. Cargo does everything - Build, test, lint, format, publish
  4. Workspaces - Monorepo that actually works
  5. Zero configuration - Just Cargo.toml and go!

Think about it: Would you rather manage webpack configs and npm scripts, or just run cargo build and have everything work?

I know my answer! ๐Ÿฆ€

Remember:

  1. mod declares modules (not imports!)
  2. use imports items (for convenience)
  3. Everything is private by default (pub to expose)
  4. mod.rs or module_name.rs for organization
  5. Cargo does EVERYTHING (build, test, deps, publish!)

Rust's module system proves that organization and simplicity aren't opposites. With clear rules and zero-config tools, you can build projects that scale from hobby to production without rewriting your build system! ๐Ÿš€โœจ


Ready to organize your Rust projects? Connect with me on LinkedIn - Let's talk project architecture!

Want to see well-organized Rust code? Check out my GitHub and follow this blog!

Now go build something amazing with Cargo! ๐Ÿฆ€๐Ÿ“ฆ