10 patterns, 514 lines. Full spec compliance. Patterns: one type per file, pub use re-exports, prelude module, feature gates, API/impl separation, test organization, type aliases, workspace multi-crate, conditional compilation, error modules.
13 KiB
Rust Module Organization Patterns
Patterns for organizing code into modules, extracted from the standard library source.
Source: rust-lang/rust at commit
f53b654
Stats: 220 mod.rs files, 41 lib.rs files, 1,404 module declarations, 919 pub(crate) items, 18,430 pub fn.
1. One Type Per File (Large Types)
Source:
library/alloc/src/string.rs (String), library/alloc/src/vec/mod.rs (Vec)
alloc/src/
├── string.rs ← String type + all impls (3000+ lines)
├── vec/
│ ├── mod.rs ← Vec type + core methods
│ ├── drain.rs ← Drain iterator
│ ├── into_iter.rs ← IntoIter
│ ├── splice.rs ← Splice iterator
│ └── ...
└── collections/
├── btree/
│ ├── map.rs
│ ├── set.rs
│ └── ...
└── ...
Why
Large types get their own file (or directory). This keeps files
navigable. When a type has many associated iterators or helpers,
it becomes a directory with mod.rs. The split is by related type,
not by "kind" (don't put all iterators in iterators.rs).
When to Use
Triggers:
- A type + impls exceeds ~500 lines
- A type has multiple associated types (iterators, builders)
- Multiple people work on the same module (reduce conflicts)
Example — before:
// src/collections.rs — 5000 lines, everything jammed together
pub struct HashMap { ... }
impl HashMap { /* 800 lines */ }
pub struct HashSet { ... }
impl HashSet { /* 600 lines */ }
pub struct BTreeMap { ... }
// Impossible to navigate
Example — after:
// src/collections/mod.rs
pub mod hash_map;
pub mod hash_set;
pub mod btree_map;
// src/collections/hash_map.rs
pub struct HashMap { ... }
impl HashMap { ... }
When NOT to Use
Don't use this when:
- The type is small (<100 lines with impls)
- Related types are tightly coupled and small (keep together)
2. Re-exports for Clean Public API (pub use)
Source:
// Internal organization:
mod io;
mod fs;
mod net;
// Public API — re-export the important bits:
pub use io::{Read, Write, BufRead, Seek};
pub use fs::{File, OpenOptions};
Why
Internal module structure serves implementation needs. Public API
structure serves user needs. They're often different. pub use
lets you organize internally however makes sense while presenting
a clean, flat API to users.
When to Use
Triggers:
- Internal modules are deeply nested but users want flat access
- You want
use my_crate::ImportantTypeinstead ofuse my_crate::internal::deep::nested::ImportantType - Reorganizing internals without breaking the public API
Example — before:
// Users must know internal structure:
use my_crate::network::http::client::HttpClient;
use my_crate::network::http::request::Request;
use my_crate::network::http::response::Response;
Example — after:
// lib.rs re-exports at the crate root:
pub use network::http::client::HttpClient;
pub use network::http::request::Request;
pub use network::http::response::Response;
// Users just write:
use my_crate::{HttpClient, Request, Response};
When NOT to Use
Don't use this when:
- The module hierarchy IS the API (users should know about it)
- Re-exports would cause name collisions
- You're re-exporting too much (defeats the purpose of modules)
3. Prelude Module for Common Imports
Source:
library/std/src/prelude/mod.rs
pub mod prelude {
pub mod v1 {
pub use crate::option::Option::{self, Some, None};
pub use crate::result::Result::{self, Ok, Err};
pub use crate::iter::Iterator;
pub use crate::clone::Clone;
// ... most commonly used traits and types
}
}
Why
A prelude gathers the most commonly used items so users can do
use my_crate::prelude::* instead of 20 individual imports. The
stdlib prelude is auto-imported; library preludes are opt-in.
When to Use
Triggers:
- Your library has 5+ types/traits that users import together
- Most users need the same set of imports
- Reducing boilerplate matters for ergonomics
Example — before:
// Every file in the project:
use my_framework::{App, Context, Request, Response, Handler};
use my_framework::middleware::{Logger, Cors, Auth};
use my_framework::error::Error;
// 3 lines of imports every single time
Example — after:
// my_framework/src/prelude.rs
pub use crate::{App, Context, Request, Response, Handler};
pub use crate::middleware::{Logger, Cors, Auth};
pub use crate::error::Error;
// Every file in the project:
use my_framework::prelude::*;
When NOT to Use
Don't use this when:
- The library is small (few imports anyway)
- Items would conflict with common names (String, Error)
- You want explicit imports for clarity
4. Feature Gates (#[cfg(feature = "...")])
Source:
The stdlib uses extensive feature gating for unstable features. Libraries use it for optional dependencies.
#[cfg(feature = "serde")]
impl Serialize for MyType { ... }
#[cfg(feature = "async")]
pub mod async_client { ... }
Why
Feature gates let you provide optional functionality without imposing dependencies on all users. Users opt in via Cargo.toml. This keeps the default compilation fast and the dependency tree small.
When to Use
Triggers:
- Optional dependencies (serde, tokio, etc.)
- Optional functionality that not all users need
- Platform-specific code
- Unstable/experimental APIs
Example — before:
// Everyone pays the compile cost of serde even if they don't use it
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct Config { ... }
Example — after:
// Cargo.toml: [features] serde = ["dep:serde"]
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Config { ... }
// Only compiled with serde when the feature is enabled
When NOT to Use
Don't use this when:
- The functionality is core (everyone needs it)
- Feature interaction is complex (too many combinations to test)
- The dependency is tiny (not worth the complexity)
5. Separating Public API from Implementation
Source:
std/src/io/
├── mod.rs ← public API, re-exports, module docs
├── buffered/ ← implementation details
│ ├── bufreader.rs
│ ├── bufwriter.rs
│ └── linewriter.rs
├── cursor.rs ← Cursor implementation
├── error.rs ← Error types
└── impls.rs ← trait implementations
Why
The mod.rs (or parent module) defines WHAT users see. Sub-modules
contain HOW things work. This separation means you can refactor
internals without changing the public API.
When to Use
Triggers:
- Module has both public API and complex implementation
- You want to reorganize implementation without breaking users
- The implementation has multiple sub-components
When NOT to Use
Don't use this when:
- The module is simple (one file is fine)
- Implementation IS the API (no separation needed)
6. tests/ Subdirectory for Test Organization
Source:
library/
├── core/
│ └── src/
│ └── option.rs ← implementation
├── coretests/
│ └── tests/
│ └── option.rs ← tests for core::option
└── alloctests/
└── tests/
└── vec.rs ← tests for alloc::vec
Why
The stdlib separates tests into their own crates (coretests,
alloctests) because core can't depend on alloc for testing.
For normal projects, tests/ directory provides the same separation
for integration tests.
When to Use
Triggers:
- Integration tests (testing the public API)
- Tests that need external resources
- Tests that should be separate binaries (parallelism)
7. Type Aliases for Complex Generics
Source:
// Instead of writing Result<T, io::Error> everywhere:
pub type Result<T> = std::result::Result<T, Error>;
// Instead of writing Box<dyn Fn(Request) -> Response + Send + Sync>:
pub type Handler = Box<dyn Fn(Request) -> Response + Send + Sync>;
Why
Type aliases reduce visual noise for complex generic types. They make function signatures readable and provide a single point of change if the underlying type evolves.
When to Use
Triggers:
- A generic type appears in 3+ signatures
- The full type is > 40 characters
- You want a domain-specific name for a generic composition
When NOT to Use
Don't use this when:
- The alias hides important information (what IS that thing?)
- The alias is used in only one place
- The original type is already short and clear
8. Workspace Organization (Multi-Crate Projects)
Source:
The Rust stdlib itself is organized as multiple crates:
library/
├── core/ ← no allocator, no OS (can run on bare metal)
├── alloc/ ← needs an allocator (Vec, String, Box)
├── std/ ← needs an OS (files, threads, networking)
└── proc_macro/ ← procedural macro support
Why
Multiple crates within one workspace provide:
- Separate compilation (parallel builds)
- Clear dependency boundaries (core doesn't depend on alloc)
- Different feature sets per crate
- Independent versioning
When to Use
Triggers:
- Different parts of your project have different dependencies
- You want parallel compilation (workspace members build in parallel)
- Some code is reusable independently (separate crate = separate publish)
- Clear architectural boundaries
When NOT to Use
Don't use this when:
- The project is small (<2000 lines)
- Everything depends on everything else (no natural boundaries)
- The complexity of multiple crates outweighs the benefits
9. Conditional Compilation (#[cfg])
Source:
Extensive use of #[cfg(target_os)], #[cfg(feature)],
#[cfg(test)] throughout the stdlib.
#[cfg(unix)]
mod unix_impl;
#[cfg(windows)]
mod windows_impl;
#[cfg(target_pointer_width = "64")]
type Repr = repr_bitpacked::Repr;
#[cfg(not(target_pointer_width = "64"))]
type Repr = repr_unpacked::Repr;
Why
#[cfg] compiles code conditionally. Dead code is eliminated at
compile time — no runtime cost. This is how one codebase supports
multiple platforms, feature sets, and configurations.
When to Use
Triggers:
- Platform-specific implementations (OS, architecture)
- Feature-gated code
- Test-only code (
#[cfg(test)]) - Debug-only code (
#[cfg(debug_assertions)])
When NOT to Use
Don't use this when:
- Runtime feature detection is needed (cfg is compile-time only)
- The conditional logic is simple enough for
if cfg!(...)macro - You're using cfg to hide broken code (fix it instead)
10. Error Modules
Source:
std/src/io/
├── error.rs ← Error type definition
├── error/
│ ├── repr_bitpacked.rs ← compact representation
│ └── repr_unpacked.rs ← standard representation
└── mod.rs ← re-exports Error
Why
Error types often need their own module when they have:
- Multiple representations
- Many From implementations
- Complex display logic
- Associated helper types
When to Use
Triggers:
- Error type exceeds 100 lines
- Error has multiple variants with different data
- Error needs platform-specific representations
Summary: Module Organization Decision Tree
How big is the module?
├── <200 lines → single file
├── 200-1000 lines → file with sections
└── >1000 lines → directory (mod.rs + sub-modules)
What should users see?
├── Everything → pub mod (expose structure)
├── Flat API → pub use (re-export from depth)
└── Common subset → prelude module
Platform-specific code?
├── cfg(unix) / cfg(windows) → parallel mod files
└── cfg(feature) → optional modules
Test organization?
├── Unit tests → #[cfg(test)] mod tests (same file)
├── Integration → tests/ directory
└── Shared helpers → tests/common/mod.rs
| Pattern | Use when |
|---|---|
| One type per file | Large types (>500 lines with impls) |
pub use re-exports |
Clean API surface hiding internal structure |
| Prelude module | 5+ commonly imported items |
| Feature gates | Optional functionality/dependencies |
| API/impl separation | Public surface ≠ implementation structure |
tests/ directory |
Integration testing |
| Type aliases | Complex generics appearing multiple times |
| Workspace (multi-crate) | Clear architectural boundaries |
#[cfg] |
Platform/feature conditional compilation |
| Error modules | Complex error types |
See also:
- api-design.md — Public API design patterns
- documentation.md — Module-level documentation