Guide contents

Lesson 71 of 94

Async Traits

In Rust, you can write async fn directly in a trait. It works smoothly with generics, and it has two sharp edges that this lesson is mostly about:

  1. Send: a generic caller can't assume the returned future is Send, so spawning it on Tokio may fail to compile. The fix is a one-line change to the trait.
  2. dyn Trait: each implementation returns a different future type, so a trait with async fn can't be used as dyn Trait directly. You box the futures, by hand or with the async-trait crate.

If you've read the Traits and Trait Objects lessons, everything here builds on them.

Situation Rust
declaring an async method trait Repo { async fn find(&self, id: u64) -> User; }
calling through a generic fn f<R: Repo>(r: &R) or &impl Repo: works directly
a list of mixed implementations Vec<Box<dyn Repo>>: needs boxed futures
one future type for everything Pin<Box<dyn Future<Output = T> + Send>> (BoxFuture<T>)

1. async fn in a trait

Write it like any other trait method. Implementations use async fn too.

Rust
use std::time::Duration;

trait WeatherSource {
    async fn temperature(&self, city: &str) -> Option<f64>;
}

struct FakeApi;

impl WeatherSource for FakeApi {
    async fn temperature(&self, city: &str) -> Option<f64> {
        tokio::time::sleep(Duration::from_millis(10)).await; // network call
        match city {
            "Oslo" => Some(-3.5),
            "Lisbon" => Some(18.0),
            _ => None,
        }
    }
}

#[tokio::main]
async fn main() {
    let api = FakeApi;
    for city in ["Oslo", "Lisbon", "Atlantis"] {
        println!("{city}: {:?}", api.temperature(city).await);
    }
}
Output
Oslo: Some(-3.5)
Lisbon: Some(18.0)
Atlantis: None

2. Using the trait through generics

Generic code over the trait works exactly like it does for normal traits: T: WeatherSource or &impl WeatherSource. Each call is statically dispatched, with no allocation.

Rust
trait WeatherSource {
    async fn temperature(&self, city: &str) -> Option<f64>;
}

struct Fixed(f64);

impl WeatherSource for Fixed {
    async fn temperature(&self, _city: &str) -> Option<f64> {
        Some(self.0)
    }
}

async fn warmest<'a>(source: &impl WeatherSource, cities: &[&'a str]) -> Option<(&'a str, f64)> {
    let mut best: Option<(&str, f64)> = None;
    for &city in cities {
        if let Some(t) = source.temperature(city).await {
            if best.is_none_or(|(_, b)| t > b) {
                best = Some((city, t));
            }
        }
    }
    best
}

#[tokio::main]
async fn main() {
    println!("{:?}", warmest(&Fixed(21.0), &["Rome", "Paris"]).await);
}
Output
Some(("Rome", 21.0))

3. Several implementations: real and fake

The main reason to put async operations behind a trait is to swap implementations: a real HTTP client in production, an in-memory fake in tests.

Rust
use std::collections::HashMap;
use std::time::Duration;

trait ExchangeRates {
    async fn rate(&self, from: &str, to: &str) -> Result<f64, String>;
}

struct HttpRates; // would call a real API

impl ExchangeRates for HttpRates {
    async fn rate(&self, from: &str, to: &str) -> Result<f64, String> {
        tokio::time::sleep(Duration::from_millis(20)).await;
        if from == "EUR" && to == "USD" { Ok(1.08) } else { Err(format!("no quote for {from}/{to}")) }
    }
}

struct FixedRates(HashMap<(&'static str, &'static str), f64>);

impl ExchangeRates for FixedRates {
    async fn rate(&self, from: &str, to: &str) -> Result<f64, String> {
        self.0.iter().find(|((f, t), _)| *f == from && *t == to).map(|(_, r)| *r).ok_or(String::from("missing"))
    }
}

async fn convert(rates: &impl ExchangeRates, cents: u64, from: &str, to: &str) -> Result<u64, String> {
    let rate = rates.rate(from, to).await?;
    Ok((cents as f64 * rate).round() as u64)
}

#[tokio::main]
async fn main() {
    println!("{:?}", convert(&HttpRates, 10_000, "EUR", "USD").await);
    println!("{:?}", convert(&HttpRates, 10_000, "EUR", "JPY").await);

    let test_rates = FixedRates(HashMap::from([(("EUR", "JPY"), 160.0)]));
    println!("{:?}", convert(&test_rates, 10_000, "EUR", "JPY").await);
}
Output
Ok(10800)
Err("no quote for EUR/JPY")
Ok(1600000)

4. Default async methods

Traits can provide default implementations for async methods, which can call the required ones.

Rust
trait KeyValueStore {
    async fn get(&self, key: &str) -> Option<String>;

    // Default method built on top of `get`.
    async fn get_or(&self, key: &str, fallback: &str) -> String {
        self.get(key).await.unwrap_or_else(|| fallback.to_string())
    }

    async fn get_many(&self, keys: &[&str]) -> Vec<Option<String>> {
        let mut out = Vec::new();
        for k in keys {
            out.push(self.get(k).await);
        }
        out
    }
}

struct EnvStore;

impl KeyValueStore for EnvStore {
    async fn get(&self, key: &str) -> Option<String> {
        match key {
            "PORT" => Some(String::from("8080")),
            "HOST" => Some(String::from("0.0.0.0")),
            _ => None,
        }
    }
}

#[tokio::main]
async fn main() {
    let store = EnvStore;
    println!("{}", store.get_or("LOG_LEVEL", "info").await);
    println!("{:?}", store.get_many(&["HOST", "PORT", "DEBUG"]).await);
}
Output
info
[Some("0.0.0.0"), Some("8080"), None]

5. What async fn in a trait means

async fn name(&self) -> T in a trait is shorthand for a method returning impl Future<Output = T>. You can write that form yourself, and implementations may still use async fn.

Rust
use std::future::Future;

trait Greeter {
    // Same as: async fn greet(&self, name: &str) -> String;
    fn greet(&self, name: &str) -> impl Future<Output = String>;
}

struct English;
struct Spanish;

impl Greeter for English {
    async fn greet(&self, name: &str) -> String {
        format!("Hello, {name}")
    }
}

impl Greeter for Spanish {
    // Or write the long form in the impl too.
    fn greet(&self, name: &str) -> impl Future<Output = String> {
        let name = name.to_string();
        async move { format!("Hola, {name}") }
    }
}

#[tokio::main]
async fn main() {
    println!("{}", English.greet("Ana").await);
    println!("{}", Spanish.greet("Ana").await);
}
Output
Hello, Ana
Hola, Ana

Each implementation's future is a different, compiler-generated type. That's what makes calls zero-cost, and it's also the root of both problems below.

6. Generic async methods

An async trait method can have its own type parameters, like any other method. A codec that turns any serializable value into bytes is a typical example of reusable infrastructure code:

Rust
use serde::{Deserialize, Serialize, de::DeserializeOwned};

trait Codec {
    async fn encode<T: Serialize + Sync>(&self, value: &T) -> Result<Vec<u8>, String>;
    async fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, String>;
}

struct Json;
struct PrettyJson;

impl Codec for Json {
    async fn encode<T: Serialize + Sync>(&self, value: &T) -> Result<Vec<u8>, String> {
        serde_json::to_vec(value).map_err(|e| e.to_string())
    }

    async fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, String> {
        serde_json::from_slice(bytes).map_err(|e| e.to_string())
    }
}

impl Codec for PrettyJson {
    async fn encode<T: Serialize + Sync>(&self, value: &T) -> Result<Vec<u8>, String> {
        serde_json::to_vec_pretty(value).map_err(|e| e.to_string())
    }

    async fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, String> {
        serde_json::from_slice(bytes).map_err(|e| e.to_string())
    }
}

#[derive(Debug, Serialize, Deserialize)]
struct Session {
    user_id: u64,
    roles: Vec<String>,
}

// Works with any codec and any serializable type.
async fn round_trip<C: Codec>(codec: &C, session: &Session) -> Result<Session, String> {
    let bytes = codec.encode(session).await?;
    println!("{} bytes", bytes.len());
    codec.decode(&bytes).await
}

#[tokio::main]
async fn main() {
    let session = Session { user_id: 7, roles: vec![String::from("admin")] };
    println!("{:?}", round_trip(&Json, &session).await);
    println!("{:?}", round_trip(&PrettyJson, &session).await);
    println!("{:?}", Json.decode::<Session>(b"{\"user_id\": \"seven\"}").await);
}
Output
31 bytes
Ok(Session { user_id: 7, roles: ["admin"] })
48 bytes
Ok(Session { user_id: 7, roles: ["admin"] })
Err("invalid type: string \"seven\", expected u64 at line 1 column 19")

Generic methods are resolved at compile time, so a trait with them can't be used as dyn Codec (see Trait Objects). Use it through generics, as round_trip does.

7. The Send problem

A generic function doesn't know which implementation it gets, so it doesn't know whether the future is Send. tokio::spawn needs Send, so this fails:

Rust✗ does not compile
use std::sync::Arc;

trait Notifier {
    async fn notify(&self, msg: String) -> bool;
}

fn notify_in_background<N: Notifier + Send + Sync + 'static>(n: Arc<N>, msg: String) {
    tokio::spawn(async move {
        n.notify(msg).await;
    });
}

fn main() {}
Output
error: future cannot be sent between threads safely
   --> src/main.rs:8:5
    |
  8 | /     tokio::spawn(async move {
  9 | |         n.notify(msg).await;
 10 | |     });
    | |______^ future created by async block is not `Send`
    |
    = help: within `{async block@src/main.rs:8:18: 8:28}`, the trait `Send` is not implemented for `impl Future<Output = bool>`
note: future is not `Send` as it awaits another future which is not `Send`
   --> src/main.rs:9:9
    |
  9 |         n.notify(msg).await;
    |         ^^^^^^^^^^^^^ await occurs here on type `impl Future<Output = bool>`, which is not `Send`
note: required by a bound in `tokio::spawn`
   --> ~/.cargo/registry/src/.../tokio-1.53.1/src/task/spawn.rs:176:21
    |
174 |     pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
    |            ----- required by a bound in this function
175 |     where
176 |         F: Future + Send + 'static,
    |                     ^^^^ required by this bound in `spawn`
help: `Send` can be made part of the associated future's guarantees for all implementations of `Notifier::notify`
    |
  4 -     async fn notify(&self, msg: String) -> bool;
  4 +     fn notify(&self, msg: String) -> impl std::future::Future<Output = bool> + Send;
    |

N: Send + Sync says the notifier is thread-safe, but says nothing about the future its notify returns. An implementation might hold an Rc across an .await, and the compiler must allow for that.

8. The fix: -> impl Future + Send in the trait

Declare the method with the explicit return type and add + Send. Now every implementation must return a Send future (the compiler checks each impl), and generic callers can rely on it. Implementations can still use async fn.

Rust
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;

trait Notifier {
    fn notify(&self, msg: String) -> impl Future<Output = bool> + Send;
}

struct EmailNotifier {
    address: String,
}

impl Notifier for EmailNotifier {
    async fn notify(&self, msg: String) -> bool {
        tokio::time::sleep(Duration::from_millis(10)).await;
        println!("email to {}: {msg}", self.address);
        true
    }
}

fn notify_in_background<N: Notifier + Send + Sync + 'static>(n: Arc<N>, msg: String) -> tokio::task::JoinHandle<bool> {
    tokio::spawn(async move { n.notify(msg).await })
}

#[tokio::main]
async fn main() {
    let email = Arc::new(EmailNotifier { address: String::from("ops@example.com") });
    let sent = notify_in_background(email, String::from("disk 90% full")).await.unwrap();
    println!("sent: {sent}");
}
Output
email to ops@example.com: disk 90% full
sent: true

This is the standard advice for library traits that users will call from multi-threaded runtimes: write fn method(...) -> impl Future<Output = T> + Send. For traits used only inside your own code with concrete types, plain async fn is fine.

9. An implementation that isn't Send is caught at the impl

With the + Send bound in the trait, a non-Send implementation is rejected where it's written, with a clear message, instead of at some distant spawn:

Rust✗ does not compile
use std::future::Future;
use std::rc::Rc;

trait Notifier {
    fn notify(&self, msg: String) -> impl Future<Output = bool> + Send;
}

struct LocalNotifier;

impl Notifier for LocalNotifier {
    async fn notify(&self, msg: String) -> bool {
        let log = Rc::new(msg); // Rc is not Send...
        tokio::task::yield_now().await; // ...and it's held across an await
        !log.is_empty()
    }
}

fn main() {}
Output
error: future cannot be sent between threads safely
  --> src/main.rs:11:44
   |
11 |     async fn notify(&self, msg: String) -> bool {
   |                                            ^^^^ future returned by `notify` is not `Send`
   |
   = help: within `impl Future<Output = bool>`, the trait `Send` is not implemented for `Rc<String>`
note: future is not `Send` as this value is used across an await
  --> src/main.rs:13:34
   |
12 |         let log = Rc::new(msg); // Rc is not Send...
   |             --- has type `Rc<String>` which is not `Send`
13 |         tokio::task::yield_now().await; // ...and it's held across an await
   |                                  ^^^^^ await occurs here, with `log` maybe used later
note: required by a bound in `Notifier::notify::{anon_assoc#0}`
  --> src/main.rs:5:67
   |
 5 |     fn notify(&self, msg: String) -> impl Future<Output = bool> + Send;
   |                                                                   ^^^^ required by this bound in `Notifier::notify::{anon_assoc#0}`

10. dyn Trait doesn't work with async fn

Putting different implementations in one collection needs dyn Trait. But the compiler builds a vtable with one fixed signature per method, and "returns some future whose type depends on the implementation" can't be expressed that way:

Rust✗ does not compile · E0038
trait Handler {
    async fn handle(&self, path: &str) -> String;
}

struct Home;

impl Handler for Home {
    async fn handle(&self, _path: &str) -> String {
        String::from("home page")
    }
}

fn main() {
    let handlers: Vec<Box<dyn Handler>> = vec![Box::new(Home)];
    println!("{}", handlers.len());
}
Output
error[E0038]: the trait `Handler` is not dyn compatible
  --> src/main.rs:14:31
   |
14 |     let handlers: Vec<Box<dyn Handler>> = vec![Box::new(Home)];
   |                               ^^^^^^^ `Handler` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
      for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
  --> src/main.rs:2:14
   |
 1 | trait Handler {
   |       ------- this trait is not dyn compatible...
 2 |     async fn handle(&self, path: &str) -> String;
   |              ^^^^^^ ...because method `handle` is `async`
   = help: consider moving `handle` to another trait
   = help: only type `Home` implements `Handler`; consider using it directly instead.

The way to get "one type for all futures" is a boxed future.

11. Boxed futures make the trait dyn-compatible

Return Pin<Box<dyn Future<Output = T> + Send + 'a>>: a heap-allocated future behind a pointer. It has the same type for every implementation, so the trait works with dyn. Implementations wrap an async block in Box::pin.

Rust
use std::future::Future;
use std::pin::Pin;

trait Handler: Send + Sync {
    fn handle<'a>(&'a self, path: &'a str) -> Pin<Box<dyn Future<Output = String> + Send + 'a>>;
}

struct Home;
struct Echo {
    prefix: String,
}

impl Handler for Home {
    fn handle<'a>(&'a self, _path: &'a str) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
        Box::pin(async { String::from("home page") })
    }
}

impl Handler for Echo {
    fn handle<'a>(&'a self, path: &'a str) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
        Box::pin(async move {
            tokio::task::yield_now().await;
            format!("{}{path}", self.prefix)
        })
    }
}

#[tokio::main]
async fn main() {
    let handlers: Vec<Box<dyn Handler>> = vec![Box::new(Home), Box::new(Echo { prefix: String::from("you asked for ") })];
    for h in &handlers {
        println!("{}", h.handle("/docs").await);
    }
}
Output
home page
you asked for /docs

The cost is one heap allocation per call.

12. BoxFuture shortens the signature

The futures crate has an alias, BoxFuture<'a, T>, for exactly that long type, and a .boxed() method (from FutureExt) instead of Box::pin.

Rust
use futures::future::{BoxFuture, FutureExt};
use std::time::Duration;

trait Storage: Send + Sync {
    fn save<'a>(&'a self, key: &'a str, bytes: &'a [u8]) -> BoxFuture<'a, Result<usize, String>>;
}

struct Disk;
struct Memory;

impl Storage for Disk {
    fn save<'a>(&'a self, key: &'a str, bytes: &'a [u8]) -> BoxFuture<'a, Result<usize, String>> {
        async move {
            tokio::time::sleep(Duration::from_millis(5)).await;
            if key.contains('/') { Err(format!("disk: invalid key {key:?}")) } else { Ok(bytes.len()) }
        }
        .boxed()
    }
}

impl Storage for Memory {
    fn save<'a>(&'a self, _key: &'a str, bytes: &'a [u8]) -> BoxFuture<'a, Result<usize, String>> {
        async move { Ok(bytes.len()) }.boxed()
    }
}

#[tokio::main]
async fn main() {
    let backends: Vec<(&str, Box<dyn Storage>)> = vec![("disk", Box::new(Disk)), ("memory", Box::new(Memory))];
    for (name, backend) in &backends {
        println!("{name}: {:?}", backend.save("a/b", b"hello").await);
    }
}
Output
disk: Err("disk: invalid key \"a/b\"")
memory: Ok(5)

13. The async-trait crate

Writing boxed signatures by hand is tedious. The widely used async-trait crate lets you write async fn and rewrites it into the boxed form for you. Put #[async_trait] on the trait and on every impl.

Rust
use async_trait::async_trait;
use std::time::Duration;

#[async_trait]
trait PaymentGateway: Send + Sync {
    async fn charge(&self, cents: u64) -> Result<String, String>;

    fn name(&self) -> &'static str;
}

struct Stripe;
struct PayPal;

#[async_trait]
impl PaymentGateway for Stripe {
    async fn charge(&self, cents: u64) -> Result<String, String> {
        tokio::time::sleep(Duration::from_millis(5)).await;
        Ok(format!("ch_{cents}"))
    }

    fn name(&self) -> &'static str {
        "stripe"
    }
}

#[async_trait]
impl PaymentGateway for PayPal {
    async fn charge(&self, cents: u64) -> Result<String, String> {
        if cents > 50_000 { Err(String::from("amount exceeds limit")) } else { Ok(format!("PAY-{cents}")) }
    }

    fn name(&self) -> &'static str {
        "paypal"
    }
}

#[tokio::main]
async fn main() {
    let gateways: Vec<Box<dyn PaymentGateway>> = vec![Box::new(Stripe), Box::new(PayPal)];
    for g in &gateways {
        println!("{}: {:?}", g.name(), g.charge(99_900).await);
    }
}
Output
stripe: Ok("ch_99900")
paypal: Err("amount exceeds limit")
TOMLCargo.toml
[dependencies]
async-trait = "0.1"

The generated futures are Send by default; #[async_trait(?Send)] drops that requirement for single-threaded use. Choose between the options like this:

Need Use
only generics / concrete types native async fn in the trait
generics, and callers spawn the futures fn ... -> impl Future<Output = T> + Send
dyn Trait (plugins, collections, DI containers) boxed futures: #[async_trait] or BoxFuture by hand

14. Shared services: Arc<dyn Trait>

For dependency injection across tasks, the usual shape is Arc<dyn Service>: one instance, shared by every request handler, chosen at startup.

Rust
use async_trait::async_trait;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};

#[async_trait]
trait IdGenerator: Send + Sync {
    async fn next_id(&self) -> String;
}

struct SequentialIds {
    next: AtomicU32,
    prefix: &'static str,
}

#[async_trait]
impl IdGenerator for SequentialIds {
    async fn next_id(&self) -> String {
        format!("{}-{:04}", self.prefix, self.next.fetch_add(1, Ordering::Relaxed))
    }
}

async fn create_order(ids: Arc<dyn IdGenerator>, item: &str) -> String {
    format!("{} for {item}", ids.next_id().await)
}

#[tokio::main]
async fn main() {
    let ids: Arc<dyn IdGenerator> = Arc::new(SequentialIds { next: AtomicU32::new(1), prefix: "ORD" });

    let mut handles = Vec::new();
    for item in ["book", "lamp", "desk"] {
        let ids = Arc::clone(&ids);
        handles.push(tokio::spawn(async move { create_order(ids, item).await }));
    }
    let mut orders = Vec::new();
    for h in handles {
        orders.push(h.await.unwrap());
    }
    orders.sort(); // the tasks ran concurrently, so which item got which id varies
    let ids: Vec<&str> = orders.iter().map(|o| &o[..8]).collect();
    println!("{} orders with ids {ids:?}", orders.len());
}
Output
3 orders with ids ["ORD-0001", "ORD-0002", "ORD-0003"]

15. Associated types in async traits

Async traits can have associated types, like any trait. A common one is the error type, so each implementation can report its own errors.

Rust
use std::fmt::Debug;

trait Fetcher {
    type Error: Debug;
    async fn fetch(&self, url: &str) -> Result<String, Self::Error>;
}

#[derive(Debug)]
enum HttpError {
    NotFound,
    Status(u16),
}

struct Http;

impl Fetcher for Http {
    type Error = HttpError;
    async fn fetch(&self, url: &str) -> Result<String, HttpError> {
        match url {
            "/ok" => Ok(String::from("<html>ok</html>")),
            "/missing" => Err(HttpError::NotFound),
            _ => Err(HttpError::Status(500)),
        }
    }
}

struct Files;

impl Fetcher for Files {
    type Error = String;
    async fn fetch(&self, url: &str) -> Result<String, String> {
        Err(format!("no such file: {url}"))
    }
}

async fn fetch_all<F: Fetcher>(f: &F, urls: &[&str]) -> Vec<Result<String, F::Error>> {
    let mut out = Vec::new();
    for u in urls {
        out.push(f.fetch(u).await);
    }
    out
}

#[tokio::main]
async fn main() {
    println!("{:?}", fetch_all(&Http, &["/ok", "/missing", "/boom"]).await);
    println!("{:?}", fetch_all(&Files, &["/etc/app.toml"]).await);
}
Output
[Ok("<html>ok</html>"), Err(NotFound), Err(Status(500))]
[Err("no such file: /etc/app.toml")]

16. &mut self in async methods

Async methods can take &mut self, which fits stateful resources like a connection or a cursor. The future borrows the object mutably until it completes.

Rust
trait Connection {
    async fn execute(&mut self, sql: &str) -> Result<u64, String>;
    async fn close(self) -> usize;
}

struct FakeConn {
    statements: Vec<String>,
}

impl Connection for FakeConn {
    async fn execute(&mut self, sql: &str) -> Result<u64, String> {
        tokio::task::yield_now().await;
        if sql.trim().is_empty() {
            return Err(String::from("empty statement"));
        }
        self.statements.push(sql.to_string());
        Ok(self.statements.len() as u64)
    }

    async fn close(self) -> usize {
        self.statements.len()
    }
}

async fn migrate(conn: &mut impl Connection) -> Result<(), String> {
    conn.execute("CREATE TABLE users (id INT)").await?;
    conn.execute("CREATE INDEX idx_id ON users (id)").await?;
    Ok(())
}

#[tokio::main]
async fn main() {
    let mut conn = FakeConn { statements: Vec::new() };
    println!("{:?}", migrate(&mut conn).await);
    println!("{:?}", conn.execute("   ").await);
    println!("closed after {} statements", conn.close().await);
}
Output
Ok(())
Err("empty statement")
closed after 2 statements

17. A decorator that adds logging

A decorator wraps another implementation of the same trait. Generic decorators (Logged<T: Trait>) are resolved at compile time and cost nothing extra.

Rust
use std::cell::RefCell;

trait UserRepo {
    async fn find_name(&self, id: u32) -> Option<String>;
}

struct Db;

impl UserRepo for Db {
    async fn find_name(&self, id: u32) -> Option<String> {
        (id == 1).then(|| String::from("ana"))
    }
}

struct Logged<R> {
    inner: R,
    log: RefCell<Vec<String>>,
}

impl<R: UserRepo> UserRepo for Logged<R> {
    async fn find_name(&self, id: u32) -> Option<String> {
        let result = self.inner.find_name(id).await;
        self.log.borrow_mut().push(format!("find_name({id}) -> {}", if result.is_some() { "hit" } else { "miss" }));
        result
    }
}

#[tokio::main]
async fn main() {
    let repo = Logged { inner: Db, log: RefCell::new(Vec::new()) };
    println!("{:?} {:?}", repo.find_name(1).await, repo.find_name(2).await);
    println!("{:#?}", repo.log.borrow());
}
Output
Some("ana") None
[
    "find_name(1) -> hit",
    "find_name(2) -> miss",
]

18. A caching decorator

A decorator can also change behavior: this one caches results so the wrapped (slow) implementation is called once per key.

Rust
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;

trait PriceService {
    async fn price(&self, sku: &str) -> u32;
}

struct SlowCatalog {
    calls: AtomicU32,
}

impl PriceService for SlowCatalog {
    async fn price(&self, sku: &str) -> u32 {
        self.calls.fetch_add(1, Ordering::Relaxed);
        tokio::time::sleep(Duration::from_millis(20)).await;
        sku.len() as u32 * 100
    }
}

struct Cached<S> {
    inner: S,
    cache: Mutex<HashMap<String, u32>>,
}

impl<S: PriceService> PriceService for Cached<S> {
    async fn price(&self, sku: &str) -> u32 {
        if let Some(&p) = self.cache.lock().unwrap().get(sku) {
            return p; // guard dropped before any await
        }
        let p = self.inner.price(sku).await;
        self.cache.lock().unwrap().insert(sku.to_string(), p);
        p
    }
}

#[tokio::main]
async fn main() {
    let svc = Cached { inner: SlowCatalog { calls: AtomicU32::new(0) }, cache: Mutex::new(HashMap::new()) };
    let mut prices = Vec::new();
    for sku in ["pen", "notebook", "pen", "pen", "notebook"] {
        prices.push(svc.price(sku).await);
    }
    println!("{prices:?}");
    println!("backend calls: {}", svc.inner.calls.load(Ordering::Relaxed));
}
Output
[300, 800, 300, 300, 800]
backend calls: 2

19. A retry decorator with dynamic dispatch

With boxed futures, decorators can wrap a Box<dyn Trait> chosen at runtime, and be stacked in any order.

Rust
use async_trait::async_trait;
use std::sync::atomic::{AtomicU32, Ordering};

#[async_trait]
trait Sender: Send + Sync {
    async fn send(&self, msg: &str) -> Result<(), String>;
}

struct FlakySmtp {
    attempts: AtomicU32,
}

#[async_trait]
impl Sender for FlakySmtp {
    async fn send(&self, msg: &str) -> Result<(), String> {
        let n = self.attempts.fetch_add(1, Ordering::Relaxed) + 1;
        if n < 3 {
            return Err(format!("attempt {n}: SMTP timeout"));
        }
        println!("  delivered {msg:?} on attempt {n}");
        Ok(())
    }
}

struct Retry {
    inner: Box<dyn Sender>,
    max: u32,
}

#[async_trait]
impl Sender for Retry {
    async fn send(&self, msg: &str) -> Result<(), String> {
        let mut last_err = String::new();
        for _ in 0..self.max {
            match self.inner.send(msg).await {
                Ok(()) => return Ok(()),
                Err(e) => last_err = e,
            }
        }
        Err(format!("gave up: {last_err}"))
    }
}

#[tokio::main]
async fn main() {
    let sender: Box<dyn Sender> = Box::new(Retry { inner: Box::new(FlakySmtp { attempts: AtomicU32::new(0) }), max: 5 });
    println!("{:?}", sender.send("welcome!").await);

    let impatient: Box<dyn Sender> = Box::new(Retry { inner: Box::new(FlakySmtp { attempts: AtomicU32::new(0) }), max: 2 });
    println!("{:?}", impatient.send("welcome!").await);
}
Output
  delivered "welcome!" on attempt 3
Ok(())
Err("gave up: attempt 2: SMTP timeout")

20. Enum dispatch: dyn without boxing

If you know every implementation up front, an enum with a match in each method replaces dyn Trait: no boxing, no Send puzzles, and native async fn works.

Rust
use std::time::Duration;

enum BlobStore {
    Local { root: String },
    S3 { bucket: String },
    InMemory,
}

impl BlobStore {
    async fn put(&self, key: &str, data: &[u8]) -> String {
        match self {
            BlobStore::Local { root } => format!("wrote {} bytes to {root}/{key}", data.len()),
            BlobStore::S3 { bucket } => {
                tokio::time::sleep(Duration::from_millis(10)).await; // upload
                format!("uploaded {} bytes to s3://{bucket}/{key}", data.len())
            }
            BlobStore::InMemory => format!("kept {} bytes in memory", data.len()),
        }
    }
}

#[tokio::main]
async fn main() {
    let stores = [
        BlobStore::Local { root: String::from("/var/data") },
        BlobStore::S3 { bucket: String::from("backups") },
        BlobStore::InMemory,
    ];
    for s in &stores {
        println!("{}", s.put("report.pdf", &[0u8; 2048]).await);
    }
}
Output
wrote 2048 bytes to /var/data/report.pdf
uploaded 2048 bytes to s3://backups/report.pdf
kept 2048 bytes in memory

21. Async closures instead of one-method traits

A trait with a single async method is often just a function. An AsyncFn bound accepts any async closure, which is lighter than defining a trait and a struct.

Rust
async fn process_all<F>(items: &[&str], handler: F) -> Vec<String>
where
    F: AsyncFn(&str) -> Result<String, String>,
{
    let mut out = Vec::new();
    for item in items {
        out.push(match handler(item).await {
            Ok(v) => v,
            Err(e) => format!("error: {e}"),
        });
    }
    out
}

#[tokio::main]
async fn main() {
    let prefix = String::from("thumb_");
    let make_thumbnail = async |name: &str| {
        tokio::task::yield_now().await;
        if name.ends_with(".png") { Ok(format!("{prefix}{name}")) } else { Err(format!("{name} is not a PNG")) }
    };

    println!("{:?}", process_all(&["a.png", "b.gif", "c.png"], make_thumbnail).await);
}
Output
["thumb_a.png", "error: b.gif is not a PNG", "thumb_c.png"]

22. Testing code that uses an async trait

#[tokio::test] makes a test function async. Combined with a fake implementation of the trait, business logic can be tested without any network.

Rustcargo test
use std::collections::HashMap;
use std::sync::Mutex;

pub trait Inventory {
    async fn reserve(&self, sku: &str, qty: u32) -> Result<(), String>;
}

pub async fn place_order<I: Inventory>(inv: &I, lines: &[(&str, u32)]) -> Result<usize, String> {
    for (sku, qty) in lines {
        inv.reserve(sku, *qty).await.map_err(|e| format!("order rejected: {e}"))?;
    }
    Ok(lines.len())
}

#[cfg(test)]
mod tests {
    use super::*;

    struct FakeInventory {
        stock: Mutex<HashMap<&'static str, u32>>,
    }

    impl Inventory for FakeInventory {
        async fn reserve(&self, sku: &str, qty: u32) -> Result<(), String> {
            let mut stock = self.stock.lock().unwrap();
            let left = stock.get_mut(sku).ok_or(format!("unknown sku {sku}"))?;
            *left = left.checked_sub(qty).ok_or(format!("only {left} {sku} left"))?;
            Ok(())
        }
    }

    fn inventory() -> FakeInventory {
        FakeInventory { stock: Mutex::new(HashMap::from([("pen", 10), ("ink", 1)])) }
    }

    #[tokio::test]
    async fn places_order_when_in_stock() {
        assert_eq!(place_order(&inventory(), &[("pen", 3), ("ink", 1)]).await, Ok(2));
    }

    #[tokio::test]
    async fn rejects_when_out_of_stock() {
        let err = place_order(&inventory(), &[("ink", 2)]).await.unwrap_err();
        assert_eq!(err, "order rejected: only 1 ink left");
    }

    #[tokio::test]
    async fn rejects_unknown_items() {
        assert!(place_order(&inventory(), &[("laptop", 1)]).await.is_err());
    }
}
Output
running 3 tests
test tests::places_order_when_in_stock ... ok
test tests::rejects_unknown_items ... ok
test tests::rejects_when_out_of_stock ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

23. A generic service over several async traits

Services usually depend on more than one abstraction. Generic parameters keep everything statically dispatched.

Rust
use std::sync::Mutex;

trait Users {
    async fn email_of(&self, id: u32) -> Option<String>;
}

trait Mailer {
    async fn send(&self, to: &str, body: &str) -> Result<(), String>;
}

struct PasswordReset<U, M> {
    users: U,
    mailer: M,
}

impl<U: Users, M: Mailer> PasswordReset<U, M> {
    async fn request(&self, user_id: u32) -> Result<String, String> {
        let email = self.users.email_of(user_id).await.ok_or(format!("no user {user_id}"))?;
        let token = format!("tok{user_id:03}");
        self.mailer.send(&email, &format!("reset link: /reset/{token}")).await?;
        Ok(token)
    }
}

struct StaticUsers;
impl Users for StaticUsers {
    async fn email_of(&self, id: u32) -> Option<String> {
        (id == 7).then(|| String::from("ana@example.com"))
    }
}

struct OutboxMailer(Mutex<Vec<String>>);
impl Mailer for OutboxMailer {
    async fn send(&self, to: &str, body: &str) -> Result<(), String> {
        self.0.lock().unwrap().push(format!("{to}: {body}"));
        Ok(())
    }
}

#[tokio::main]
async fn main() {
    let svc = PasswordReset { users: StaticUsers, mailer: OutboxMailer(Mutex::new(Vec::new())) };
    println!("{:?}", svc.request(7).await);
    println!("{:?}", svc.request(8).await);
    println!("outbox: {:?}", svc.mailer.0.lock().unwrap());
}
Output
Ok("tok007")
Err("no user 8")
outbox: ["ana@example.com: reset link: /reset/tok007"]

24. Calling several dyn implementations concurrently

With boxed futures, you can start calls on every implementation and await them together with join_all. Here a message goes out on all configured notification channels at once.

Rust
use async_trait::async_trait;
use futures::future::join_all;
use std::sync::Arc;
use std::time::Duration;

#[async_trait]
trait Channel: Send + Sync {
    fn name(&self) -> &str;
    async fn deliver(&self, msg: &str) -> Result<(), String>;
}

struct Slack;
struct Sms {
    ms: u64,
}
struct Pager;

#[async_trait]
impl Channel for Slack {
    fn name(&self) -> &str {
        "slack"
    }
    async fn deliver(&self, _msg: &str) -> Result<(), String> {
        tokio::time::sleep(Duration::from_millis(30)).await;
        Ok(())
    }
}

#[async_trait]
impl Channel for Sms {
    fn name(&self) -> &str {
        "sms"
    }
    async fn deliver(&self, msg: &str) -> Result<(), String> {
        tokio::time::sleep(Duration::from_millis(self.ms)).await;
        if msg.len() > 20 { Err(format!("message too long ({} chars)", msg.len())) } else { Ok(()) }
    }
}

#[async_trait]
impl Channel for Pager {
    fn name(&self) -> &str {
        "pager"
    }
    async fn deliver(&self, _msg: &str) -> Result<(), String> {
        Err(String::from("no one on call"))
    }
}

#[tokio::main]
async fn main() {
    let channels: Vec<Arc<dyn Channel>> = vec![Arc::new(Slack), Arc::new(Sms { ms: 50 }), Arc::new(Pager)];
    let msg = "database is down in eu-west";

    let results = join_all(channels.iter().map(|c| c.deliver(msg))).await;
    for (c, r) in channels.iter().zip(results) {
        println!("{:<6} {r:?}", c.name());
    }
}
Output
slack  Ok(())
sms    Err("message too long (27 chars)")
pager  Err("no one on call")

25. Realistic: a router of async handlers

A tiny web-framework core: handlers implement an async trait, the router stores them as Box<dyn Handler> in a HashMap, and dispatches by path. This is roughly how axum and actix-web look underneath, minus a lot of generics.

Rust
use async_trait::async_trait;
use std::collections::HashMap;

struct Request {
    path: String,
    body: String,
}

#[derive(Debug)]
struct Response {
    status: u16,
    body: String,
}

#[async_trait]
trait Handler: Send + Sync {
    async fn call(&self, req: &Request) -> Response;
}

struct Health;
struct CreateUser;

#[async_trait]
impl Handler for Health {
    async fn call(&self, _req: &Request) -> Response {
        Response { status: 200, body: String::from("ok") }
    }
}

#[async_trait]
impl Handler for CreateUser {
    async fn call(&self, req: &Request) -> Response {
        tokio::task::yield_now().await; // would write to a database
        if req.body.contains('@') {
            Response { status: 201, body: format!("created {}", req.body) }
        } else {
            Response { status: 400, body: String::from("email required") }
        }
    }
}

struct Router {
    routes: HashMap<&'static str, Box<dyn Handler>>,
}

impl Router {
    fn new() -> Self {
        Router { routes: HashMap::new() }
    }

    fn route(mut self, path: &'static str, h: impl Handler + 'static) -> Self {
        self.routes.insert(path, Box::new(h));
        self
    }

    async fn dispatch(&self, req: Request) -> Response {
        match self.routes.get(req.path.as_str()) {
            Some(h) => h.call(&req).await,
            None => Response { status: 404, body: format!("no route for {}", req.path) },
        }
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", Health).route("/users", CreateUser);

    for (path, body) in [("/health", ""), ("/users", "ana@example.com"), ("/users", "ana"), ("/admin", "")] {
        let resp = app.dispatch(Request { path: path.into(), body: body.into() }).await;
        println!("{path:<8} -> {} {}", resp.status, resp.body);
    }
}
Output
/health  -> 200 ok
/users   -> 201 created ana@example.com
/users   -> 400 email required
/admin   -> 404 no route for /admin

26. Realistic: plugins selected from configuration

Which storage backend to use is decided at runtime from config. A factory returns Arc<dyn Store>, and the rest of the application never knows which one it got.

Rust
use futures::future::{BoxFuture, FutureExt};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

trait Store: Send + Sync {
    fn put<'a>(&'a self, key: &'a str, value: String) -> BoxFuture<'a, Result<(), String>>;
    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Option<String>>;
}

#[derive(Default)]
struct MemoryStore(Mutex<HashMap<String, String>>);

impl Store for MemoryStore {
    fn put<'a>(&'a self, key: &'a str, value: String) -> BoxFuture<'a, Result<(), String>> {
        async move {
            self.0.lock().unwrap().insert(key.to_string(), value);
            Ok(())
        }
        .boxed()
    }
    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Option<String>> {
        async move { self.0.lock().unwrap().get(key).cloned() }.boxed()
    }
}

struct ReadOnlyStore;

impl Store for ReadOnlyStore {
    fn put<'a>(&'a self, key: &'a str, _value: String) -> BoxFuture<'a, Result<(), String>> {
        async move { Err(format!("read-only: cannot write {key}")) }.boxed()
    }
    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Option<String>> {
        async move { (key == "motd").then(|| String::from("maintenance tonight")) }.boxed()
    }
}

fn store_from_config(kind: &str) -> Result<Arc<dyn Store>, String> {
    match kind {
        "memory" => Ok(Arc::new(MemoryStore::default())),
        "readonly" => Ok(Arc::new(ReadOnlyStore)),
        other => Err(format!("unknown store type {other:?}")),
    }
}

async fn app(store: Arc<dyn Store>) -> String {
    let write = store.put("motd", String::from("hello")).await;
    let read = store.get("motd").await;
    format!("write: {write:?}, read: {read:?}")
}

#[tokio::main]
async fn main() {
    for kind in ["memory", "readonly", "redis"] {
        match store_from_config(kind) {
            Ok(store) => println!("{kind}: {}", app(store).await),
            Err(e) => println!("{kind}: config error: {e}"),
        }
    }
}
Output
memory: write: Ok(()), read: Some("hello")
readonly: write: Err("read-only: cannot write motd"), read: Some("maintenance tonight")
redis: config error: unknown store type "redis"

27. Realistic: a job scheduler over a job trait

Jobs implement an async trait; a scheduler runs them concurrently on Tokio with a timeout each, and collects a report. Arc<dyn Job> lets each spawned task own a handle to its job.

Rust
use async_trait::async_trait;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinSet;

#[async_trait]
trait Job: Send + Sync {
    fn name(&self) -> &str;
    async fn run(&self) -> Result<String, String>;
}

struct Sleepy {
    name: &'static str,
    ms: u64,
}

struct Failing;

#[async_trait]
impl Job for Sleepy {
    fn name(&self) -> &str {
        self.name
    }
    async fn run(&self) -> Result<String, String> {
        tokio::time::sleep(Duration::from_millis(self.ms)).await;
        Ok(format!("done in {} ms", self.ms))
    }
}

#[async_trait]
impl Job for Failing {
    fn name(&self) -> &str {
        "import-csv"
    }
    async fn run(&self) -> Result<String, String> {
        Err(String::from("line 17: invalid date"))
    }
}

async fn run_all(jobs: Vec<Arc<dyn Job>>, limit: Duration) -> Vec<String> {
    let mut set = JoinSet::new();
    for job in jobs {
        set.spawn(async move {
            let outcome = match tokio::time::timeout(limit, job.run()).await {
                Ok(Ok(msg)) => format!("ok: {msg}"),
                Ok(Err(e)) => format!("failed: {e}"),
                Err(_) => String::from("timed out"),
            };
            format!("{:<12} {outcome}", job.name())
        });
    }
    let mut report = Vec::new();
    while let Some(line) = set.join_next().await {
        report.push(line.unwrap());
    }
    report.sort();
    report
}

#[tokio::main]
async fn main() {
    let jobs: Vec<Arc<dyn Job>> = vec![
        Arc::new(Sleepy { name: "send-digest", ms: 20 }),
        Arc::new(Failing),
        Arc::new(Sleepy { name: "reindex", ms: 500 }),
    ];
    for line in run_all(jobs, Duration::from_millis(100)).await {
        println!("{line}");
    }
}
Output
import-csv   failed: line 17: invalid date
reindex      timed out
send-digest  ok: done in 20 ms

28. Summary table

Situation Write
async methods, used with concrete types or generics async fn in the trait
same, but generic callers will tokio::spawn the futures fn m(&self) -> impl Future<Output = T> + Send
trait objects: Box<dyn T>, Arc<dyn T>, collections, plugins #[async_trait], or -> BoxFuture<'_, T> by hand
a closed set of implementations an enum with async fn methods
just one async operation to pass around an AsyncFn closure bound

Summary

  • async fn in traits works natively, with default methods, associated types, and &mut self. Generic callers get static dispatch and no allocations.
  • Each implementation returns its own future type. Consequence 1: generic code can't assume the future is Send; declare -> impl Future<Output = T> + Send in the trait when callers will spawn it.
  • Consequence 2: such traits aren't dyn-compatible. Box the futures (Pin<Box<dyn Future + Send + 'a>>, BoxFuture) or use #[async_trait], which does it for you.
  • Decorators, fakes for tests, and runtime-selected plugins all work, with the choice between generics (zero-cost) and dyn (flexible) made explicitly.
  • #[tokio::test] runs async tests.

Next: Async Streams: the async version of Iterator.