How to Create Public APIs with Proper Encapsulation in Rust

Expose public APIs in Rust by using the pub keyword on modules and items while keeping internal logic private.

The wall between your code and the world

You are building a library. You want to ship version 1.0. You know you will need to fix bugs, optimize hot paths, and add features. You also know that users will depend on your code. If you rename a helper function or change an internal enum variant, you do not want every user's build to break. You need a boundary. You need a public face that is clean and stable, while keeping the messy machinery hidden behind a wall.

Rust gives you that wall through visibility modifiers. The default is private. Everything stays behind the wall unless you explicitly mark it public. This design forces you to think about your API surface. You decide what users see. You decide what stays internal. The compiler enforces the boundary at compile time.

Think of a restaurant kitchen. The menu is your public API. Customers order from the menu. They never see the prep station, the inventory logs, or the chef's scratchpad. If the chef decides to swap the knife block for a magnetic strip, the menu does not change. The customers do not care. In Rust, pub marks the menu items. Everything else stays behind the swinging doors.

How visibility works

Rust organizes code into modules. A module is a namespace. You can nest modules, group related items, and control who sees what. The key insight is that visibility is relative. An item is visible to its own module, its parent modules, and any modules that are allowed to see it. By default, nothing is visible outside its own module.

To expose something, you use pub. You can mark modules, functions, structs, enums, and fields as public. You can also re-export items to flatten your API.

Here is the minimal pattern for a clean API.

// src/lib.rs

// Keep the module private. Users cannot access `my_crate::internals`.
mod internals;

// Re-export the public face. Users import `my_crate::Color`.
pub use internals::Color;

// Re-export the function. Users import `my_crate::mix`.
pub use internals::mix;

This structure does two things. First, it hides the module structure. Users never see internals. They only see Color and mix at the crate root. Second, it gives you freedom. You can rename internals to core or engine without breaking user code. The re-export acts as a stable alias. The menu stays the same even if you reorganize the kitchen.

A realistic API surface

Let's build a color manipulation crate. Users want to create colors and calculate brightness. They do not need to know how you store the color values internally. You might switch from RGB to HSL later. The public API should remain stable.

// src/lib.rs

// Private module. Hides implementation details.
mod internals;

// Public types and functions. This is the API surface.
pub use internals::Color;
pub use internals::Palette;
// src/internals.rs

/// Represents a color in RGB space.
///
/// Fields are private. Users must use constructors and methods.
pub struct Color {
    r: u8,
    g: u8,
    b: u8,
}

/// Private helper. Only used inside this crate.
fn validate_rgb(r: u8, g: u8, b: u8) -> bool {
    // Validation logic. Users don't see this function.
    r <= 255 && g <= 255 && b <= 255
}

impl Color {
    /// Creates a new color from RGB components.
    ///
    /// Panics if any component exceeds 255.
    pub fn new(r: u8, g: u8, b: u8) -> Self {
        if !validate_rgb(r, g, b) {
            panic!("Invalid color values");
        }
        Self { r, g, b }
    }

    /// Calculates the perceived brightness.
    pub fn brightness(&self) -> f32 {
        (self.r as f32 + self.g as f32 + self.b as f32) / 3.0
    }
}

Notice the struct definition. pub struct Color makes the type name public. The fields r, g, and b have no pub marker. They are private. Users can create a Color using Color::new, but they cannot access color.r directly. This is a crucial distinction in Rust. pub struct does not make fields public. You must mark fields explicitly.

This forces encapsulation. Users must go through your methods. You control the invariants. You can add validation in new. You can change the internal representation later without breaking user code, as long as the method signatures stay the same.

Crate-level privacy: the superpower

Rust has a visibility rule that often surprises developers coming from other languages. Private items are visible to the entire crate, not just the module. This is called crate-level privacy.

Any code inside the crate can access private items. Module boundaries do not block access within the crate. Only the crate boundary blocks access.

This is a superpower for library authors. You can share helpers freely inside the crate without worrying about module visibility. You can access private fields of structs from any module. You only need to worry about what leaks outside the crate.

Here is how Palette can access private fields of Color.

// src/internals.rs

/// A collection of colors.
pub struct Palette {
    colors: Vec<Color>,
}

impl Palette {
    /// Creates a new palette.
    pub fn new() -> Self {
        Self { colors: Vec::new() }
    }

    /// Adds a color to the palette.
    pub fn add(&mut self, color: Color) {
        self.colors.push(color);
    }

    /// Checks if any color is predominantly red.
    ///
    /// Accesses private field `r` of `Color`.
    /// This works because both types are in the same crate.
    pub fn has_dominant_red(&self) -> bool {
        self.colors.iter().any(|c| c.r > 200)
    }
}

The method has_dominant_red accesses c.r. The field r is private. This code compiles perfectly. The compiler allows access because Palette and Color are in the same crate. The wall only stops outsiders. Inside the crate, your code can talk freely.

This design encourages tight coupling internally while maintaining a clean external boundary. You can refactor internal structures without changing visibility. You can share data without exposing it.

Pitfalls and compiler errors

The compiler protects your API boundary. It rejects code that tries to cross the wall. You will see these errors when users or you try to access private items.

If a user tries to access a private field, the compiler rejects it with E0616 (field is private).

error[E0616]: field `r` of struct `Color` is private

If a user tries to peek behind the curtain and access a private module, they get E0603 (module is private).

error[E0603]: module `internals` is private

These errors are features. They tell you that the encapsulation is working. The user cannot depend on internal details. You are free to change them.

Another common pitfall is using pub mod when you should use mod with pub use. When you write pub mod utils, you expose the module path. Users must write my_crate::utils::mix. If you rename utils to helpers, every user's code breaks. That is a breaking change.

If you use mod utils and pub use utils::mix, users write my_crate::mix. You can rename the module to helpers and the user code stays valid. The re-export hides the structure. Use pub mod only when the module name is part of the API. Use mod with pub use to hide implementation details.

Choosing visibility modifiers

Rust provides several visibility modifiers. Each serves a specific purpose. Use the right one for the job.

Use pub for items that form your external API. These are the types and functions users import and depend on. Mark structs, enums, functions, and constants as pub when they are part of the public contract.

Use pub(crate) for helpers that multiple modules inside your crate need to share, but users should never touch. This keeps the internal plumbing accessible without leaking it. It is safer than pub because it prevents accidental exposure.

Use pub use to flatten your API surface. Re-export items from deep modules so users can import them directly from the crate root. This hides module structure and protects users from internal refactoring.

Use pub mod when the module name itself is part of the API. This is common for large crates where grouping by feature helps users navigate, like std::collections::HashMap. Only use this when the grouping adds value for users.

Use private modules (no pub) to hide implementation details. Combine this with pub use to expose the results while keeping the structure secret. This is the standard pattern for clean APIs.

Use pub(super) to expose an item to the parent module only. This is useful for nested modules where a child needs to share something with the parent, but not with siblings or the crate root. It limits the blast radius of visibility.

Convention asides

The community follows a few conventions that pay off over time.

Re-export at the crate root. Users expect to import types and functions directly from the crate. Do not force them to type my_crate::deep::nested::Module::Type. Use pub use to bring items up. Keep the root clean and flat.

Prefer pub(crate) for internal sharing. When you need to share a helper across modules, mark it pub(crate). This makes your intent clear. It signals that the item is internal. It also prevents accidental leaks if you move the item to a public module later. The compiler will catch the visibility mismatch.

Keep unsafe blocks small and isolated. If your API involves unsafe, wrap the unsafe logic in a small helper function. Mark the helper unsafe and call it from a safe public function. Document the safety invariants. This limits the unsafe surface and makes the code easier to audit.

Use let _ = result to discard values intentionally. When a function returns a value that you choose to ignore, assign it to _. This signals to readers that you considered the value and decided to drop it. It suppresses warnings and clarifies intent.

Where to go next

Leak less. Your future self will thank you when you refactor.