Encapsulation with Modules and Visibility
Learn how Rust's module system and visibility controls enable encapsulation, hiding implementation details and enforcing invariants through private fields and public APIs.
Encapsulation is the practice of bundling data with the code that operates on it while keeping the internal state hidden from outside code. In object-oriented design, this means consumers of a type should not reach into its internals and change data directly. They should only interact through a public interface—methods that the type intentionally exposes.
Rust supports encapsulation without classes. Instead, it uses a combination of modules, visibility modifiers, and the fact that all items are private by default. This system gives you precise control over what parts of a type or module are accessible, while leaving the rest safely sealed away.
Modules as the Foundation of Privacy
A module in Rust is a namespace boundary defined with the mod keyword. Everything inside a module—functions, types, constants, and even other modules—is private to that module by default. Code outside the module cannot see or use private items.
Consider a library that handles financial transactions. A payments module might contain internal helpers that should never be called directly by external code.
mod payments {
fn validate_transaction(amount: f64) -> bool {
amount > 0.0
}
pub fn process_payment(amount: f64) {
if validate_transaction(amount) {
println!("Processing payment of ${:.2}", amount);
}
}
}
fn main() {
// payments::process_payment(30.0); // Would work if we were in the right scope,
// but outside the library, only `process_payment` would be public.
}
Only the function marked pub is accessible outside payments. The validate_transaction helper remains hidden. That is encapsulation: the external world sees process_payment but has no idea how validation is implemented. If you later swap validate_transaction for a call to a remote fraud-detection service, no calling code needs to change.
Module boundaries define privacy:
Privacy in Rust is about modules, not about structs or classes as in other languages. When you mark something pub, you are saying "code outside this module can use it." The module is the gate.
Visibility Control with pub
The pub keyword makes an item visible outside its defining module. Without it, the item remains private. This simple rule underpins Rust's entire encapsulation model.
You can also use more granular visibility specifiers:
pub(crate): visible anywhere within the current crate.pub(super): visible in the parent module.pub(in path::to::module): visible within a specific module path.
Most libraries stick with private-by-default items and a few carefully chosen pub items that form the public API.
Struct Field Privacy Is Independent
A common point of confusion is that marking a struct pub does not automatically make its fields public. Each field defaults to private, and you must explicitly mark the ones you want to expose.
This design means you can have a struct that is usable by name outside its module, but whose internal data cannot be poked at directly. Outside code is forced to go through methods.
Compile-time enforcement:
Attempting to access a private field from outside the module results in a compiler error: field is private. This is not a runtime check or a convention—it is a hard guarantee.
Encapsulation in Practice: AveragedCollection
When a data structure needs to maintain a relationship between multiple fields, encapsulation becomes essential. The classic Rust example is a collection that tracks a list of integers and caches their average. The invariant is: whenever the list changes, the cached average must be recalculated. Allowing external code to modify the list directly would break that invariant.
The following code defines an AveragedCollection struct. The struct itself is public, but its two fields are private. The public methods add, remove, and average are the only way to interact with the data.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn new() -> AveragedCollection {
AveragedCollection {
list: vec![],
average: 0.0,
}
}
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
The add method pushes a value onto the vector and immediately calls update_average. The remove method pops a value and, if successful, also calls update_average. The average method returns the cached value without recomputing it. The private update_average method is the only piece of code that modifies the average field, and it runs only through the two mutation methods.
If a user tries to access list or average directly, the compiler stops them:
let mut ac = AveragedCollection::new();
ac.add(5);
// ac.list.push(10); // error: field `list` of struct `AveragedCollection` is private
This rigidity is the point. No matter what external code does, the average field always reflects the actual average of the list. You cannot forget to update it because there is no mechanism to bypass the update logic.
Invariant safety:
The private field + public method combination ensures the invariant "average is always correct" holds for the entire lifetime of every AveragedCollection instance. If your code compiles, the invariant is enforced.
Refactoring Behind the Scenes
Because the fields are hidden, the internal data structures can change without affecting any code that uses AveragedCollection. Suppose you decide to store the list as a HashSet<i32> instead of a Vec<i32> to prevent duplicate values. As long as the public method signatures stay the same, users of the struct do not need to change a single line.
use std::collections::HashSet;
pub struct AveragedCollection {
list: HashSet<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.insert(value);
self.update_average();
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
// Other methods unchanged
}
The public API add, remove, and average remain exactly as before. External code calling ac.add(5) will compile and run correctly. This is the power of encapsulation: internal implementation details are free to evolve.
Don't expose internals prematurely:
A common mistake early in a project is to make fields pub just to get something working, with the intention of locking them down later. That "later" rarely arrives, and once external code depends on direct field access, removing that access becomes a breaking change. Start private and only expose what you must.
Why Encapsulation Matters Beyond Invariants
Rust’s encapsulation is not just about preventing accidental invariant breakage. It also:
- Serves as documentation. A public API shows the intended usage. Everything private is an implementation detail that a consumer of the code can ignore.
- Enables fearless concurrency. When internal state is only reachable through controlled methods, reasoning about thread safety becomes easier because you know exactly where mutations happen.
- Reduces cognitive load. Callers don't need to know how something works; they only need to know what it does.
These benefits apply even in small codebases. A module that exposes one public function and keeps the rest private signals clearly: "This is the entry point; the rest is none of your concern."
Common Misconceptions
"pub struct makes all its fields public."
It does not. A public struct with private fields is a standard pattern in Rust and a deliberate design choice. Only the struct name is usable outside the module; the fields remain hidden.
"I need getters and setters for every field."
Not necessarily. If a field is readable by design and that read does not break any invariant, you can simply return a reference or copy. You don't need boilerplate accessor methods. In some cases, making a field pub is appropriate when it is just plain data with no invariant—Rust lets you make that decision field by field.
"Encapsulation requires object-oriented classes."
Encapsulation is a design principle, not a language feature restricted to classes. Rust achieves strong encapsulation through its module and visibility system, often with less ceremony than traditional OOP languages.
Summary
Rust treats encapsulation as a boundary drawn around modules. Everything is private by default, and you expose only what you choose with pub. This module-level privacy, combined with struct field privacy, makes it possible to hide implementation details and enforce invariants without relying on classes or inheritance.
The key takeaway is this: start with everything private, then carefully open access where needed. The compiler will guard the rest. When you later refactor internals, your public interface remains stable, and users of your code never know the difference.
Encapsulation is one pillar of object-oriented design that Rust fully supports and arguably improves upon.