Files
rust-patterns/patterns/module-organization.md
Rodin e1c2caf8d5 docs: module organization patterns from rust-lang/rust
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.
2026-04-30 15:11:43 -07:00

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:

library/std/src/lib.rs

// 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::ImportantType instead of use 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: