Modules and Project Organization

mod and use are simple enough on a small project, one or two files, everything pub, done. The friction shows up once a crate grows: which visibility level actually fits "shared internally, hidden from callers," how the module tree should map onto files on disk, and when a project has outgrown a single crate entirely. This tutorial covers that growth path, not the basic syntax.


The Module Tree vs. File Layout

mod foo; declares a module and tells Rust to look for its contents in a file. Since the 2018 edition, the idiomatic layout puts a module's own code in foo.rs and its submodules in a foo/ directory next to it, rather than the older foo/mod.rs style:

src/
├── lib.rs
├── auth.rs          // mod auth;  (declared in lib.rs)
└── auth/
    ├── session.rs    // mod session;  (declared in auth.rs)
    └── token.rs      // mod token;    (declared in auth.rs)
// lib.rs
mod auth;

// auth.rs
mod session;
mod token;

pub use session::Session;

The module tree (how code refers to itself: auth::session::Session) and the file tree (where that code physically lives) are two different things that happen to mirror each other by convention, not by requirement. mod declarations are what actually define the tree; file paths just need to match where Rust expects to find each declared module.


Visibility: Picking the Right Level

Everything in Rust is private by default, visible only within the module that defines it and that module's descendants. The visibility modifiers exist to deliberately widen that:

  • pub: visible to anyone who depends on this crate, full external API surface.
  • pub(crate): visible anywhere inside this crate, but not to external dependents. This is the level that does the most real work in a growing codebase, "needs to be used by other modules in this crate, but isn't part of the public contract."
  • pub(super): visible to the parent module only, useful for a helper that exactly one calling module should reach.
  • pub(in some::path): visible only within a specific module path, the most fine-grained option, reach for it when even pub(crate) is wider than you want.
pub struct Account {
    pub id: u64,
    pub(crate) internal_flags: u32,  // other modules in this crate can read it
    balance: i64,                     // private: only `account`'s own code touches this
}

A common mistake is defaulting straight to pub on anything that needs to cross a module boundary, even internally. That makes the field or function part of your crate's public API contract the moment an external user notices it's accessible, even if you never intended it to be. pub(crate) gets you cross-module access within your own crate without that commitment.


Re-exporting: Decoupling Internal Structure from the Public API

pub use re-exports an item under a new path, which lets your internal module structure differ from what callers actually see. This is how a crate can be organized into many small internal modules for the maintainers' sake, while presenting a flat, simple surface to everyone else.

// lib.rs
mod auth;
mod billing;

pub use auth::Session;
pub use billing::Invoice;

Callers write my_crate::Session, not my_crate::auth::Session, they never need to know Session lives in an auth submodule at all. This means you're free to reorganize the internal module layout later, split auth into smaller pieces, rename it, whatever the codebase needs, without it being a breaking change for anyone depending on the crate, as long as the re-exported paths stay the same.


"Private Type in Public Interface"

This compiler error shows up the moment a pub function's signature exposes a type that isn't itself pub:

mod internal {
    pub(crate) struct Config { /* ... */ }
}

pub fn load() -> internal::Config {  // ERROR: `Config` is private
    todo!()
}

The function is public, but its return type isn't accessible outside the crate, so an external caller couldn't actually name the type even if they wanted to call load() and bind the result. The fix is either making the type itself pub, or re-exporting it alongside the function so both are reachable at a consistent visibility level. This error is the compiler catching a visibility inconsistency that would otherwise only surface as confusion for whoever tries to use the crate.


Workspaces: Splitting a Project into Multiple Crates

A [workspace] in Cargo.toml groups several crates that share one Cargo.lock and one target/ build directory, while each crate still compiles and versions independently.

# Cargo.toml at the workspace root
[workspace]
members = ["core", "cli", "server"]
my-project/
├── Cargo.toml      # [workspace] members = [...]
├── core/           # shared library logic
├── cli/            # binary crate depending on `core`
└── server/         # binary crate also depending on `core`

The main reason to split into a workspace rather than keep everything as modules in one crate: incremental compile times. A single crate recompiles in full whenever any file in it changes. Splitting slow-to-compile or rarely-changing code (like core above) into its own crate means touching cli only triggers a cli rebuild, not a full rebuild of core and everything depending on it. It's also the right tool when you genuinely have multiple binaries (cli and server here) sharing one library, rather than one binary crate awkwardly containing logic for several different entry points.

For a small, single-binary project, a workspace adds overhead with no benefit, reach for one once you have multiple crates that should be versioned and compiled together, not as a default project structure.


Key Takeaways

  • The module tree (mod declarations) and the file layout on disk mirror each other by convention; since the 2018 edition, prefer foo.rs + a foo/ directory over the older foo/mod.rs style.
  • pub(crate) is the visibility level that does the most work in a growing codebase: accessible across your own modules, without committing it to the crate's external public API.
  • pub use re-exports let your internal module structure differ from what callers see, so you can reorganize internals later without it being a breaking change.
  • "Private type in public interface" means a pub function exposes a type callers can't actually name, fix it by making the type pub or re-exporting it alongside the function.
  • A Cargo workspace groups multiple crates under one lockfile and build directory. Reach for one mainly to cut incremental compile times (changing one crate doesn't force a full rebuild of unrelated crates) or to share a core library across multiple binaries.