Rust Module Layout (Inside a Single Crate)
Authority: The Rust Book ch7, Rust Reference ch7, Rust API Guidelines — Organization (C-HIERARCHY, C-REEXPORT), Rust Style Guide.
This skill is the crate-internal counterpart to rust-workspace. That skill decides crate boundaries (workspace, packages, dependency direction). This skill decides what lives inside one crate's src/: the file tree, the mod declarations, the visibility, and the public re-export surface.
Capability Boundaries
✅ Strengths
- Translating a logical module hierarchy into a physical
src/directory tree - Writing
lib.rs/main.rsas a thin index, not a dumping ground - Choosing between
foo.rs/foo/mod.rs/foo/(Edition 2018+ vs legacy) - Naming modules semantically (
connectionrather thandb,runtimerather thanrt) - Applying
pub/pub(crate)/pub(super)/pub(in path)deliberately - Designing a flat public facade over a deep private tree via targeted
pub use - Recognizing and refactoring the "flat lib.rs +
pub use *::*" anti-pattern (common in crates ported from Java/Python) - Splitting a 500+ line file into a directory without breaking callers
- Migrating legacy
mod.rslayouts to the modernfoo.rs + foo/form
⚠️ Prerequisites
- Rust ownership and basic module syntax — see the
rust-stableskill - Crate vs package vs workspace — see the
rust-workspaceskill
❌ Out of Scope
- Cargo.toml / dependencies / features → use
rust-cargo-build - Splitting a crate into workspace members → use
rust-workspace - Visibility of
unsafeblocks → userust-unsafe-ffi
Data Privacy
This skill does not collect, store, or transmit any user data.
The Two Inviolable Rules
If you remember nothing else, remember these. Every Rust crate in existence obeys them:
Rule 1 — Directories are not modules until you declare them
// ❌ You created src/string/case.rs. The compiler does not see it.
// ✅ You must declare every level:
// src/lib.rs
pub mod string; // loads src/string.rs OR src/string/mod.rs
// src/string.rs (modern) OR src/string/mod.rs (legacy)
pub mod case; // loads src/string/case.rs
A file sitting in a directory is invisible to the compiler until a parent module declares mod <name>;. This is the #1 surprise for developers coming from Java/Python/Go, where the filesystem is the module system.
Rule 2 — Everything is private by default; only pub exposes
// src/string/case.rs
pub fn to_snake_case(s: &str) -> &str { ... } // ✅ callable from outside
fn helper(s: &str) -> &str { ... } // 🔒 crate-internal to this file
| Modifier | Visible to |
|---|---|
| (no modifier) | Current module and its descendants only |
pub(crate) |
Entire crate, not external users |
pub(super) |
Parent module only |
pub(in path) |
Ancestor module path and its descendants |
pub(self) |
Same as no modifier (rarely written explicitly) |
pub |
Everyone (subject to parent module visibility — privacy is parent-bound) |
Critical subtlety: pub on an item does not guarantee external visibility. The item is reachable only if every module on the path from the crate root to it is also pub. This is why mod string; (private) hides everything under string/ from external users, even if string::case::to_snake_case is pub.
Decision: foo.rs vs foo/mod.rs vs foo.rs + foo/
Rust 2018+ introduced a third layout that eliminates mod.rs. All three are legal; pick one and be consistent within a crate.
| Layout | File layout | When to use |
|---|---|---|
| Single file | src/foo.rs |
Module body fits in one file (rule of thumb: <300–500 lines) |
| Legacy directory | src/foo/mod.rs + src/foo/bar.rs |
Pre-2018 codebases; required when targeting Edition 2015 |
| Modern directory (2018+) | src/foo.rs + src/foo/bar.rs |
Default for new code — one fewer file per directory, no mod.rs boilerplate |
// Modern layout (default for new crates)
src/
├── lib.rs // pub mod foo;
├── foo.rs // pub mod bar; pub fn root_fn() {}
└── foo/
└── bar.rs // pub fn bar_fn() {}
// Legacy layout (only if Edition 2015)
src/
├── lib.rs // pub mod foo;
└── foo/
├── mod.rs // pub mod bar;
└── bar.rs
Mixing is allowed but discouraged. If one subtree uses foo/mod.rs and another uses bar.rs + bar/, readers have to check each directory individually. Pick one per crate (the validator script enforces consistency).
Canonical Layout Templates
Template A — Small crate (< ~1000 LOC, single domain)
src/
├── lib.rs // 5–30 lines: mod decls + pub use facade
├── error.rs // crate error type
├── parser.rs // one file per major concern
├── ast.rs
└── codegen.rs
// src/lib.rs
//! One-line crate docs.
mod error;
mod parser;
mod ast;
mod codegen;
pub use error::Error;
pub use parser::parse; // targeted re-export — only the most-used items
pub use ast::{Expr, Stmt};
Template B — Medium crate (~1k–10k LOC, multiple subdomains)
src/
├── lib.rs // index + facade
├── error.rs
├── parser/
│ ├── mod.rs // (or parser.rs in modern layout) — declares submodules
│ ├── lexer.rs
│ ├── grammar.rs
│ └── error.rs // parser-specific errors, re-exported upward
├── ast/
│ ├── mod.rs
│ ├── expr.rs
│ ├── stmt.rs
│ └── visit.rs
└── codegen/
├── mod.rs
├── llvm.rs
└── cranelift.rs
// src/lib.rs
mod error;
mod parser;
mod ast;
mod codegen;
pub use error::Error;
pub use parser::Parser; // types users construct
pub use ast::{Expr, Stmt, Module}; // data types users manipulate
// Note: codegen stays internal — users don't construct it directly
Template C — Large crate (10k+ LOC, deep nesting)
Same as Template B but:
- Each top-level directory may nest 3–4 levels deep (
parser/grammar/expr/primary.rs) lib.rsstays thin (~50 lines max) — onlymod+ selectedpub use- Subdirectory
mod.rsfiles act as sub-facades: they re-export their most useful items upward one level, but keep implementation details private - See
references/large-crate-layout.mdfor a worked 50-file example
The Public Facade Pattern (C-REEXPORT)
The Rust API Guidelines (C-REEXPORT) recommend: the crate root re-exports the most common types so users write use my_crate::Thing rather than use my_crate::deep::path::Thing.
// src/lib.rs — GOOD
mod connection;
mod pool;
mod query;
mod error;
// Targeted re-exports — only the items users actually need
pub use connection::Connection;
pub use pool::Pool;
pub use query::{Query, QueryBuilder};
pub use error::Error;
// The internal tree (connection/, pool/, query/) stays private.
// Users cannot reach my_crate::connection::tcp::TcpStream even if they try.
Targeted re-export vs glob re-export
| Form | When OK | When bad |
|---|---|---|
pub use foo::{Bar, Baz}; |
✅ Default — explicit, IDE-friendly, forces author to make a decision per item | — |
pub use foo::*; |
Rarely — only when foo is a leaf module of stably-named items that all belong at the root |
❌ Most cases — dumps dozens of unrelated symbols into the root namespace, hides provenance, breaks IDE autocomplete |
pub use foo::*; for every foo |
Never | ❌ This is the flat lib.rs anti-pattern (see next section) |
Glob re-export is seductive because it "just works" — type a name, it resolves. But it has real costs:
- Provenance is lost:
my_crate::Pool— where doesPoolactually live?pool::?connection::pool::?connection::pool::v2::? Readers and IDEs cannot tell. - Symbol conflicts silently: two
pub use *::*from different submodules clash; last-write-wins. - Refactoring is opaque: moving
Poolfrompool::topool::v2::doesn't show up in diffs because the re-export is glob. - IDE completion becomes noise: typing
my_crate::shows hundreds of symbols instead of a curated dozen.
Anti-Patterns (and how to refactor them)
These patterns appear frequently in crates authored by developers coming from Java/Python, where the language has different conventions. All examples are anonymized but drawn from real public crates.
| # | Anti-pattern | Symptom | Fix |
|---|---|---|---|
| 1 | Flat lib.rs + glob re-exports everywhere |
pub mod foo; pub use foo::*; repeated for every module |
Replace pub use foo::*; with targeted pub use foo::{A, B};; make implementation modules private (mod foo;) |
| 2 | Monster file at src/ root |
One file with 500+ lines mixing multiple concerns | Split into a directory, one file per concern (see examples/splitting-files.md) |
| 3 | Vague / abbreviated module names | db, io, rt, ext, util, common, core, types |
Rename to semantic names: connection, buffer, runtime (see references/naming.md) |
| 4 | Leaky privacy (pub everywhere) |
pub struct Pool { pub inner: Vec<_>, pub config: _, } |
Default to private; widen only when stable (see references/visibility-and-privacy.md) |
| 5 | Deep public tree | my_crate::connection::tcp::stream::TcpStream |
Keep the tree private; expose a flat facade (mod connection; pub use connection::TcpStream;) |
| 6 | mod.rs in Edition 2018+ crate |
Mixed foo/mod.rs + bar.rs + bar/ layouts in same crate |
Pick one per crate; prefer modern foo.rs + foo/ (see references/modernizing-mod-rs.md) |
| 7 | #[macro_use] mod foo; |
Legacy pre-2018 macro import leaks all macros crate-wide | Use #[macro_export] + explicit pub use (see examples/refactoring-anti-patterns.md Refactor 5) |
For full step-by-step refactors of each anti-pattern, see:
examples/refactoring-anti-patterns.md— seven worked walkthroughs with before/after codereferences/refactoring-flat-lib-rs.md— complete migration of an index + globlib.rsto a curated facade
Splitting a Growing File
When a file exceeds ~500 lines or starts mixing concerns, split it into a directory.
Mechanical procedure (zero behavior change for callers):
- Create
src/foo/directory - For each concern in the original
foo.rs, createsrc/foo/<concern>.rsand move the items - Replace
src/foo.rswithpub mod <concern>;declarations and targeted re-exports to preserve the old API - Run
cargo check— compilation should succeed with zero changes to callers
For a worked 600-line → directory example, see examples/splitting-files.md.
Directory vs workspace split
- Split into a directory (within the same crate): subdomains share types, are always used together, or are tightly coupled. One version, one publish.
- Split into a workspace (separate crates): subdomains are independently useful, have different stability trajectories, or have different dependency footprints.
See rust-workspace for the workspace-level decision.
Visibility Cheat Sheet
// Public — anyone with a path to this item can use it
pub fn f() {}
// Crate-visible — usable anywhere inside this crate, not by external users
pub(crate) fn g() {}
// Parent-visible — usable in the parent module and its descendants
pub(super) fn h() {}
// Restricted to an ancestor path and its descendants
pub(in crate::foo::bar) fn i() {}
// Module-private (default) — usable only inside this module
fn j() {}
// `pub` with a private parent is effectively `pub(crate)` from the outside.
// Privacy is **parent-bound**: external reachability requires every module
// on the path from the crate root to be `pub`.
mod internal {
pub struct Hidden; // pub, but `internal` is private → unreachable externally
}
Workflow
- Identify logical domains — list the concerns the crate addresses (parsing, AST, codegen, runtime). Each becomes a top-level module.
- Decide top-level visibility — for each module, ask: is this part of the public API (users construct these types) or implementation detail? Mark implementation modules
mod(private). - Design the facade — write
lib.rsas if it were the only file users read. List everypub usethey will need. If the list exceeds ~30 items, the crate is doing too much — consider a workspace split. - Lay out the directory tree — for each top-level module, decide: single file (Template A) or directory (Template B/C). Use modern
foo.rs + foo/layout for new code. - Name modules semantically — full words, no abbreviations. The module name should tell the reader what's inside without opening the file.
- Apply privacy minimally — start with everything private. Widen to
pub(crate), thenpub(super), thenpubonly when a caller actually needs it. - Validate — run
cargo check, thencargo doc --no-deps --open. The generatedindex.htmlfor your crate root is your public API. If it lists 100+ items, your facade is leaking.
Gotchas
- Privacy is parent-bound. A
pubitem inside a privatemodis not externally reachable. Many "why can't external users see my type?" bugs trace to this. foo.rsandfoo/mod.rscannot both exist for the samefoo— the compiler errors out. Choose one.#[path]breaks filesystem conventions and should be reserved for generated code or unusual layouts. Document why if you use it.pub use foo::*at the crate root looks convenient but makes refactor diffs unreadable and IDE completion noisy. Prefer targetedpub use foo::{A, B};.extern crate foo;is unnecessary in Edition 2018+ for most crates; justuse foo as bar;. Keepextern crateonly for crates that need#[macro_use](rare) or rename-on-import.pub use crate::foo::Bar;vspub use self::foo::Bar;vspub use foo::Bar;— all legal.crate::is the most readable for absolute paths inside the current crate;self::for relative;foo::Bar(without prefix) only works iffoois an external crate or inusescope.- Renaming a module is a breaking change if the module is
pub. Bump the major version, or provide a deprecation alias:pub mod old_name { pub use crate::new_name::*; }. mod.rsis not deprecated. It's required for Edition 2015 and still works in 2018+. The modernfoo.rs + foo/layout is preferred for new code butmod.rsis not wrong.
On-Demand Resources
- Layout templates with copy-paste skeletons
- Flat-lib.rs refactoring walkthrough
- Large crate layout (50-file worked example)
- Migrating
foo/mod.rs→foo.rs + foo/ - Java/Python → Rust module mindset
- Public facade design (C-REEXPORT)
- Naming conventions