Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 054 / 100

Dynamic dispatch with dyn Trait: lessons from production

When you need runtime polymorphism.

Expona Admin7 min read

Dynamic dispatch with dyn Trait: lessons from production

You're building a payment processing system and need to support multiple payment gateways—Stripe, PayPal, Square—without hardcoding each one into your request handler. Rust's static type system usually forces you to know exact types at compile time, but dynamic dispatch with dyn Trait lets you work with polymorphic types at runtime. We've learned hard lessons about when to reach for this powerful tool and when it'll slow you down or create unexpected pain.

Understanding Box<dyn Trait> for mixed types

The core pattern in runtime polymorphism is storing trait objects—pointers to data along with metadata about how to call methods on that data. In production, you'll most often see Box<dyn Trait>: a heap-allocated value where the exact type is erased but the trait interface remains available.

Picture a payment gateway abstraction. Different providers have different APIs, but we want a unified interface:

rust
trait PaymentGateway {
    fn charge(&mut self, amount: u64) -> Result<String, PaymentError>;
    fn refund(&mut self, transaction_id: &str) -> Result<(), PaymentError>;
}

struct StripeGateway {
    api_key: String,
}

struct PayPalGateway {
    client_id: String,
}

impl PaymentGateway for StripeGateway {
    fn charge(&mut self, amount: u64) -> Result<String, PaymentError> {
        // Call Stripe API
        Ok(format!("stripe_txn_{}", amount))
    }

    fn refund(&mut self, transaction_id: &str) -> Result<(), PaymentError> {
        // Process refund via Stripe
        Ok(())
    }
}

impl PaymentGateway for PayPalGateway {
    fn charge(&mut self, amount: u64) -> Result<String, PaymentError> {
        // Call PayPal API
        Ok(format!("paypal_txn_{}", amount))
    }

    fn refund(&mut self, transaction_id: &str) -> Result<(), PaymentError> {
        // Process refund via PayPal
        Ok(())
    }
}

Without dyn Trait, storing a vector of mixed gateway types is impossible. But with trait objects, we can:

rust
fn get_gateway_for_merchant(merchant_id: &str) -> Box<dyn PaymentGateway> {
    match merchant_id {
        "stripe_merchant" => Box::new(StripeGateway {
            api_key: "sk_live_123".to_string(),
        }),
        "paypal_merchant" => Box::new(PayPalGateway {
            client_id: "client_123".to_string(),
        }),
        _ => Box::new(StripeGateway {
            api_key: "sk_test".to_string(),
        }),
    }
}

async fn process_payment(
    merchant_id: &str,
    amount: u64,
) -> Result<String, PaymentError> {
    let mut gateway = get_gateway_for_merchant(merchant_id);
    gateway.charge(amount)
}

This pattern works beautifully when you're making routing decisions at runtime based on configuration, merchant data, or A/B test flags. We've used it extensively in SaaS platforms where customer tenants can configure their own integrations.

Understanding the small runtime cost from vtables

Here's what happens under the hood: when you use Box<dyn PaymentGateway>, Rust creates a virtual method table (vtable). This vtable is a struct of function pointers—one for each method in the trait. Every time you call a method on a trait object, the runtime performs an indirect function call through the vtable instead of a direct static call.

The cost is real but usually negligible. On modern CPUs, an indirect function call through a vtable costs roughly the same as a regular function call, plus the overhead of dereferencing two pointers. We've profiled this in production and seen it add microseconds, not milliseconds. The bottleneck is almost never the vtable; it's the I/O underneath—the network request to Stripe, the database query, the cache miss.

However, in tight inner loops—parsing millions of events, processing high-frequency trading signals—that small cost compounds. In a ride-hailing system's match-making algorithm that runs thousands of times per second, using generics instead of trait objects can make a measurable difference.

The memory layout also matters slightly. A trait object is a fat pointer: 16 bytes on 64-bit systems (one pointer to data, one pointer to vtable). A regular reference is 8 bytes. For collections of millions of items, that extra memory can hurt cache locality.

Practical guidance: measure first. Use trait objects for business logic and I/O-bound operations. Avoid them in CPU-intensive inner loops unless profiling shows the trait abstraction is worth the cost.

Object safety rules and their constraints

Not every trait can be made into a trait object. Rust enforces object safety rules to ensure the vtable can actually be generated. Violations of these rules become compile-time errors, and understanding them saves time debugging.

A trait is object-safe if:

  • All methods return Self or a concrete type—never a type parameter that depends on Self.
  • Methods don't use <T> or other generic type parameters that aren't part of the trait definition itself.
  • The trait doesn't have static methods (methods without &self, &mut self, or Box<self>).

A classic violation:

rust
trait Cloneable {
    fn clone_self(&self) -> Self; // Returns Self, not object-safe
}

// This won't compile:
// let obj: Box<dyn Cloneable> = Box::new(MyType);

Why? The vtable would need to know the actual concrete type at runtime to return the right one. Instead, use a concrete return type:

rust
trait Cloneable {
    fn clone_self(&self) -> Box<dyn Cloneable>; // Object-safe
}

Generic methods also break object safety:

rust
trait Serializable {
    fn serialize<F: Format>(&self, format: F) -> Vec<u8>;
}

// This won't compile as a trait object.
// The vtable can't store infinitely many versions for every type F.

We've hit this in production when designing plugin systems. You want plugins to be trait objects, but plugin methods often need generics. The solution is usually to move the generic constraints elsewhere—accept a Box<dyn Format> instead of a generic type parameter.

Using generics when types are known at compile time

Generics and trait objects solve different problems. Generics work when you know the concrete types at compile time; the compiler generates specialized code for each type (monomorphization). Trait objects work when types are unknown until runtime.

In an e-commerce system, imagine a checkout pipeline with handlers for validation, payment, fulfillment:

rust
trait CheckoutStep {
    fn execute(&mut self, order: &Order) -> Result<(), CheckoutError>;
}

// If the pipeline is fixed at compile time, use generics:
struct Checkout<V: CheckoutStep, P: CheckoutStep, F: CheckoutStep> {
    validator: V,
    processor: P,
    fulfiller: F,
}

impl<V, P, F> Checkout<V, P, F>
where
    V: CheckoutStep,
    P: CheckoutStep,
    F: CheckoutStep,
{
    fn process(&mut self, order: &Order) -> Result<(), CheckoutError> {
        self.validator.execute(order)?;
        self.processor.execute(order)?;
        self.fulfiller.execute(order)?;
        Ok(())
    }
}

Generics give you zero-cost abstraction: the compiler generates optimized code with no indirection. Inlining is possible. Branch prediction works well. This is Rust's preferred approach.

But if you need a variable number of steps loaded from configuration, or middleware registered at runtime, trait objects become necessary:

rust
struct DynamicCheckout {
    steps: Vec<Box<dyn CheckoutStep>>,
}

impl DynamicCheckout {
    fn process(&mut self, order: &Order) -> Result<(), CheckoutError> {
        for step in &mut self.steps {
            step.execute(order)?;
        }
        Ok(())
    }
}

We use this pattern in multi-tenant SaaS platforms where each tenant configures their own business logic. The cost is worth it for the flexibility.

Common mistakes

Overusing trait objects when generics suffice. If you control all the types at compile time—even across modules—generics are faster and clearer. Reaching for Box<dyn Trait> by default creates unnecessary indirection.

Forgetting lifetime annotations. Trait objects with references need lifetime annotations: Box<dyn Trait + 'a>. Forgetting this causes borrow checker errors that are confusing to debug.

Mixing mutable and immutable references. If you store Box<dyn Trait> in a struct and later try to call mutable methods, you'll hit borrow checker issues. Think carefully about whether your trait needs &mut self or if interior mutability via Cell<T> or RefCell<T> is better.

Creating trait objects in hot paths. Allocating and deallocating boxes repeatedly in a tight loop creates garbage pressure. Consider pooling or reusing trait objects when performance matters.

Key takeaways

  • Box<dyn Trait> enables polymorphism for mixed types when you don't know the concrete type until runtime—essential for plugin systems, configurable pipelines, and dynamic dispatch scenarios.
  • Vtable indirection has a small cost (typically microseconds per call), negligible for I/O-bound code but worth measuring in CPU-intensive loops.
  • Object safety rules prevent you from using generic type parameters in trait methods that return types depending on Self or static methods in trait objects.
  • Prefer generics when types are fixed at compile time; they're zero-cost and allow full optimization by the compiler.
  • Measure performance impact in production before optimizing away trait objects; the actual bottleneck is usually elsewhere.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles