Module System & Crate Organization
Where this fits: study this module after 15_testing. By now you have
written multi-file code and tests; this module explains how Rust actually
organizes that code into modules and crates, and how visibility works. It is
one of the highest-friction topics for C# developers because almost every
assumption you carry over from namespaces, internal, and one-type-per-file is
slightly wrong.
The mental model (start here)
The single most useful thing you can do is map Rust's vocabulary onto C#'s:
| C# concept | Rust concept | Notes |
|---|---|---|
namespace Foo.Bar | module path crate::foo::bar | logical grouping of items |
project / assembly (.csproj β .dll) | crate | the unit the compiler builds at once |
solution (.sln) | workspace ([workspace] in Cargo.toml) | groups crates that build together |
using Foo.Bar; | use crate::foo::bar; | brings names into scope |
public | pub | visible everywhere |
internal | pub(crate) | visible within this crate only |
private (member) | (no keyword β the default) | visible within the module |
protected | (no direct equivalent) | Rust has no inheritance |
In C#: a namespace is purely a naming bucket; you declare namespace Acme.Billing at the top of any file and the compiler stitches all files with that namespace together. The namespace tree is decoupled from the file layout β one file can hold several namespaces, and one namespace can span many files.
In Rust: modules are also decoupled from files (an inline mod can sit anywhere, and one file can hold many modules), but with a twist β when you write mod foo; (no body), the compiler goes and loads a specific file for you. So the module tree is built from mod declarations, not scanned from the filesystem. This is the detail that trips up C# developers the most: declaring a module is not the same as importing it.
Crates: lib.rs, main.rs, and the crate root
A crate is the compilation unit (think: one .dll or one .exe). A package (a directory with a Cargo.toml) can contain:
- one library crate β root file
src/lib.rs - one binary crate β root file
src/main.rs - extra binary crates β each
src/bin/*.rsfile is its own binary crate
The crate root is the file the compiler starts from (lib.rs or main.rs). That file is the root module β its top level is the module called crate. You never write mod crate { }; it is implicit.
src/
lib.rs // crate root of the library -> module `crate`
main.rs // crate root of a binary
bin/
task_01.rs // crate root of another binaryIn C#: lib.rs is like a class library project that produces a reusable assembly; main.rs is like a console app with a Main entry point. A package that has both is like a solution folder holding a library project plus an executable that references it. Each file in src/bin/ is like its own tiny console app sharing the same Cargo.toml.
This course crate uses src/bin/task_01.rs, task_02.rs, etc. β that is why
you run a task with cargo run -p course_37_module_system --bin task_01: each
task is a separate binary crate.
Declaring modules: three forms (NOT one-per-file)
A module is just a named container for items (functions, structs, other modules, β¦). There are three ways to create one. They produce the same module tree β only the source layout differs.
1. Inline module β body in braces
mod geometry {
pub fn area(w: u32, h: u32) -> u32 {
w * h
}
}
fn main() {
println!("{}", geometry::area(4, 3)); // 12
}You can nest inline modules and put several in one file. In C#: closest to writing multiple namespace { } blocks in a single file. (All the tasks and examples in this module use inline modules so each file is self-contained.)
2. File module β mod foo; loads foo.rs
// in src/lib.rs (or main.rs)
mod geometry; // <-- declaration: "there is a module geometry, find its body"The compiler then loads src/geometry.rs and treats its contents as the body of module geometry.
In C#: there is no analogue to the mod geometry; declaration step. In C# the compiler discovers every .cs file in the project automatically. In Rust a file is invisible until some module declares it with mod. A .rs file you never mod into the tree is dead weight the compiler ignores.
3. Directory module β submodules in their own files
When a module has children that each live in a file, use a directory. Two layouts:
src/
geometry.rs // module `geometry`; contains `mod shapes;`
geometry/
shapes.rs // module `geometry::shapes`The older (pre-2018) style puts the parent's own code in geometry/mod.rs instead of geometry.rs. Both still work; the geometry.rs + geometry/ style is preferred today.
Key correction: modules are not 1:1 with files. The file system is a
convenience the compiler uses after you write mod. The module tree is
whatever your mod declarations and inline mod blocks say it is.
Visibility: private by default β even to the parent
Every item in Rust is private by default. Private here means: visible only within the module that defines it and that module's descendants.
mod outer {
fn helper() {} // private to `outer` (and its children)
pub fn api() {} // visible wherever `outer` is visible
mod inner {
fn secret() {} // private to `inner`
pub fn shown() {
super::helper(); // OK: a child CAN see an ancestor's private items
}
}
fn uses_inner() {
// inner::secret(); // ERROR: parent CANNOT see a child's private item
inner::shown(); // OK: `shown` is pub
}
}Two rules capture all of it:
- A child module can use items in its ancestor modules β even private ones (the child is lexically inside the ancestor).
- A parent module cannot reach into a child module's private items. To expose them, the child must mark them
pub.
In C#: this is the biggest shift. In C#, top-level types default to internal (visible across the whole assembly), and class members default to private. In Rust there is one uniform rule and the default is the most restrictive one β private to the defining module. Nothing is visible across the crate just because it compiled. You opt in to visibility explicitly with pub and friends, and even a pub struct's fields stay private unless each field is also marked pub.
The visibility modifiers
| Modifier | Visible to⦠| C# analogue |
|---|---|---|
| (none) | the defining module and its descendants | private (member-level) |
pub | everywhere the path is reachable | public |
pub(crate) | anywhere in the current crate | internal |
pub(super) | the parent module only | (no exact match) |
pub(in path) | a specific ancestor module subtree | (no exact match) |
mod config {
pub(crate) const VERSION: u32 = 2; // internal: any module in this crate
pub mod limits {
pub(super) fn max() -> u32 { 100 } // only `config` may call this
}
pub fn show_max() -> u32 {
limits::max() // OK: `config` is the parent
}
}In C#: pub(crate) is your day-to-day internal β use it for things shared across the crate but not part of the public API. pub(super) has no clean C# equivalent; it is "expose to my parent module only," finer-grained than anything C# offers.
Paths: crate::, super::, self::, and use
To name an item you write a path through the module tree. Paths come in two flavors:
- Absolute β start at the crate root:
crate::config::VERSION. Like a fully-qualifiedAcme.Billing.Invoicein C#. - Relative β start at the current module, optionally via keywords:
self::thingβ an item in the current modulesuper::thingβ go up to the parent module (like../in a path)super::super::thingβ up two levels
const APP: &str = "demo";
mod app {
pub fn describe() -> String {
// absolute path from the crate root:
format!("{} v{}", crate::APP, crate::config::VERSION)
}
}
mod config {
pub(crate) const VERSION: u32 = 2;
}use β bringing paths into scope (vs C# using)
use creates a shortcut so you can write a short name instead of a full path:
use std::collections::{HashMap, HashSet}; // grouped, like several usings
use crate::config::VERSION; // now write VERSION directly
use crate::app::describe as describe_app; // aliasIn C#: use is the cousin of using, but note the differences:
- C#
using Acme.Billing;imports an entire namespace; Rustusetypically imports specific items (use foo::Bar;). You can glob withuse foo::*;, but it is discouraged outside of preludes and test modules. - C#
using Foo = Acme.Billing.Invoice;(alias) maps to Rustuse ... as .... - A
usingsits at the top of a file (or globally); a Rustuseis scoped to the block or module it appears in, and is itself subject to visibility (a privateuseonly shortens names inside that module). - Critically:
mod foo;is not an import β it declares/loads the module. You often need bothmod foo;(once, to add it to the tree) anduse foo::Bar;(to shorten the name). C# has no separate "declare the file exists" step.
pub use re-exports and the facade pattern
pub use imports a path and re-exports it under the new location, so callers can reach a deeply nested item via a short, stable path:
mod internals {
pub mod math {
pub fn add(a: i32, b: i32) -> i32 { a + b }
}
}
// Facade: expose a clean public surface, hide the internal layout.
pub use internals::math::add;
// Consumers can now write `your_crate::add(...)`
// even though the real code lives at `internals::math::add`.This lets you reorganize internals freely while keeping the public API stable β the classic facade pattern. Libraries commonly re-export their key types at the crate root from lib.rs.
In C#: there is no direct equivalent. The closest ideas are type-forwarding ([assembly: TypeForwardedTo]) or simply choosing a flat public namespace while keeping helpers in deeper internal namespaces. Rust's pub use is more ergonomic: one line republishes any item under any module you like.
What "edition" means
You will see edition = "2021" in every Cargo.toml. An edition (2015, 2018, 2021, 2024) is an opt-in marker that lets Rust evolve the language β introduce new keywords, change defaults, adjust idioms β without breaking existing code. Each crate picks its edition independently, and crates of different editions link together seamlessly, so the ecosystem is never forced to migrate in lockstep. Editions never split the standard library or fragment the language; they are a backward-compatibility mechanism, not a version of Rust itself (the compiler version, e.g. 1.95, is separate).
In C#: closest to <LangVersion> in a .csproj β it selects which language features/defaults apply to that project, independent of the runtime/SDK version, and different projects in a solution can choose different values.
Key takeaway
Map it to C# first: crate β assembly/project, module β namespace, workspace β solution, use β using, pub(crate) β internal, pub β public, default β private. Then internalize the three real differences: (1) everything is private by default, even to the parent module β a child may reach up into an ancestor's privates, but a parent can never reach down into a child's; (2) modules are not 1:1 with files β the tree is built from mod declarations (inline mod foo { }, file mod foo; β foo.rs, or directory foo/mod.rs), and mod foo; loads a file rather than importing it; (3) navigate with paths (crate:: absolute, super::/self:: relative), shorten them with use, and present a clean public API with pub use re-exports.