Skip to lesson

learningrust.org / intermediate / 17-modules-and-crates · lesson 17 of 26

TL;DR

Learn how to organize Rust code with modules and manage dependencies using crates and Cargo

Key concepts

  • Rust modules
  • Rust crates
  • Rust Cargo
  • Rust code organization
  • Rust mod system

Modules and Crates

As your Rust programs grow, keeping all your code in a single file becomes unwieldy. Rust's module system gives you precise control over how you structure, organize, and expose your code. Paired with crates — Rust's unit of compilation and code sharing — the module system forms the backbone of every real-world Rust project.

What You'll Learn

By the end of this lesson you will finish a small library laid out the way a real crate is — a private helper it does not export, a struct with one public field and one private one, and a child module that reaches up into its parent — and get it compiling by deciding, item by item, what to publish and what to keep in. The part that catches people is that pub is not a switch on an item; it is a claim about who may see it, and a call site three modules away can be refused by any single missing pub on the path.

This is the shape the capstone's taskwork CLI is built in. That program splits into four inline mod blocks — the task model, the line parser, the rendering, and the command-line parser — and the boundaries between them are exactly the decisions you are about to make here, including which items the other modules reach with use super::. You arrive here able to define a struct with an impl block and a constructor (Structs and Enums); what is new is that the constructor now has a reason to exist beyond convenience.

What Is a Module?

A module is a named container for items like functions, structs, enums, constants, and even other modules. You define one with the mod keyword. Everything inside a module is private by default — only the module itself can see it — with two exceptions: the associated items of a pub trait, and the variants of a pub enum, which are public along with the item that declares them.

mod greetings {
    pub fn hello(name: &str) -> String {
        format!("Hello, {}!", name)
    }

    pub fn goodbye(name: &str) -> String {
        format!("Goodbye, {}!", name)
    }
}

fn main() {
    let msg = greetings::hello("Alice");
    println!("{}", msg);

    let farewell = greetings::goodbye("Bob");
    println!("{}", farewell);
}

The :: operator navigates the module path. greetings::hello means "the hello function inside the greetings module". Without pub, calling those functions from outside the module would be a compile error.

Controlling Visibility with pub

By default, items inside a module are private — only accessible to code within the same module. The pub keyword makes an item public, exposing it to the outside world.

mod geometry {
    pub struct Circle {
        pub radius: f64,
    }

    impl Circle {
        pub fn new(radius: f64) -> Self {
            Circle { radius }
        }

        pub fn area(&self) -> f64 {
            std::f64::consts::PI * self.radius * self.radius
        }

        // Private helper — only usable inside this module
        fn circumference(&self) -> f64 {
            2.0 * std::f64::consts::PI * self.radius
        }
    }
}

fn main() {
    let c = geometry::Circle::new(5.0);
    println!("Radius: {}", c.radius);
    println!("Area: {:.2}", c.area());
    // c.circumference() would be a compile error — it's private
}

Notice that for structs, each field also needs pub separately if you want it accessible outside the module. This gives you fine-grained control: a struct can be public while keeping some of its internals private, forcing callers to use constructor functions instead of building the struct directly.

Narrowing a surface, one stage at a time

pub is easy to read as a switch you flip when the compiler complains. It is more useful to watch a module narrow, because each narrowing forces a change on the caller and the change is the argument for it. Same module, three stages. Read the comments for why the previous stage was not good enough:

mod inventory {
    // Stage 1: everything pub. It compiles, and it commits you to all of it.
    pub struct Item {
        pub name: String,
        pub count: u32,
    }

    pub fn restock(item: &mut Item, n: u32) {
        item.count += n;
    }
}

fn main() {
    let mut i = inventory::Item { name: String::from("bolt"), count: 2 };
    inventory::restock(&mut i, 3);
    println!("{} x{}", i.name, i.count);
}

Nothing is wrong with that program, and everything is wrong with the module. count being pub means any caller may assign to it — so restock is not the way stock changes, it is merely a way, and the invariant it was written to protect does not exist. Take the field private and the compiler forces the caller to come through the door instead:

mod inventory {
    // Stage 2: count goes private, so the struct literal in main is no longer
    // legal and a constructor becomes the only door in. That is not a rule
    // about style - it is what makes restock the ONLY way count can change.
    pub struct Item {
        pub name: String,
        count: u32,
    }

    impl Item {
        pub fn new(name: &str, count: u32) -> Self {
            Item { name: name.to_string(), count }
        }

        // The accessor publishes the VALUE, not the field. Storing the count
        // differently later changes this one line, not every caller.
        pub fn count(&self) -> u32 {
            self.count
        }
    }

    pub fn restock(item: &mut Item, n: u32) {
        item.count += n;
    }
}

fn main() {
    let mut i = inventory::Item::new("bolt", 2);
    inventory::restock(&mut i, 3);
    println!("{} x{}", i.name, i.count());
}

Both print bolt x5. The difference is not in the output; it is in what a future caller is able to do, which is the only thing visibility ever controls.

Now do the third stage yourself, and it is a design question rather than a syntax one. Add a child module inventory::audit containing fn describe(item: &Item) -> String that formats the line, and have inventory::report call it — so main never names audit at all. Then decide: should describe be pub, or pub(super)? Write down your answer and your reason before you try either. Both compile; the compiler will not help you choose, and that is precisely the situation pub(super) exists for.

That per-field rule is the single most common surprise in the module system, and it bites at the call site rather than at the definition — the struct compiles perfectly, and the error arrives in whoever tries to read the field. The program below is a small catalog that does exactly that. Commit a hypothesis about which line rustc rejects before you change anything:

Debug

This should build two products and total their prices to 2050 cents. The module compiles, the constructor works, and main looks ordinary — but it does not build. Name the line rustc rejects and why, then fix it the way a library author should.

mod catalog {
  pub struct Product {
      pub name: String,
      price_cents: u32,
  }

  impl Product {
      pub fn new(name: &str, price_cents: u32) -> Self {
          Product { name: name.to_string(), price_cents }
      }
  }
}

fn main() {
  // Build a product and total up an order.
  let items = vec![
      catalog::Product::new("Mug", 850),
      catalog::Product::new("Poster", 1200),
  ];

  let total: u32 = items.iter().map(|p| p.price_cents).sum();

  assert_eq!(total, 2050, "expected the order to total 2050 cents, got {}", total);
  println!("order total: {} cents", total);
  for p in &items {
      println!("  {}", p.name);
  }
}

Expected output: order total: 2050 cents Mug Poster

Continue learning

The rejected line is the map closure, with error[E0616]: field price_cents of struct catalog::Product is private. pub on a struct makes the type visible, never its fields — each field needs its own pub, which is why p.name on the next line is fine and p.price_cents is not. Notice where the error lands: inside main, not inside the module. The definition is legal Rust; only an outside reader is refused. Two fixes exist and they are not equal. Adding pub price_cents: u32 costs one word but publishes the representation, so storing the price in dollars later becomes a breaking change. The better fix is the one the module system is nudging you toward: an accessor, pub fn price_cents(&self) -> u32, called as p.price_cents(). It is the same instinct that put Product::new there in the first place — callers never assemble the struct field by field, so the module stays free to change how it stores things.

Nested Modules and use

Modules can contain other modules, building a tree-shaped hierarchy. Typing full module paths like library::fiction::recommend every time gets tedious, so the use keyword creates a shortcut, bringing items into the current scope.

mod math {
    pub mod stats {
        pub fn mean(values: &[f64]) -> f64 {
            let sum: f64 = values.iter().sum();
            sum / values.len() as f64
        }

        pub fn max(values: &[f64]) -> f64 {
            values.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
        }

        pub fn min(values: &[f64]) -> f64 {
            values.iter().cloned().fold(f64::INFINITY, f64::min)
        }
    }
}

use math::stats::{mean, max, min};

fn main() {
    let data = vec![3.0, 7.0, 2.0, 9.0, 5.0];
    println!("Mean: {:.1}", mean(&data));
    println!("Max:  {:.1}", max(&data));
    println!("Min:  {:.1}", min(&data));
}

You can bring in multiple items from the same path using {item1, item2} syntax. You can also write use math::stats::* to import everything from a module, though this is generally discouraged outside of test modules because it makes it unclear where names come from.

The super and self Keywords

Inside a module, self refers to the current module and super refers to the parent module. These let you write relative paths instead of always starting from the crate root.

mod config {
    pub const MAX_CONNECTIONS: u32 = 100;
    pub const TIMEOUT_SECS: u64 = 30;

    pub mod defaults {
        use super::{MAX_CONNECTIONS, TIMEOUT_SECS};

        pub fn print_limits() {
            println!("Max connections: {}", MAX_CONNECTIONS);
            println!("Default timeout: {}s", TIMEOUT_SECS);
        }

        pub fn is_within_limit(connections: u32) -> bool {
            connections <= MAX_CONNECTIONS
        }
    }
}

use config::defaults;

fn main() {
    defaults::print_limits();
    println!("50 connections OK? {}", defaults::is_within_limit(50));
    println!("200 connections OK? {}", defaults::is_within_limit(200));
}

use super::MAX_CONNECTIONS inside defaults means "go up to the parent module (config) and bring MAX_CONNECTIONS into scope here."

Those constants were pub, so nothing surprising happened. But what if the parent's item is not pub? "Private" sounds absolute, and it is not — privacy in Rust has a direction. The program below has a parent module with a completely private function and a public child that calls it. Decide whether it compiles, and what happens in each direction, before you run it:

Predict

store::secret_rate has no pub anywhere on it. The child module store::pricing calls it through super::. The parent store also calls down into the child's pub function. Predict whether this compiles and what it prints — and, separately, what would happen if main tried to call store::secret_rate() directly.

mod store {
  // Private to store. No pub anywhere on it.
  fn secret_rate() -> u32 {
      7
  }

  pub mod pricing {
      // Reaching UP into the parent's private item.
      pub fn quote(units: u32) -> u32 {
          units * super::secret_rate()
      }
  }

  pub fn parent_quote(units: u32) -> u32 {
      // Reaching DOWN into the child's public item.
      pricing::quote(units)
  }
}

fn main() {
  println!("child sees parent's private fn: {}", store::pricing::quote(3));
  println!("parent calls child's pub fn:     {}", store::parent_quote(3));
}
Continue learning

Both lines print 21, and a call to store::secret_rate() from main would fail with error[E0603]: function secret_rate is private. Privacy in Rust is relative to the module tree, not absolute. A private item is visible inside the module that defines it and inside every module nested within it, so a child may reach up with super:: into its parent's internals. Reaching down is different: a parent sees only what the child marked pub, and code outside the subtree sees only what is pub at every level of the path. That asymmetry is what makes submodules worth having — you can split a large module into pieces that share private helpers without any of those helpers escaping into the crate's public API.

Repair the visibility, without over-publishing

Here is that rule turned into work. The program below is a three-level tree — billing::invoice::line and billing::invoice::totals — and it does not compile. Nothing is wrong with any algorithm in it; the arithmetic is right and the call sites are right. Two items are simply not visible from where they are being used.

Your job is the one a library author actually has: make it compile by adding the fewest, narrowest visibility annotations that do it. There is more than one arrangement that compiles, and the difference between them is the whole exercise.

Debug

This invoice program is correct and does not compile. Two items are not visible from where they are used. Read both errors, say which item each one names and from where it was being reached, then repair the tree with the fewest and narrowest visibility annotations that make it build. Before you type anything, commit to an answer for this: for each of the two, is the right fix pub, or something narrower than pub?

mod billing {
  pub mod invoice {
      pub mod line {
          pub struct Line {
              pub label: String,
              cents: u32,
          }

          impl Line {
              pub fn new(label: &str, cents: u32) -> Self {
                  Line { label: label.to_string(), cents }
              }

              fn cents(&self) -> u32 {
                  self.cents
              }
          }
      }

      mod totals {
          use super::line::Line;

          pub fn sum(lines: &[Line]) -> u32 {
              lines.iter().map(|l| l.cents()).sum()
          }
      }
  }
}

use billing::invoice::line::Line;
use billing::invoice::totals::sum;

fn main() {
  let lines = vec![Line::new("Mug", 850), Line::new("Poster", 1200)];
  assert_eq!(sum(&lines), 2050, "the invoice should total 2050 cents");
  for l in &lines {
      println!("  {}", l.label);
  }
  println!("invoice total: {} cents", sum(&lines));
}

Expected output: Mug Poster invoice total: 2050 cents

Continue learning

Two errors, two different situations, and the error codes separate them before you read any code. error[E0603]: module \totals` is privateis about a **path** —mainwalksbilling::invoice::totals::sum, and every level of a path must be visible to whoever is walking it. The caller is outside the subtree but inside the same crate, so pub(crate) mod totalsis the narrowest fix. Plainpub mod totalsalso compiles, but exposes more than this call requires.error[E0624]: method `cents` is privateis about a **method**, and its caller is notmain— it issum, sitting one module over inside invoice. pub(super)publishescentstoinvoiceand stops there; plainpub` compiles just as well and gives it to the world. Both build. Only one of them says what you meant.

Both of those codes are worth knowing as codes. Read to the bottom of the output and rustc tells you so itself — it ends with Some errors have detailed explanations: E0603, E0624. and then For more information about an error, try `rustc --explain E0603`. An error code is a stable, searchable identifier, not prose that may be reworded in the next release, and a code that carries a detailed explanation has a written article behind it that names the rule the compiler was applying. When a visibility error is unfamiliar, reading that is faster than guessing at the message — and it is exactly what you need in order to choose between two fixes that both compile.

What Is a Crate?

A crate is Rust's fundamental unit of compilation. Every Rust project you build is a crate. There are two kinds:

  • Binary crates — compile to an executable. They have a main function and their root file is src/main.rs.
  • Library crates — compile to a reusable .rlib that other crates can depend on. Their root file is src/lib.rs.

When you run cargo new my_project, Cargo creates a binary crate. Running cargo new --lib my_library creates a library crate.

The entire Rust ecosystem lives on crates.io. Adding a dependency is as simple as editing your Cargo.toml:

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

After editing Cargo.toml, running cargo build downloads and compiles the dependencies automatically. Cargo tracks exact versions in Cargo.lock so builds are reproducible across machines.

Before assembling all of this, pull one prerequisite back out of memory. Every module above hangs its behaviour off an impl block, and the constructor pattern those blocks use is not a module-system idea at all — you met it several lessons earlier:

Recall

Without scrolling up: in Structs & Enums you wrote both Type::new(...) and value.method(). The Circle::new and Item::new above are the first kind. What distinguishes an associated function from a method, and why does that distinction matter the moment a struct's fields go private?

Continue learning

An associated function takes no self and is called on the type with ::; a method takes self, &self or &mut self and is called on a value with a dot. Both live in impl blocks, and the receiver is the whole difference. It becomes load-bearing the moment a field goes private, because struct-literal syntax requires every field to be visible to the code writing it — so an outside caller simply cannot construct the value. A pub associated function declared inside the module can see the private fields, which makes it the only door in. That is why a struct with private fields nearly always ships a new, and why that constructor is the natural place to enforce invariants: everything has to come through it.

Modules Elsewhere

Every language with more than one file has had to answer the same question, and the answers rhyme without matching. If you have written JavaScript or TypeScript, it is worth being exact about which half of your instincts carries over:

Transfer

In JavaScript and TypeScript, an ES module's bindings are private to the file unless you mark them export, and an importing file can only reach what was exported. Rust says the same thing with pub. Which statement names what genuinely transfers between the two systems, rather than a resemblance that will mislead you?

Continue learning

Try It Yourself

Reading about visibility is not the same as designing a boundary. This is a build task: a small program that reports its own pass/fail. You are given a library module laid out the way a real crate would be — a private helper it does not export, a pub struct with one public and one private field, and a child module that reaches up into its parent — with three things missing. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.

Every one of the three is a visibility decision rather than an algorithm, and each body is a line or two. Book::new must reach the module's private normalize, which only code inside library can do. copies() is the accessor that makes a private field readable without publishing it — exactly the fix the debug block above argued for. And shelf::total_copies sits in a child module, so it must go through that accessor too, because copies is private to library and shelf is inside it. The whole file is one crate with no Cargo.toml, which is why every mod is declared inline.

Build

Finish the build. Three items are stubbed out and the checks below them fail until each behaves. Run it as-is to see which check fails first, decide what that item needs, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

// A single-file crate laid out the way a real one would be: a library module
// with a narrow public surface, a private helper it does not export, and a
// child module that reaches up into its parent.
//
// Three things are missing, and every one of them is a VISIBILITY decision
// rather than an algorithm. The bodies you need are one line each.

mod library {
  // PRIVATE to library on purpose: the outside world must not depend on it.
  // Code inside library — including its child modules — can still call it.
  fn normalize(title: &str) -> String {
      title.trim().to_lowercase()
  }

  pub struct Book {
      pub title: String,
      // Deliberately NOT pub: callers go through an accessor.
      copies: u32,
  }

  impl Book {
      // TODO 1: build a Book whose title has been run through the module's
      //   private normalize helper, keeping copies as given.
      pub fn new(title: &str, copies: u32) -> Self {
          let _ = (title, copies);
          Book {
              title: String::new(),
              copies: 0,
          }
      }

      // TODO 2: expose the private copies field to the outside world.
      //   This is the accessor that makes copies private-but-readable.
      pub fn copies(&self) -> u32 {
          0
      }
  }

  pub mod shelf {
      use super::Book;

      // TODO 3: sum copies() across every book.
      //   You are INSIDE library::shelf, so Book is in scope from the use above.
      pub fn total_copies(books: &[Book]) -> u32 {
          let _ = books;
          0
      }

      pub fn titles(books: &[Book]) -> Vec<String> {
          books.iter().map(|b| b.title.clone()).collect()
      }
  }
}

use library::shelf::{titles, total_copies};
use library::Book;

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  let books = vec![
      Book::new("  The Rust Book  ", 3),
      Book::new("Systems Programming", 2),
  ];

  assert_eq!(
      books[0].title, "the rust book",
      "Book::new should run the title through the module's private normalize helper"
  );
  assert_eq!(
      books[0].copies(),
      3,
      "copies() should expose the private field's value"
  );
  assert_eq!(
      total_copies(&books),
      5,
      "shelf::total_copies should sum copies() across every book"
  );
  assert_eq!(
      titles(&books),
      vec![
          String::from("the rust book"),
          String::from("systems programming")
      ],
      "shelf::titles should return each normalized title in order"
  );

  println!("All checks passed.");
  println!("titles: {:?}", titles(&books));
  println!("total copies: {}", total_copies(&books));
}

Expected output: All checks passed. titles: ["the rust book", "systems programming"] total copies: 5

Continue learning

Once it passes, try two variations and predict each before running:

  1. Construct the struct from outside. In main, replace the first Book::new(...) with a struct literal, Book { title: String::from("the rust book"), copies: 3 }, and predict what the compiler says before running. It is error[E0451]: field copies of struct Book is private — struct-literal syntax needs every field visible to the code writing it, and main is outside library. This is the constructor earning its existence: it is not a stylistic convention, it is the only door in.
  2. Publish the field instead of the accessor. Add pub to copies and change total_copies to read b.copies directly, then predict whether the checks still pass. They do — and that is the point. The compiler is perfectly happy, but you have now made the representation part of your public API, so a later change from a u32 count to, say, a Vec of copy records breaks every caller. The accessor version survives that change untouched.

Capstone milestone

Milestone — the module split. The capstone's taskwork CLI is one file containing four inline mod blocks: the task model, the line parser, the rendering, and the command-line parser. Each one owns a single job, and the modules reach each other with use super:: naming the specific items they need. The library module you just finished is that split in miniature. Confirm you can decide a boundary rather than react to a compiler error.

Hint: You do not need the task model or the parser yet — this confirms the boundary judgment they rest on. In taskwork every mod is declared inline in the one file, for the reason this lesson gives: there is no Cargo.toml, so a file-per-module layout is not available, and inline mod blocks are the same module system either way.

  • Split a program into named mod blocks along what each part is FOR, not along what would fit on a screen
  • Reached across a module boundary with use super:: rather than crate::, and can say what the relative path buys when the modules move together
  • Imported the specific items a module needs rather than a glob, and can say which module a change to the task file format would touch
  • Read a visibility error, identified which item and which caller it named, and chose between two annotations that both compile
Continue learning

Key Takeaways

  • Modules (mod) group related code into named namespaces, keeping your codebase organized as it grows
  • Items inside a module are private by default — use pub to expose them to the outside
  • For structs, both the struct itself and each field need separate pub declarations
  • The use keyword imports items into the current scope to reduce repetitive path typing
  • use module::{item1, item2} lets you import multiple items from one path in a single statement
  • super navigates to the parent module; self refers to the current module
  • A crate is Rust's compilation unit — binary crates produce executables, library crates produce reusable APIs
  • External crates are declared in Cargo.toml and downloaded automatically by Cargo

Pro Tip: When designing a library, start with everything private and only add pub when you have a concrete reason. Public items are a commitment — once other code depends on them, changing their signature is a breaking change. A narrow, intentional public surface is far easier to maintain and evolve than one that exposed implementation details by accident.

Next Steps

Now that you can structure your code with modules, it's time to learn about concurrency — how Rust enables safe concurrent programming with threads, message passing, and shared state.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.