How to Use the Handle-Body (Pimpl) Pattern in Rust

Implement the Handle-Body pattern in Rust by wrapping private implementation details in a Box within a public struct to hide fields and ensure binary compatibility.

The problem: changing the guts breaks the contract

You publish a library. Users depend on it. Everything works. Then you update a dependency, swap an i32 for an f64, or add a field to optimize a hot path. You bump the patch version. Users update. Their builds fail with linker errors, or worse, they have to recompile their entire dependency tree because your struct layout changed.

In Rust, the size and memory layout of a public struct are part of the Application Binary Interface. If you change the fields, you change the ABI. Any code compiled against the old layout is now broken. This is a feature for safety, but it becomes a friction point when you are maintaining a library and want to iterate on the implementation without forcing every user to recompile or relink.

You need a way to separate the public interface from the private implementation. You need a handle that stays stable while the body behind it changes.

The solution: a handle to a hidden body

The Handle-Body pattern, often called Pimpl (Pointer to Implementation) or Opaque Pointer, solves this by introducing a wrapper struct. The public struct holds only a pointer to a private inner struct. The private struct contains all the actual fields and logic.

The public struct is the handle. It exposes methods. It has a fixed size: just the size of a pointer. The private struct is the body. It lives on the heap. It can grow, shrink, or change layout without affecting the handle.

Think of a remote control. The remote is the handle. It has buttons and a display. You press a button, and something happens. You do not care about the circuit board, the battery type, or the microcontroller inside the plastic shell. The manufacturer can swap the battery from AA to lithium, or replace the chip with a faster model. As long as the buttons work the same way, the remote is unchanged. The internals are hidden behind the casing.

In Rust, the Box is the casing. The public struct holds the Box. The body is inside the Box, and the type of the body is private to the module.

How it works: the pointer shield

When you define a public struct with a Box<PrivateType>, the compiler sees a struct with a single field: a pointer. The size of the struct is the size of the pointer, typically 8 bytes on 64-bit systems. The alignment is the alignment of the pointer.

The private type is never exposed to the outside world. Users of your crate cannot see its fields. They cannot see its size. They cannot see its dependencies. They only see the handle.

Because the handle's size and layout never change, the ABI remains stable. You can add fields to the body, remove fields, change types, or reorder fields. The handle stays the same. Users who have pre-compiled binaries against your library can continue to use them. Users who compile against your library do not need to recompile when you change the body, because the compiler only needs to know the size of the handle, which is constant.

This also reduces compile times for your users. If the body contains heavy types or complex generic machinery, hiding it means those types are not part of the public API surface. The compiler does not need to monomorphize or instantiate those types for every user of your crate. The complexity stays inside your crate.

Minimal example

Here is the pattern in its simplest form. The public struct Widget holds a Box to WidgetImpl. WidgetImpl is private.

// lib.rs

// The public handle.
// This struct is visible to users of the crate.
// Its size is fixed: just a pointer.
pub struct Widget {
    // The Box points to the body on the heap.
    // The body type is opaque to the outside world.
    inner: Box<WidgetImpl>,
}

// The private body.
// This struct is not public.
// Users cannot access these fields directly.
struct WidgetImpl {
    value: i32,
    heavy_data: Vec<u8>,
}

impl Widget {
    // Create a new Widget by allocating the body on the heap.
    pub fn new() -> Self {
        Widget {
            inner: Box::new(WidgetImpl {
                value: 0,
                heavy_data: vec![],
            }),
        }
    }

    // Expose a method that accesses the body.
    // The method is public, but the body remains hidden.
    pub fn get_value(&self) -> i32 {
        self.inner.value
    }

    // Mutate the body through the handle.
    pub fn set_value(&mut self, v: i32) {
        self.inner.value = v;
    }
}

The convention in the Rust community is to name the inner struct with a suffix like Impl, Inner, or Pimpl. Pick one and stick with it. WidgetImpl is common and clear.

Real-world usage: hiding dependencies

The most compelling reason to use this pattern is often not ABI stability, but compile-time reduction and dependency hiding. If your implementation relies on a third-party crate with a heavy API, you do not want that crate to leak into your public types.

When a type appears in a public function signature, every user of your crate must compile that type. If the type comes from a complex dependency, you drag that complexity into your users' build graphs. Hiding the type behind a handle breaks that link.

// lib.rs

// Private module for implementation details.
// This keeps the dependency out of the public API.
mod impls {
    // This dependency is hidden.
    // Users of the crate do not need to know about `external_engine`.
    use external_engine::ComplexProcessor;

    // The body holds the heavy dependency.
    pub(super) struct EngineImpl {
        processor: ComplexProcessor,
        config: String,
    }

    impl EngineImpl {
        pub(super) fn process(&mut self) {
            // Use the heavy dependency internally.
            self.processor.run();
        }
    }
}

// The public handle.
// No trace of `external_engine` in this type.
pub struct Engine {
    inner: Box<impls::EngineImpl>,
}

impl Engine {
    pub fn new() -> Self {
        Engine {
            inner: Box::new(impls::EngineImpl {
                processor: external_engine::ComplexProcessor::default(),
                config: String::from("default"),
            }),
        }
    }

    pub fn process(&mut self) {
        // Delegate to the body.
        self.inner.process();
    }
}

The mod impls pattern is a common convention. It groups the private body and its helpers in a submodule, keeping the top-level namespace clean. The body struct is pub(super) so the handle can access it, but it remains invisible to the outside world.

The friction: traits and derives

The pattern introduces friction with derived traits. You cannot use #[derive(Debug)], #[derive(Clone)], or #[derive(Hash)] on the handle. The compiler generates code for these derives that accesses the fields of the struct. Since the body is private, the generated code cannot access the body's fields. The compiler rejects the derive with E0277 (trait bound not satisfied) or a privacy error.

You must implement these traits manually. This is a deliberate trade-off. The compiler forces you to think about how the handle exposes information.

use std::fmt;

impl fmt::Debug for Widget {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Implement Debug manually.
        // You control exactly what gets printed.
        // You can hide sensitive fields in the body.
        f.debug_struct("Widget")
            .field("value", &self.inner.value)
            // Omit heavy_data to keep the debug output clean.
            .finish()
    }
}

impl Clone for Widget {
    fn clone(&self) -> Self {
        Widget {
            // Clone the body explicitly.
            // This ensures the body type implements Clone.
            inner: Box::new(self.inner.clone()),
        }
    }
}

If the body does not implement Clone, the handle cannot implement Clone. The trait bounds must be satisfied by the body, even though the body is hidden. This is another reason to keep the body minimal and well-defined.

Pitfalls and costs

The pattern is not free. It adds indirection. Every access to the body requires dereferencing the Box. This means one extra memory load. In tight loops, this can hurt performance due to cache misses. The body lives on the heap, scattered in memory. If you have a Vec<Widget>, you have a vector of pointers to scattered bodies. A Vec<WidgetImpl> would be contiguous data with better cache locality.

Measure before you apply the pattern. If performance is critical and the data is accessed frequently, the indirection tax might be too high.

Another pitfall is leaking the body through Deref. It is tempting to implement Deref for the handle to expose the body's methods directly.

// BAD: This defeats the purpose of the pattern.
// Users can now access the body type and its fields.
// The body is no longer opaque.
impl std::ops::Deref for Widget {
    type Target = WidgetImpl;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

If you implement Deref, the body type becomes part of the public API. Users can cast the handle to the body. The ABI stability is lost. The dependency hiding is lost. Do not do this. If you need to expose methods, write wrapper methods on the handle. Keep the body hidden.

If you try to access the body's fields from outside the module, the compiler stops you with E0603 (struct field is private). This is a safety feature. It enforces the abstraction. Respect it.

When to use this pattern

Use the Handle-Body pattern when you are building a library and need to guarantee ABI stability across versions. Use the Handle-Body pattern when you want to hide heavy or third-party types from your public API to reduce compile times for users. Use the Handle-Body pattern when the internal layout of a struct changes frequently during development, and you want to avoid breaking downstream code. Reach for a plain public struct when you are writing an application or a crate where ABI stability does not matter and you want zero indirection overhead. Reach for Box<dyn Trait> when you need runtime polymorphism and the set of types is open, rather than just hiding the implementation of a single type.

Hide the complexity. Expose the interface. The handle is the contract. The body is the secret.

Where to go next