DEV Community

Cover image for Rust Concepts: dyn Trait, Custom Iterators, Deref/Drop & Axum REST API (Part 5)
mihir mohapatra
mihir mohapatra

Posted on

Rust Concepts: dyn Trait, Custom Iterators, Deref/Drop & Axum REST API (Part 5)

This is Part 5 of the Core Rust Concepts series.

  • Part 1 — Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching
  • Part 2 — Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await
  • Part 3 — Macros, Modules, Testing, Unsafe Rust, FFI
  • Part 4 — Threads, Channels, Send/Sync, Mutex, Atomics, clap

Table of Contents

  1. Trait Objects and Dynamic Dispatch
  2. The Deref Trait
  3. The Drop Trait
  4. Custom Iterators
  5. Building a REST API with Axum

25. Trait Objects and Dynamic Dispatch

In Part 2 we used impl Trait — that's static dispatch: the compiler knows the exact type at compile time and monomorphizes it. Sometimes you don't know the type until runtime. That's where trait objects (dyn Trait) come in.

Static vs Dynamic dispatch

trait Greet {
    fn hello(&self) -> String;
}

struct English;
struct Spanish;

impl Greet for English {
    fn hello(&self) -> String { String::from("Hello!") }
}

impl Greet for Spanish {
    fn hello(&self) -> String { String::from("¡Hola!") }
}

// Static dispatch — type known at compile time, zero overhead
fn greet_static(g: &impl Greet) {
    println!("{}", g.hello());
}

// Dynamic dispatch — type known at runtime via vtable
fn greet_dynamic(g: &dyn Greet) {
    println!("{}", g.hello());
}

fn main() {
    let e = English;
    let s = Spanish;

    greet_static(&e);
    greet_dynamic(&s);

    // Only dyn Trait allows mixing types in a collection
    let greeters: Vec<Box<dyn Greet>> = vec![
        Box::new(English),
        Box::new(Spanish),
        Box::new(English),
    ];

    for g in &greeters {
        println!("{}", g.hello());
    }
}
Enter fullscreen mode Exit fullscreen mode

When to use dyn Trait vs impl Trait

impl Trait dyn Trait
Dispatch Static (compile time) Dynamic (runtime vtable)
Performance Zero overhead Small indirection cost
Heterogeneous collections
Return from function ✅ (one concrete type) ✅ (any type at runtime)
Sized ❌ (must be behind & or Box)

Object-safe traits

Not every trait can be made into a trait object. A trait is object-safe if:

  • It has no methods that return Self
  • It has no generic methods
// ✅ Object-safe
trait Drawable {
    fn draw(&self);
    fn area(&self) -> f64;
}

// ❌ NOT object-safe — returns Self
trait Clone2 {
    fn clone2(&self) -> Self; // can't use as dyn Clone2
}
Enter fullscreen mode Exit fullscreen mode

Real-world pattern — plugin system

trait Plugin {
    fn name(&self) -> &str;
    fn run(&self, input: &str) -> String;
}

struct UppercasePlugin;
struct ReversePlugin;

impl Plugin for UppercasePlugin {
    fn name(&self) -> &str { "uppercase" }
    fn run(&self, input: &str) -> String { input.to_uppercase() }
}

impl Plugin for ReversePlugin {
    fn name(&self) -> &str { "reverse" }
    fn run(&self, input: &str) -> String { input.chars().rev().collect() }
}

struct Pipeline {
    plugins: Vec<Box<dyn Plugin>>,
}

impl Pipeline {
    fn new() -> Self { Self { plugins: vec![] } }

    fn add(mut self, p: Box<dyn Plugin>) -> Self {
        self.plugins.push(p);
        self
    }

    fn run(&self, input: &str) -> String {
        self.plugins.iter().fold(input.to_string(), |acc, p| p.run(&acc))
    }
}

fn main() {
    let pipeline = Pipeline::new()
        .add(Box::new(UppercasePlugin))
        .add(Box::new(ReversePlugin));

    println!("{}", pipeline.run("hello")); // "OLLEH"
}
Enter fullscreen mode Exit fullscreen mode

26. The Deref Trait

Deref controls what happens when you use the * operator on a type. It's also what enables deref coercions — Rust automatically calls deref() to convert types so you don't have to do it manually.

use std::ops::Deref;

// A simple smart pointer
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> { MyBox(x) }
}

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0 // return a reference to the inner value
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let x = MyBox::new(String::from("Rustacean"));

    // These are all equivalent due to deref coercions:
    hello(&x);           // MyBox<String> → &String → &str  (auto!)
    hello(&(*x)[..]);    // manual — what Rust does behind the scenes
    hello(x.deref());    // explicit deref
}
Enter fullscreen mode Exit fullscreen mode

Deref coercion chain

Rust automatically chains Deref calls as many times as needed:

MyBox<String>  →  String  →  str
    &MyBox<String>  →  &String  →  &str
Enter fullscreen mode Exit fullscreen mode
fn main() {
    let boxed = Box::new(String::from("hello"));

    // Box<String> derefs to String, then to str
    println!("{}", boxed.to_uppercase()); // HELLO
    println!("{}", boxed.len());          // 5

    let s: &str = &boxed; // Box<String> → &String → &str — all automatic
    println!("{s}");
}
Enter fullscreen mode Exit fullscreen mode

DerefMut

DerefMut does the same for mutable contexts:

use std::ops::{Deref, DerefMut};

struct Wrapper(Vec<i32>);

impl Deref for Wrapper {
    type Target = Vec<i32>;
    fn deref(&self) -> &Vec<i32> { &self.0 }
}

impl DerefMut for Wrapper {
    fn deref_mut(&mut self) -> &mut Vec<i32> { &mut self.0 }
}

fn main() {
    let mut w = Wrapper(vec![1, 2, 3]);
    w.push(4);                     // DerefMut lets you call Vec methods
    println!("{:?}", w.as_slice()); // Deref: [1, 2, 3, 4]
}
Enter fullscreen mode Exit fullscreen mode

27. The Drop Trait

Drop lets you run custom cleanup code when a value goes out of scope — like a destructor in C++. Rust calls drop() automatically; you never call it yourself (use std::mem::drop() to drop early).

struct Resource {
    name: String,
}

impl Resource {
    fn new(name: &str) -> Self {
        println!("acquiring: {name}");
        Resource { name: name.to_string() }
    }
}

impl Drop for Resource {
    fn drop(&mut self) {
        println!("releasing: {}", self.name);
    }
}

fn main() {
    let _a = Resource::new("database connection");
    let _b = Resource::new("file handle");
    println!("resources in use...");
    // Drop order is LIFO: _b dropped first, then _a
}
Enter fullscreen mode Exit fullscreen mode

Output:

acquiring: database connection
acquiring: file handle
resources in use...
releasing: file handle
releasing: database connection
Enter fullscreen mode Exit fullscreen mode

Dropping early

fn main() {
    let conn = Resource::new("db");
    println!("doing work...");

    drop(conn); // explicit early drop — calls Drop::drop()
    println!("conn already released");

    // conn.name; ← compile error: value was dropped
}
Enter fullscreen mode Exit fullscreen mode

Drop in practice — RAII pattern

use std::sync::{Arc, Mutex};

struct DbPool {
    connections: Arc<Mutex<Vec<String>>>,
}

struct DbConnection {
    id: String,
    pool: Arc<Mutex<Vec<String>>>,
}

impl Drop for DbConnection {
    fn drop(&mut self) {
        // Return the connection back to the pool automatically
        self.pool.lock().unwrap().push(self.id.clone());
        println!("connection '{}' returned to pool", self.id);
    }
}

impl DbPool {
    fn new() -> Self {
        let conns = vec!["conn-1".into(), "conn-2".into(), "conn-3".into()];
        Self { connections: Arc::new(Mutex::new(conns)) }
    }

    fn get(&self) -> Option<DbConnection> {
        let id = self.connections.lock().unwrap().pop()?;
        println!("checked out: {id}");
        Some(DbConnection { id, pool: Arc::clone(&self.connections) })
    }
}

fn main() {
    let pool = DbPool::new();
    {
        let _c1 = pool.get(); // "conn-3"
        let _c2 = pool.get(); // "conn-2"
    } // both returned to pool here automatically
    println!("pool size: {}", pool.connections.lock().unwrap().len()); // 3
}
Enter fullscreen mode Exit fullscreen mode

🦀 This is the RAII (Resource Acquisition Is Initialization) pattern — the same one Rust's MutexGuard, file handles, and socket wrappers use internally.


28. Custom Iterators

You can make any type iterable by implementing the Iterator trait, which requires only one method: next().

struct Counter {
    count: u32,
    max: u32,
}

impl Counter {
    fn new(max: u32) -> Self { Self { count: 0, max } }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.count < self.max {
            self.count += 1;
            Some(self.count)
        } else {
            None // signals the iterator is exhausted
        }
    }
}

fn main() {
    let counter = Counter::new(5);

    // You get ALL iterator methods for free!
    let sum: u32 = counter.sum();
    println!("sum: {sum}"); // 15

    // Chain with other iterators
    let result: Vec<u32> = Counter::new(5)
        .zip(Counter::new(5).skip(1))   // pair consecutive values
        .map(|(a, b)| a * b)            // multiply each pair
        .filter(|x| x % 3 == 0)        // keep multiples of 3
        .collect();

    println!("{:?}", result); // [6, 12]
}
Enter fullscreen mode Exit fullscreen mode

A real-world custom iterator — paginated results

struct Paginator {
    current_page: u32,
    total_pages: u32,
    page_size: u32,
}

impl Paginator {
    fn new(total_items: u32, page_size: u32) -> Self {
        Self {
            current_page: 0,
            total_pages: (total_items + page_size - 1) / page_size,
            page_size,
        }
    }
}

#[derive(Debug)]
struct Page {
    number: u32,
    offset: u32,
    limit: u32,
}

impl Iterator for Paginator {
    type Item = Page;

    fn next(&mut self) -> Option<Page> {
        if self.current_page < self.total_pages {
            self.current_page += 1;
            Some(Page {
                number: self.current_page,
                offset: (self.current_page - 1) * self.page_size,
                limit: self.page_size,
            })
        } else {
            None
        }
    }
}

fn main() {
    let pages = Paginator::new(95, 20);

    for page in pages {
        println!("Page {}: OFFSET {} LIMIT {}", page.number, page.offset, page.limit);
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Page 1: OFFSET 0 LIMIT 20
Page 2: OFFSET 20 LIMIT 20
Page 3: OFFSET 40 LIMIT 20
Page 4: OFFSET 60 LIMIT 20
Page 5: OFFSET 80 LIMIT 20
Enter fullscreen mode Exit fullscreen mode

IntoIterator — making structs work in for loops

struct Stack<T> {
    items: Vec<T>,
}

impl<T> Stack<T> {
    fn new() -> Self { Self { items: vec![] } }
    fn push(&mut self, item: T) { self.items.push(item); }
}

impl<T> IntoIterator for Stack<T> {
    type Item = T;
    type IntoIter = std::vec::IntoIter<T>;

    fn into_iter(self) -> Self::IntoIter {
        self.items.into_iter()
    }
}

fn main() {
    let mut stack = Stack::new();
    stack.push(1);
    stack.push(2);
    stack.push(3);

    for item in stack { // works because of IntoIterator
        println!("{item}");
    }
}
Enter fullscreen mode Exit fullscreen mode

29. Building a REST API with Axum

axum is the most popular async web framework in Rust, built on top of tokio and hyper. It uses Rust's type system to make routing and request extraction ergonomic and safe.

Setup

# Cargo.toml
[dependencies]
axum      = "0.7"
tokio     = { version = "1", features = ["full"] }
serde     = { version = "1", features = ["derive"] }
serde_json = "1"
uuid      = { version = "1", features = ["v4"] }
Enter fullscreen mode Exit fullscreen mode

Full CRUD API — Todo List

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::Json,
    routing::{delete, get, post, put},
    Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use uuid::Uuid;

// --- Models ---

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Todo {
    id: String,
    title: String,
    completed: bool,
}

#[derive(Deserialize)]
struct CreateTodo {
    title: String,
}

#[derive(Deserialize)]
struct UpdateTodo {
    title: Option<String>,
    completed: Option<bool>,
}

// --- Shared state ---

type Db = Arc<Mutex<Vec<Todo>>>;

// --- Handlers ---

async fn list_todos(State(db): State<Db>) -> Json<Vec<Todo>> {
    let todos = db.lock().unwrap();
    Json(todos.clone())
}

async fn create_todo(
    State(db): State<Db>,
    Json(payload): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
    let todo = Todo {
        id: Uuid::new_v4().to_string(),
        title: payload.title,
        completed: false,
    };
    db.lock().unwrap().push(todo.clone());
    (StatusCode::CREATED, Json(todo))
}

async fn get_todo(
    State(db): State<Db>,
    Path(id): Path<String>,
) -> Result<Json<Todo>, StatusCode> {
    let todos = db.lock().unwrap();
    todos
        .iter()
        .find(|t| t.id == id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

async fn update_todo(
    State(db): State<Db>,
    Path(id): Path<String>,
    Json(payload): Json<UpdateTodo>,
) -> Result<Json<Todo>, StatusCode> {
    let mut todos = db.lock().unwrap();
    let todo = todos.iter_mut().find(|t| t.id == id).ok_or(StatusCode::NOT_FOUND)?;

    if let Some(title) = payload.title { todo.title = title; }
    if let Some(completed) = payload.completed { todo.completed = completed; }

    Ok(Json(todo.clone()))
}

async fn delete_todo(
    State(db): State<Db>,
    Path(id): Path<String>,
) -> StatusCode {
    let mut todos = db.lock().unwrap();
    let before = todos.len();
    todos.retain(|t| t.id != id);

    if todos.len() < before { StatusCode::NO_CONTENT }
    else { StatusCode::NOT_FOUND }
}

// --- Router ---

fn app(db: Db) -> Router {
    Router::new()
        .route("/todos",      get(list_todos).post(create_todo))
        .route("/todos/:id",  get(get_todo).put(update_todo).delete(delete_todo))
        .with_state(db)
}

// --- Main ---

#[tokio::main]
async fn main() {
    let db: Db = Arc::new(Mutex::new(vec![]));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("listening on http://localhost:3000");

    axum::serve(listener, app(db)).await.unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Test it with curl

# Create todos
curl -s -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Rust"}' | jq

curl -s -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Build an API"}' | jq

# List all
curl -s http://localhost:3000/todos | jq

# Get one (replace ID)
curl -s http://localhost:3000/todos/<id> | jq

# Update
curl -s -X PUT http://localhost:3000/todos/<id> \
  -H "Content-Type: application/json" \
  -d '{"completed": true}' | jq

# Delete
curl -s -X DELETE http://localhost:3000/todos/<id>
Enter fullscreen mode Exit fullscreen mode

Adding middleware — request logging

use axum::middleware::{self, Next};
use axum::extract::Request;
use axum::response::Response;

async fn log_request(req: Request, next: Next) -> Response {
    let method = req.method().clone();
    let uri    = req.uri().clone();
    let res    = next.run(req).await;
    println!("{} {} → {}", method, uri, res.status());
    res
}

fn app(db: Db) -> Router {
    Router::new()
        .route("/todos",     get(list_todos).post(create_todo))
        .route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
        .layer(middleware::from_fn(log_request)) // add logging to all routes
        .with_state(db)
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Concept What it gives you
dyn Trait Runtime polymorphism, heterogeneous collections
Deref Transparent smart pointer ergonomics, auto-coercions
Drop RAII — deterministic cleanup, no GC needed
Custom iterators Any type can plug into Rust's iterator ecosystem
Axum Type-safe, async REST APIs on top of tokio

The full series

Part Topics
Part 1 Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching
Part 2 Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await
Part 3 Macros, Modules, Cargo, Testing, Unsafe, FFI
Part 4 Threads, Channels, Send/Sync, Mutex, Atomics, clap
Part 5 dyn Trait, Deref, Drop, Custom Iterators, Axum REST API

What's in Part 6?

  • Workspaces and multi-crate projects
  • serde — serialization deep dive
  • Benchmarking with criterion
  • Error handling patterns with thiserror and anyhow
  • Writing a real-world CLI + library crate

Found this useful? Drop a ❤️ and follow for Part 6!

Top comments (0)