ExamplesBy LevelBy TopicLearning Paths
340 Advanced

340: Async Trait Pattern

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "340: Async Trait Pattern" functional Rust example. Difficulty level: Advanced. Key concepts covered: Functional Programming. Rust traits with `async fn` methods have a fundamental limitation: the returned `Future` type differs per implementation, making traits with async methods not object-safe. Key difference from OCaml: 1. **Stable Rust (1.75+)**: Return

Tutorial

The Problem

Rust traits with async fn methods have a fundamental limitation: the returned Future type differs per implementation, making traits with async methods not object-safe. The workaround is to return Pin<Box<dyn Future<Output = T> + Send>> — a heap-allocated, type-erased future. The async-trait crate automates this boxing. Understanding the manual pattern illuminates what the macro generates and when to use each approach.

🎯 Learning Outcomes

  • • Understand why async fn in traits is not directly object-safe
  • • Implement async traits using Pin<Box<dyn Future<...>>> return types manually
  • • Use AsyncResult<T, E> type alias for cleaner signatures
  • • Recognize when to use the async-trait crate vs manual boxing
  • Code Example

    type AsyncResult<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;
    
    trait AsyncStore: Send + Sync {
        fn get(&self, key: &str) -> AsyncResult<Option<String>, String>;
        fn set(&self, key: String, val: String) -> AsyncResult<(), String>;
    }

    Key Differences

  • Stable Rust (1.75+): Return-position impl Trait in traits (RPITIT) was stabilized — async fn in traits now works on stable Rust without the async-trait crate in many cases.
  • Object safety: For dyn AsyncStore, boxing is still required; for monomorphic dispatch, stable async fn in traits now works.
  • async-trait crate: #[async_trait] macro transforms async fn methods to return Pin<Box<dyn Future>> automatically — reducing boilerplate.
  • Performance: Boxed futures allocate per-call; unboxed async fn in traits (stable Rust 1.75+) avoids this for concrete types.
  • OCaml Approach

    OCaml's module types with Lwt functions are the idiomatic equivalent — each module implementing the signature provides its own Lwt.t-returning functions:

    module type STORE = sig
      val get : string -> string option Lwt.t
      val set : string -> string -> unit Lwt.t
    end
    

    Module types are inherently polymorphic — no boxing is required.

    Full Source

    #![allow(clippy::all)]
    //! # Async Trait Pattern
    //!
    //! Async methods in traits require boxing — `async fn` in traits isn't directly
    //! supported in stable Rust without the `async-trait` crate.
    
    use std::collections::HashMap;
    use std::future::Future;
    use std::pin::Pin;
    use std::sync::Mutex;
    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
    
    /// Type alias for boxed async results.
    pub type AsyncResult<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;
    
    /// Async storage trait with boxed futures for object safety.
    pub trait AsyncStore: Send + Sync {
        fn get(&self, key: &str) -> AsyncResult<Option<String>, String>;
        fn set(&self, key: String, value: String) -> AsyncResult<(), String>;
        fn delete(&self, key: &str) -> AsyncResult<bool, String>;
    }
    
    /// In-memory implementation of AsyncStore.
    pub struct MemStore {
        data: Mutex<HashMap<String, String>>,
    }
    
    impl MemStore {
        pub fn new() -> Self {
            Self {
                data: Mutex::new(HashMap::new()),
            }
        }
    }
    
    impl Default for MemStore {
        fn default() -> Self {
            Self::new()
        }
    }
    
    impl AsyncStore for MemStore {
        fn get(&self, key: &str) -> AsyncResult<Option<String>, String> {
            let result = self.data.lock().unwrap().get(key).cloned();
            Box::pin(async move { Ok(result) })
        }
    
        fn set(&self, key: String, value: String) -> AsyncResult<(), String> {
            self.data.lock().unwrap().insert(key, value);
            Box::pin(async { Ok(()) })
        }
    
        fn delete(&self, key: &str) -> AsyncResult<bool, String> {
            let removed = self.data.lock().unwrap().remove(key).is_some();
            Box::pin(async move { Ok(removed) })
        }
    }
    
    /// A failing store for testing error handling.
    pub struct FailStore;
    
    impl AsyncStore for FailStore {
        fn get(&self, _key: &str) -> AsyncResult<Option<String>, String> {
            Box::pin(async { Err("connection refused".to_string()) })
        }
    
        fn set(&self, _key: String, _value: String) -> AsyncResult<(), String> {
            Box::pin(async { Err("read-only store".to_string()) })
        }
    
        fn delete(&self, _key: &str) -> AsyncResult<bool, String> {
            Box::pin(async { Err("operation not permitted".to_string()) })
        }
    }
    
    /// A minimal executor for testing.
    pub fn block_on<F: Future>(fut: F) -> F::Output {
        unsafe fn clone_waker(ptr: *const ()) -> RawWaker {
            RawWaker::new(ptr, &VTABLE)
        }
        unsafe fn noop(_: *const ()) {}
    
        static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_waker, noop, noop, noop);
    
        let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
        let mut cx = Context::from_waker(&waker);
        let mut fut = Box::pin(fut);
    
        loop {
            if let Poll::Ready(value) = fut.as_mut().poll(&mut cx) {
                return value;
            }
        }
    }
    
    /// Demonstrates using the store through the trait interface.
    pub fn use_store(store: &dyn AsyncStore, key: &str, value: &str) -> Result<Option<String>, String> {
        block_on(store.set(key.to_string(), value.to_string()))?;
        block_on(store.get(key))
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_mem_store_set_get() {
            let store = MemStore::new();
            block_on(store.set("key".to_string(), "value".to_string())).unwrap();
            assert_eq!(
                block_on(store.get("key")).unwrap(),
                Some("value".to_string())
            );
        }
    
        #[test]
        fn test_mem_store_missing_key() {
            let store = MemStore::new();
            assert_eq!(block_on(store.get("missing")).unwrap(), None);
        }
    
        #[test]
        fn test_mem_store_delete() {
            let store = MemStore::new();
            block_on(store.set("k".to_string(), "v".to_string())).unwrap();
            assert!(block_on(store.delete("k")).unwrap());
            assert!(!block_on(store.delete("k")).unwrap());
        }
    
        #[test]
        fn test_fail_store_returns_errors() {
            let store = FailStore;
            assert!(block_on(store.get("any")).is_err());
            assert!(block_on(store.set("k".into(), "v".into())).is_err());
        }
    
        #[test]
        fn test_trait_object_dispatch() {
            let stores: Vec<Box<dyn AsyncStore>> = vec![Box::new(MemStore::new()), Box::new(FailStore)];
    
            // First store works
            assert!(block_on(stores[0].set("k".into(), "v".into())).is_ok());
    
            // Second store fails
            assert!(block_on(stores[1].get("k")).is_err());
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_mem_store_set_get() {
            let store = MemStore::new();
            block_on(store.set("key".to_string(), "value".to_string())).unwrap();
            assert_eq!(
                block_on(store.get("key")).unwrap(),
                Some("value".to_string())
            );
        }
    
        #[test]
        fn test_mem_store_missing_key() {
            let store = MemStore::new();
            assert_eq!(block_on(store.get("missing")).unwrap(), None);
        }
    
        #[test]
        fn test_mem_store_delete() {
            let store = MemStore::new();
            block_on(store.set("k".to_string(), "v".to_string())).unwrap();
            assert!(block_on(store.delete("k")).unwrap());
            assert!(!block_on(store.delete("k")).unwrap());
        }
    
        #[test]
        fn test_fail_store_returns_errors() {
            let store = FailStore;
            assert!(block_on(store.get("any")).is_err());
            assert!(block_on(store.set("k".into(), "v".into())).is_err());
        }
    
        #[test]
        fn test_trait_object_dispatch() {
            let stores: Vec<Box<dyn AsyncStore>> = vec![Box::new(MemStore::new()), Box::new(FailStore)];
    
            // First store works
            assert!(block_on(stores[0].set("k".into(), "v".into())).is_ok());
    
            // Second store fails
            assert!(block_on(stores[1].get("k")).is_err());
        }
    }

    Deep Comparison

    OCaml vs Rust: Async Trait Pattern

    Trait Definition

    OCaml:

    module type ASYNC_STORE = sig
      type t
      val get : t -> string -> string option Lwt.t
      val set : t -> string -> string -> unit Lwt.t
    end
    

    Rust:

    type AsyncResult<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;
    
    trait AsyncStore: Send + Sync {
        fn get(&self, key: &str) -> AsyncResult<Option<String>, String>;
        fn set(&self, key: String, val: String) -> AsyncResult<(), String>;
    }
    

    Key Differences

    AspectOCamlRust
    PolymorphismFirst-class modulesdyn Trait
    BoxingImplicit (Lwt.t)Explicit Box::pin
    Object safetyN/A (modules)Requires boxed return
    Crate helperN/A#[async_trait]

    Exercises

  • Implement two different AsyncStore backends (in-memory and a mock filesystem), and swap them in a function that takes &dyn AsyncStore.
  • Use the async-trait crate and compare its generated code to the manual boxing approach.
  • Benchmark the performance difference between a boxed async trait method and a non-boxed concrete async fn call.
  • Open Source Repos