# Modules, crates & Cargo

> Organize code into modules with deliberate visibility, manage dependencies and features with Cargo, and split large projects into workspaces.

Rust roadmap · Core language · 16 min read · https://levelcode.org/roadmaps/rust/modules-and-cargo

A single `main.rs` gets you through the first week. After that you need structure: modules to group code and hide implementation details, a clear public API, dependencies from crates.io, and eventually several crates in one repository. Rust's module system trips people up because it doesn't follow the filesystem automatically the way Go packages or JavaScript files do. This lesson explains how it works, how visibility is controlled, and how Cargo manages dependencies, features, profiles and workspaces.

## Packages, crates and modules

Three words, three levels:

- A **crate** is a unit of compilation: one library or one binary. The compiler builds one crate at a time.
- A **package** is what `Cargo.toml` describes: one or more crates. At most one library crate (rooted at `src/lib.rs`) and any number of binary crates (`src/main.rs`, plus each file in `src/bin/`).
- A **module** is a namespace inside a crate. Every crate is a tree of modules, rooted at `lib.rs` or `main.rs`.

A common layout puts all the logic in the library and keeps `main.rs` thin. Tests, benchmarks and other binaries can then use the library too:

```text
todo/
├── Cargo.toml
└── src/
    ├── main.rs      # binary crate: parses args, calls the library
    ├── lib.rs       # library crate root
    ├── store.rs     # module `store`
    └── model/
        ├── mod.rs   # (or model.rs next to the folder) module `model`
        └── task.rs  # module `model::task`
```

## Declaring modules

Modules are declared with `mod`, not discovered. A file only becomes part of the crate when its parent declares it:

```rust
// src/lib.rs
pub mod model;   // loads src/model.rs or src/model/mod.rs
pub mod store;   // loads src/store.rs
mod util {       // inline module, in this file
    pub fn slugify(s: &str) -> String {
        s.to_lowercase().replace(' ', "-")
    }
}
```

```rust
// src/model.rs (or src/model/mod.rs)
pub mod task;    // loads src/model/task.rs
```

If you create `src/helpers.rs` and never write `mod helpers;` anywhere, the compiler ignores the file entirely. That's the most common "why isn't my code found?" moment. For nested modules, the modern convention is `src/model.rs` plus a `src/model/` folder for its children; `mod.rs` still works and is common in older code.

## Paths and use

Items are referred to by path. `crate::` starts at the crate root, `super::` at the parent module, and `self::` at the current module:

```rust
// src/store.rs
use crate::model::task::Task;      // absolute path from the crate root
use std::collections::HashMap;     // from another crate (std)

pub struct Store {
    tasks: HashMap<u64, Task>,
    next_id: u64,
}

impl Store {
    pub fn new() -> Self {
        Self { tasks: HashMap::new(), next_id: 1 }
    }

    pub fn add(&mut self, title: &str) -> u64 {
        let id = self.next_id;
        self.next_id += 1;
        self.tasks.insert(id, Task::new(id, title));
        id
    }

    pub fn get(&self, id: u64) -> Option<&Task> {
        self.tasks.get(&id)
    }
}
```

`use` brings a path into scope so you can write the short name. Conventions: import types, traits and macros by their name (`use std::collections::HashMap;`), but import functions by their parent module and call them qualified (`use std::fs;` then `fs::read_to_string`), so it's clear where a function comes from. Group imports with braces, `use std::io::{self, Read, Write};`, and rename clashes with `as`: `use std::fmt::Result as FmtResult;`.

## Visibility

Everything is **private by default**: visible inside the module where it's defined and its descendants. `pub` opens it up:

| Modifier | Visible to |
| --- | --- |
| *(none)* | The current module and its children |
| `pub(super)` | The parent module |
| `pub(crate)` | The whole crate, but not other crates |
| `pub` | Anyone who can reach the enclosing module |

Struct fields have their own visibility, separate from the struct's:

```rust
// src/model/task.rs
#[derive(Debug, Clone)]
pub struct Task {
    pub id: u64,
    pub title: String,
    done: bool,            // private: only this module can change it directly
}

impl Task {
    pub(crate) fn new(id: u64, title: &str) -> Self {
        Self { id, title: title.to_string(), done: false }
    }

    pub fn is_done(&self) -> bool {
        self.done
    }

    pub fn complete(&mut self) {
        self.done = true;
    }
}
```

A struct with any private field can't be built with a struct literal outside its module, so `new` is the only way in. That's how you protect invariants: if a `Task` must never have an empty title, check it in the constructor, keep the field private, and no other code can create an invalid one. Enum variants, by contrast, are all public if the enum is.

`pub(crate)` is underused. Most items in an application don't need to be visible to other crates, and keeping them `pub(crate)` lets the compiler warn you about dead code that a plain `pub` hides.

**Re-exports** with `pub use` let you shape the public API independently of the file layout:

```rust
// src/lib.rs
mod model;
mod store;

pub use model::task::Task;   // users write todo::Task
pub use store::Store;        // instead of todo::store::Store
```

## Dependencies

Dependencies go in `Cargo.toml`. Add them with `cargo add`, which picks the latest version and writes the line for you:

```bash
cargo add serde --features derive
cargo add tokio --features full
cargo add --dev pretty_assertions   # only for tests and examples
```

```toml
[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

[dev-dependencies]
pretty_assertions = "1"
```

Version requirements are **semver** by default: `"1.4"` means `>=1.4.0, <2.0.0`. Cargo resolves the newest compatible versions and records the exact ones in `Cargo.lock`. Commit `Cargo.lock` for applications and libraries alike (the old advice to skip it for libraries changed in 2023), so CI builds what you tested. `cargo update` moves to newer compatible versions; `cargo tree` shows the whole graph and why each crate is there.

> [!WARNING]
> Every dependency is code you ship and trust, and `build.rs` scripts and procedural macros run on your machine at build time. Check a crate's downloads, maintenance and dependency count before adding it, and run `cargo audit` (from the `cargo-audit` crate) or `cargo deny` in CI to catch known vulnerabilities.

## Features

**Features** are named, optional parts of a crate, turned on by whoever depends on it. They're how `tokio` lets you include only the runtime parts you use, and how `serde` gates its derive macros.

```toml
[features]
default = ["json"]
json = ["dep:serde_json"]   # enabling `json` pulls in the optional dependency
metrics = []

[dependencies]
serde_json = { version = "1", optional = true }
```

```rust
#[cfg(feature = "json")]
pub fn to_json(task: &Task) -> String {
    serde_json::json!({ "id": task.id, "title": task.title }).to_string()
}
```

Features must be **additive**: enabling one may add functionality but must never remove or change any. Cargo unifies features across the dependency graph, so if two crates depend on yours with different features, both sets get turned on.

## Profiles and workspaces

**Profiles** control how code is compiled. `dev` (used by `cargo build`) favours fast compiles; `release` favours fast code. You can tune them:

```toml
[profile.release]
lto = "thin"        # link-time optimization across crates
codegen-units = 1   # slower build, better optimization
panic = "abort"     # smaller binary; no unwinding

[profile.dev.package."*"]
opt-level = 2       # optimize dependencies even in dev builds
```

The last one is a common quality-of-life tweak: your own code stays quick to rebuild, but heavy dependencies (image processing, crypto) run at reasonable speed in debug builds.

A **workspace** is several packages in one repository that share a `Cargo.lock` and a `target/` directory, so dependencies are compiled once:

```toml
# Cargo.toml at the repository root
[workspace]
resolver = "3"
members = ["crates/core", "crates/api", "crates/cli"]  # crates/core is package "todo-core"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
```

```toml
# crates/api/Cargo.toml
[package]
name = "api"
version = "0.1.0"
edition = "2024"

[dependencies]
todo-core = { path = "../core" }   # a path dependency on another member
serde = { workspace = true }
tokio = { workspace = true }
```

`workspace.dependencies` pins versions in one place, and members opt in with `workspace = true`. Split into crates when compile times grow (crates compile in parallel and are cached separately) or when you want a hard boundary: a `core` crate that can't depend on your web framework by construction. The idea is the same as [package by feature](https://levelcode.org/concepts/package-by-feature) and the dependency rule from clean architecture, enforced by Cargo.

> [!TIP]
> `cargo build -p api` and `cargo test -p core` work on one member. Running `cargo` commands at the workspace root applies them to every member.

## Practice

Turn a single-file program into a structured crate.

1. `cargo new --lib bookshelf`, then add `src/main.rs` so the package has both a library and a binary.
2. Create modules `book` (a `Book` struct with private `isbn: String`, public `title` and `author`) and `shelf` (a `Shelf` that stores books in a `Vec`).
3. Make `Book::new(isbn, title, author)` return `Result<Book, String>`, rejecting ISBNs that aren't 13 digits. Make `Shelf::add` and `Shelf::by_author(&self, author: &str) -> Vec<&Book>` public, and keep a helper `normalize(author: &str)` `pub(crate)`.
4. Re-export `Book` and `Shelf` at the crate root, and use them from `main.rs` as `bookshelf::Book`.
5. Add an optional feature `serde` that derives `Serialize` for `Book`.

**Solution**

```toml
# Cargo.toml
[package]
name = "bookshelf"
version = "0.1.0"
edition = "2024"

[features]
serde = ["dep:serde"]

[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
```

```rust
// src/lib.rs
mod book;
mod shelf;

pub use book::Book;
pub use shelf::Shelf;
```

```rust
// src/book.rs
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Book {
    isbn: String,
    pub title: String,
    pub author: String,
}

impl Book {
    pub fn new(isbn: &str, title: &str, author: &str) -> Result<Self, String> {
        let digits: String = isbn.chars().filter(|c| *c != '-').collect();
        if digits.len() != 13 || !digits.chars().all(|c| c.is_ascii_digit()) {
            return Err(format!("invalid ISBN-13: {isbn}"));
        }
        Ok(Self { isbn: digits, title: title.into(), author: author.into() })
    }

    pub fn isbn(&self) -> &str {
        &self.isbn
    }
}
```

```rust
// src/shelf.rs
use crate::book::Book;

pub(crate) fn normalize(author: &str) -> String {
    author.trim().to_lowercase()
}

#[derive(Default)]
pub struct Shelf {
    books: Vec<Book>,
}

impl Shelf {
    pub fn add(&mut self, book: Book) {
        self.books.push(book);
    }

    pub fn by_author(&self, author: &str) -> Vec<&Book> {
        let wanted = normalize(author);
        self.books.iter().filter(|b| normalize(&b.author) == wanted).collect()
    }
}
```

```rust
// src/main.rs
use bookshelf::{Book, Shelf};

fn main() -> Result<(), String> {
    let mut shelf = Shelf::default();
    shelf.add(Book::new("978-1-7185-0310-6", "The Rust Programming Language", "Steve Klabnik")?);
    for b in shelf.by_author("steve klabnik") {
        println!("{} ({})", b.title, b.isbn());
    }
    Ok(())
}
```

`main.rs` is a separate crate that depends on the library by its package name, so it can only see what `lib.rs` exports with `pub`. `cfg_attr(feature = "serde", …)` applies the derive only when the feature is on; build with `cargo build --features serde` to check it.

## Key takeaways

- A package contains crates (one library, any number of binaries); a crate is a tree of modules.
- Modules are declared with `mod`; a file that isn't declared isn't compiled.
- Everything is private by default. Use `pub(crate)` for crate-internal items, keep fields private to protect invariants, and shape the public API with `pub use`.
- Add dependencies with `cargo add`, commit `Cargo.lock`, and audit what you pull in.
- Features must be additive; workspaces share one lockfile and build cache across crates and enforce boundaries between them.

## Quiz

1. You create src/parser.rs, but code in it never compiles or runs. What's the likely cause?

   A. Rust ignores files that don't start with mod
   B. Files must live in a src/parser/ folder
   C. No module declares it with mod parser;
   D. The file must be listed in Cargo.toml

   **Answer: C.** Modules are declared, not discovered. lib.rs or main.rs (or a parent module) must contain mod parser; for the file to become part of the crate.

2. A struct has a public name field and a private id field. What can code in another module do?

   A. Read and write name, but not build the struct with a struct literal
   B. Build it with a literal if it sets both fields
   C. Read id through the dot operator
   D. Nothing; a private field makes the whole struct private

   **Answer: A.** Field visibility is per field. Outside the module you can use name, but a literal would have to set the private id, so you must go through a constructor the module provides.

3. Why must Cargo features be additive?

   A. Cargo can only enable one feature at a time
   B. Features are unified across the dependency graph, so a crate may get features another dependent asked for
   C. Removing code with features is a syntax error
   D. crates.io rejects crates with more than one feature

   **Answer: B.** If two crates depend on yours with different features, Cargo builds it once with the union. A feature that removed or changed behavior would break the other dependent.

4. What is the main benefit of a Cargo workspace?

   A. Each member gets its own Cargo.lock
   B. It publishes all crates to crates.io at once
   C. It lets crates use private items from each other
   D. Members share one lockfile and target directory, so dependencies resolve consistently and compile once

   **Answer: D.** A workspace shares Cargo.lock and target/, so versions stay consistent and builds are cached across members. Privacy across crates is unchanged: only pub items are visible.
