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
| Unit | What it is | Where you see it |
|---|---|---|
| Item | A function, struct, enum, trait, const, type alias | Everywhere |
| Module | A namespace, declared with mod name; or mod name { ... } | Inside files |
| Crate | A compilation unit: one library or one binary | One lib.rs or one main.rs |
| Package | A bundle of one or more crates, defined by Cargo.toml | The thing you cargo new |
| Workspace | A 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.rsis the default binary;src/bin/*.rsare 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:
pub mod catalog;
pub mod error;
mod internal;This declares three modules:
pub mod catalog;— public, contents insrc/catalog.rsorsrc/catalog/mod.rs.pub mod error;— public, contents insrc/error.rs.mod internal;— private (default visibility), contents insrc/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.rsBoth 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 modulecrate:: 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.
// 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.// 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?
| Signal | Verdict |
|---|---|
| It compiles independently and changes rarely | Strong yes |
| It has tests that should run in isolation | Yes |
| Multiple binaries or services share it | Yes |
| It needs different feature flags from the rest | Yes |
| It is just "files I want to organize" | No, use modules |
| It has a single consumer in the same workspace | No, 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.