Trait Objects and Dynamic Dispatch

Designing Extensible Interfaces

An extensible interface allows downstream users or plugins to add new implementations of a behavior and compose them into existing pipelines, registries, or middlewares without modifying the core library source code.

Designing a dyn-Compatible Plugin and Middleware Pipeline

To make a trait work cleanly in both generic and dynamic (Box<dyn Trait>) plugin registries:

  1. Keep core trait methods dyn-compatible (&self or &mut self, concrete slice/struct parameters rather than generic <T> method parameters).
  2. Gate convenience builder or boxing methods with where Self: Sized.
  3. Provide a forwarding impl<T: Trait + ?Sized> Trait for Box<T> so Box<dyn Trait> itself satisfies the trait bound.
#[derive(Debug, Clone)]
pub struct Request {
    pub path: String,
    pub headers: Vec<(String, String)>,
}

pub trait Middleware: Send + Sync {
    fn handle(&self, req: &mut Request);
}

// Forwarding implementation so `Box<dyn Middleware>` also implements `Middleware`:
impl<M: Middleware + ?Sized> Middleware for Box<M> {
    fn handle(&self, req: &mut Request) {
        (**self).handle(req);
    }
}

pub struct Pipeline {
    stages: Vec<Box<dyn Middleware>>,
}

impl Pipeline {
    pub fn new() -> Self {
        Self { stages: Vec::new() }
    }

    pub fn add<M: Middleware + 'static>(&mut self, stage: M) {
        self.stages.push(Box::new(stage));
    }

    pub fn execute(&self, req: &mut Request) {
        for stage in &self.stages {
            stage.handle(req);
        }
    }
}

// Downstream user implementations:
struct PathPrefixer(&'static str);

impl Middleware for PathPrefixer {
    fn handle(&self, req: &mut Request) {
        req.path = format!("{}{}", self.0, req.path);
    }
}

struct HeaderInjector {
    key: &'static str,
    value: &'static str,
}

impl Middleware for HeaderInjector {
    fn handle(&self, req: &mut Request) {
        req.headers.push((self.key.to_owned(), self.value.to_owned()));
    }
}

fn main() {
    let mut pipeline = Pipeline::new();
    pipeline.add(PathPrefixer("/v1"));
    pipeline.add(HeaderInjector {
        key: "X-Runtime",
        value: "Rust-2024",
    });

    let mut req = Request {
        path: String::from("/users"),
        headers: Vec::new(),
    };

    pipeline.execute(&mut req);
    println!("Final request: {req:?}");
}
Final request: Request { path: "/v1/users", headers: [("X-Runtime", "Rust-2024")] }

Supporting Cloning on Trait Objects (CloneBox Pattern)

Because Clone::clone(&self) -> Self returns Self by value, Clone is not dyn-compatible (Box<dyn MyTrait + Clone> does not compile). To make Box<dyn MyTrait> cloneable, add a clone_box(&self) -> Box<dyn MyTrait> helper trait implemented via a blanket impl for all T: MyTrait + Clone:

trait Action: ActionClone {
    fn describe(&self) -> String;
}

trait ActionClone {
    fn clone_box(&self) -> Box<dyn Action>;
}

impl<T> ActionClone for T
where
    T: 'static + Action + Clone,
{
    fn clone_box(&self) -> Box<dyn Action> {
        Box::new(self.clone())
    }
}

impl Clone for Box<dyn Action> {
    fn clone(&self) -> Self {
        self.clone_box()
    }
}

#[derive(Clone)]
struct PingAction {
    target: String,
}

impl Action for PingAction {
    fn describe(&self) -> String {
        format!("PING {}", self.target)
    }
}

fn main() {
    let original: Box<dyn Action> = Box::new(PingAction {
        target: String::from("10.0.0.1"),
    });
    let copy = original.clone();

    println!("Original: {}", original.describe());
    println!("Copy:     {}", copy.describe());
}
Original: PING 10.0.0.1
Copy:     PING 10.0.0.1