Modules
Rust splits code by module and crate, with visibility controlled per-item. The rules are small and the conventions are tight, so codebases look more similar to each other than in most languages.
Modules: one file = one module
The simplest case:
pub mod catalog;
pub mod error;
mod internal;This file declares three modules:
pub mod catalog;— a public module, contents insrc/catalog.rs(orsrc/catalog/mod.rs). External callers can usecrate::catalog::....pub mod error;— public, contents insrc/error.rs.mod internal;— private (the default), contents insrc/internal.rs. Only this crate can use it.
Multi-file modules
If a module grows beyond one file, you create a subdirectory:
src/
└── catalog/
├── mod.rs # The module entry point.
├── memory.rs # Submodule: catalog::memory
└── sqlite.rs # Submodule: catalog::sqlitemod.rs re-exports or declares submodules. Modern Rust also supports the "no mod.rs" style:
src/
├── catalog.rs # The module entry (replaces mod.rs).
└── catalog/
├── memory.rs # Submodule
└── sqlite.rsBoth work. Pick one per project and stick with it.
Visibility: private by default
pub struct Config { ... } // public
struct Internal { ... } // private to this module
pub(crate) struct CrateLocal { ... } // visible anywhere in this crate
pub(super) struct ParentOnly { ... } // visible in the parent modulepub(crate) is the workhorse for "shared within this crate, hidden from outside." Use it liberally. It is the equivalent of "package-private" in Java.
Re-exports: control your public API surface
mod internal;
mod helpers;
pub use internal::PublicType;
pub use helpers::do_thing;This is the canonical Sail pattern. Internal modules are private, then pub use re-exports just the things callers should see. The result: callers say mycrate::PublicType, not mycrate::internal::PublicType.
Imports: use
use std::collections::HashMap;
use std::sync::Arc;
use crate::error::CatalogError;
use crate::error::CatalogResult;
// Grouped
use std::collections::{HashMap, HashSet, BTreeMap};
// Wildcard (use sparingly; only for preludes)
use crate::prelude::*;
// Renamed
use std::io::Result as IoResult;use paths can be relative (crate::, super::, self::) or absolute (std::, tokio::, serde::).
Crates: Rust's unit of compilation
A crate is everything that compiles into one library or binary. Each Cargo.toml with [package] defines one crate. Workspaces group many crates.
[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = "1"
tokio = { version = "1", features = ["full"] }The crate's name becomes how others depend on it: my-crate = "0.1" in their Cargo.toml, then use my_crate::Thing; in their code (Rust converts hyphens to underscores).
Conventions you should expect
| Convention | Used by | Why |
|---|---|---|
lib.rs is tiny: mod and pub use only | All idiomatic crates | Public surface should be a manifest, not a body of logic |
error.rs defines one error enum per crate | Sail and most production code | Errors are crate-local contracts |
mod tests { ... } at the bottom of each file | Standard | Tests next to code, behind #[cfg(test)] |
prelude.rs for re-exports the consumer commonly wants | Diesel, Bevy, some others | Optional convenience |
pub(crate) fn helper(...) for shared internals | Sail and most production code | Reduces noise in the public API |
Workspace dependencies
In a workspace, you can centralize dep versions:
[workspace]
members = ["crates/*"]
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }Then each member crate writes:
[dependencies]
serde = { workspace = true }
tokio = { workspace = true }This is how Sail keeps version drift under control across 36 crates. Worth adopting in any multi-crate project.
Trait re-exports
A common subtlety: to use a trait's methods, the trait must be in scope. So crates often re-export traits in a prelude:
pub use std::io::{Read, Write};Now callers can use mycrate::prelude::*; and reach for .read_to_string(...) without separately importing std::io::Read.