The Book·Chapter 7·12 min

Packages, crates, modules

One language, five units of organization. Once they click, every Rust repo looks the same.

Rust organizes code at five scales: workspaces, packages, crates, modules, items. Each one has a clear job. Learn them once and every Rust repo, including Sail's 36-crate workspace, reads the same way.

The five units

UnitWhat it isWhere you see it
ItemA function, struct, enum, trait, const, type aliasEverywhere
ModuleA namespace, declared with mod name; or mod name { ... }Inside files
CrateA compilation unit: one library or one binaryOne lib.rs or one main.rs
PackageA bundle of one or more crates, defined by Cargo.tomlThe thing you cargo new
WorkspaceA bundle of packages with a shared Cargo.lock and target/Large repos like Sail

You compile a crate. You publish a package. You develop in a workspace.

Packages vs crates

A package is what Cargo.toml describes. A crate is what rustc compiles. A package can contain:

  • At most one library crate (src/lib.rs).
  • Zero or more binary crates (src/main.rs is the default binary; src/bin/*.rs are additional ones).

So cargo new foo makes a package called foo with one binary crate foo. cargo new foo --lib makes a package called foo with one library crate foo. Most non-trivial projects have a library crate (where the logic lives) and one or more binary crates (thin entry points that call into the library).

Workspaces

A workspace is a top-level Cargo.toml that lists member packages:

# Top-level Cargo.toml
[workspace]
resolver = "2"
members = [
    "crates/sail-common",
    "crates/sail-execution",
    "crates/sail-server",
    # ...
]
 
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
thiserror = "2"

Each member package has its own Cargo.toml. They share a single Cargo.lock (so all crates resolve to the same versions of shared dependencies) and a single target/ build directory (so builds are cached across crates).

Inside a member, you reference another with workspace = true:

# crates/sail-execution/Cargo.toml
[dependencies]
sail-common = { path = "../sail-common" }
tokio = { workspace = true }

Workspaces are how a 36-crate project like Sail stays sane. Each crate has its own focus, dependencies, and tests, but the build system treats them as one project.

Modules: one file is one module

The simplest case:

src/lib.rs
pub mod catalog;
pub mod error;
mod internal;

This declares three modules:

  • pub mod catalog; — public, contents in src/catalog.rs or src/catalog/mod.rs.
  • pub mod error; — public, contents in src/error.rs.
  • mod internal; — private (default visibility), contents in src/internal.rs.

The mod statement tells the compiler "this module exists here." Without it, the file is not part of the crate, even if it sits in the right directory. Forgetting mod foo; is a common cause of "I wrote the file, why does it not exist?"

Multi-file modules: two styles

If a module grows beyond one file, you create a subdirectory. Two layouts, both valid:

# Old style: explicit mod.rs
src/
└── catalog/
    ├── mod.rs        # The module entry point.
    ├── memory.rs     # Submodule: catalog::memory
    └── sqlite.rs     # Submodule: catalog::sqlite
# New style: no mod.rs
src/
├── catalog.rs        # The module entry (replaces mod.rs).
└── catalog/
    ├── memory.rs     # Submodule
    └── sqlite.rs

Both work in any modern Rust project. Pick one per repo and stick with it. The no-mod.rs style is more common in newer code because it avoids having a dozen identically named mod.rs tabs open in your editor.

Inside catalog.rs (or catalog/mod.rs):

pub mod memory;
pub mod sqlite;
 
pub use memory::MemoryCatalog;
pub use sqlite::SqliteCatalog;

The pub use lines re-export, so users can write crate::catalog::MemoryCatalog instead of crate::catalog::memory::MemoryCatalog. Re-exports flatten the namespace at the boundaries you care about.

Paths and use

Inside the crate, items are addressed by path:

use crate::catalog::MemoryCatalog;            // absolute
use super::error::CatalogError;                // one level up
use self::helpers::normalize;                  // current module

crate:: starts at the crate root. super:: goes up one module. self:: is the current module (rarely needed).

For external crates, the path starts at the crate name:

use tokio::sync::mpsc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use does not move anything around; it just creates a local alias. The actual item lives wherever it was defined.

Visibility

Items are private by default. Three relevant qualifiers:

mod inner {
    pub fn anyone() {}            // visible anywhere this module is visible
    pub(crate) fn within() {}      // visible anywhere in this crate
    pub(super) fn parent() {}      // visible only to the immediate parent module
    fn nobody() {}                 // private to this module
}

pub(crate) is the most important and the least understood. It says: "expose this within my crate, but never to my users." Use it generously for internal helpers that multiple modules in your crate share but that should not be part of the public API.

pub(super) is for tight coupling between a child and its parent: a helper that exists for the parent module only.

△ Bad
// In crates/my-lib/src/internal.rs
pub struct Buffer { /* ... */ }
pub fn allocate(...) -> Buffer { /* ... */ }
pub fn flush(b: &mut Buffer) { /* ... */ }

// Now every Buffer detail is part of the public API.
// Renaming a field breaks downstream callers.
◇ Good
// In crates/my-lib/src/internal.rs
pub(crate) struct Buffer { /* ... */ }
pub(crate) fn allocate(...) -> Buffer { /* ... */ }
pub(crate) fn flush(b: &mut Buffer) { /* ... */ }

// In crates/my-lib/src/lib.rs
mod internal;
// Nothing from internal is exposed to users.

Why lib.rs files in well-organized crates are tiny

A common pattern in mature Rust codebases: lib.rs is a few lines that declare submodules and re-export the public surface. The actual logic lives in those submodules.

Splitting work into crates

When does a piece of code deserve its own crate inside a workspace?

SignalVerdict
It compiles independently and changes rarelyStrong yes
It has tests that should run in isolationYes
Multiple binaries or services share itYes
It needs different feature flags from the restYes
It is just "files I want to organize"No, use modules
It has a single consumer in the same workspaceNo, use modules

Sail's 36 crates each meet at least one of the "yes" criteria. sail-common is shared infrastructure. sail-execution is one runtime component. sail-catalog-memory and sail-catalog-sqlite are alternative backends behind the same trait. Each one compiles independently and tests independently.

A workspace with three crates that all depend on each other for trivial reasons is worse than one crate with three modules. Crate boundaries are not free: they slow incremental compilation and force you to think about visibility at every edge.

Where agents go wrong