M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 11 of 42

Rust Web Server with Axum 0.8: Full CRUD with a Repository Trait

November 3, 2026

Episode 11 of the Rust Backend series, where we build the backend of a blog step by step. After episode 10 our handlers talk to SQLite directly with query_as!, and update and delete are still switched off. Today: all of CRUD on the database, and a PostRepo trait, so the handlers do not care which database is behind it. On the way we try Arc<dyn PostRepo>, hit error E0038, and use generics instead. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episode 10 (axum 0.8.9, sqlx 0.9 with SQLite, migrations in migrations/, the .sqlx/ query cache). No new crates today.

1. Why a trait

Later we write tests with a fake repository, and maybe switch to PostgreSQL. The handlers should only say what they need: list, get, create, update, delete.

text
handler โ†’ PostRepo โ†’ SQLite (or a fake in tests)

2. src/repo.rs: the trait

Create src/repo.rs:

bash
touch src/repo.rs

The trait PostRepo lists five methods. Each returns a future, and that future must be Send, because axum runs handlers on many threads. The trait also requires Clone + Send + Sync + 'static: that is what axum needs from state.

3. SqlitePostRepo

SqlitePostRepo holds the pool. Its impl writes the methods as async fn, with our queries from last time. update is UPDATE ... RETURNING with fetch_optional again: no row means no such post. delete uses rows_affected() to tell us whether a row was there. The final src/repo.rs:

rust
use std::future::Future;

use sqlx::SqlitePool;

use crate::models::{NewPost, Post};

pub trait PostRepo: Clone + Send + Sync + 'static {
    fn list(&self) -> impl Future<Output = sqlx::Result<Vec<Post>>> + Send;
    fn get(&self, id: i64) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
    fn create(&self, new: NewPost) -> impl Future<Output = sqlx::Result<Post>> + Send;
    fn update(&self, id: i64, new: NewPost) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
    fn delete(&self, id: i64) -> impl Future<Output = sqlx::Result<bool>> + Send;
}

#[derive(Clone)]
pub struct SqlitePostRepo {
    pub pool: SqlitePool,
}

impl PostRepo for SqlitePostRepo {
    async fn list(&self) -> sqlx::Result<Vec<Post>> {
        sqlx::query_as!(Post, "SELECT id, title, body, created_at FROM posts ORDER BY id")
            .fetch_all(&self.pool)
            .await
    }

    async fn get(&self, id: i64) -> sqlx::Result<Option<Post>> {
        sqlx::query_as!(Post, "SELECT id, title, body, created_at FROM posts WHERE id = ?", id)
            .fetch_optional(&self.pool)
            .await
    }

    async fn create(&self, new: NewPost) -> sqlx::Result<Post> {
        sqlx::query_as!(
            Post,
            r#"INSERT INTO posts (title, body) VALUES (?, ?) RETURNING id, title, body, created_at"#,
            new.title,
            new.body
        )
        .fetch_one(&self.pool)
        .await
    }

    async fn update(&self, id: i64, new: NewPost) -> sqlx::Result<Option<Post>> {
        sqlx::query_as!(
            Post,
            r#"UPDATE posts SET title = ?, body = ? WHERE id = ? RETURNING id, title, body, created_at"#,
            new.title,
            new.body,
            id
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn delete(&self, id: i64) -> sqlx::Result<bool> {
        let result = sqlx::query!("DELETE FROM posts WHERE id = ?", id)
            .execute(&self.pool)
            .await?;
        Ok(result.rows_affected() > 0)
    }
}

4. First idea: Arc<dyn PostRepo>

Add mod repo; to main.rs and make the state hold any repository at all, in src/models.rs:

rust
use crate::repo::PostRepo;
use std::sync::Arc;

pub type Db = Arc<dyn PostRepo>;

5. Error E0038

bash
cargo build
text
error[E0038]: the trait `PostRepo` is not dyn compatible
  --> src/handlers.rs:16:22
   |
16 |     State(db): State<Db>,
   |                      ^^ `PostRepo` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
  --> src/repo.rs:7:21
   |
 7 | pub trait PostRepo: Clone + Send + Sync + 'static {
   |           --------  ^^^^^ ...because it requires `Self: Sized`

The compiler points at Clone, which needs a known size. And methods that return impl Future break dyn too: every implementation has its own hidden future type.

6. Generics instead

Each handler gets a type parameter R that implements PostRepo, and takes State<R>. The Db type is removed from models.rs. The handlers get short: check, call the repository, wrap the answer. No SQL is left in them. The final src/handlers.rs:

rust
use axum::extract::{rejection::JsonRejection, Path, State};
use axum::{http::StatusCode, Json};

use crate::error::ApiError;
use crate::models::{NewPost, Post};
use crate::repo::PostRepo;

fn check(new: &NewPost) -> Result<(), ApiError> {
    if new.title.trim().is_empty() {
        return Err(ApiError::EmptyTitle);
    }
    Ok(())
}

#[tracing::instrument(skip(repo, payload))]
pub async fn create_post<R: PostRepo>(
    State(repo): State<R>,
    payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<(StatusCode, Json<Post>), ApiError> {
    let Json(new) = payload?;
    check(&new)?;
    let post = repo.create(new).await?;
    tracing::debug!(id = post.id, "created");
    Ok((StatusCode::CREATED, Json(post)))
}

pub async fn get_post<R: PostRepo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<Json<Post>, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn list_posts<R: PostRepo>(State(repo): State<R>) -> Result<Json<Vec<Post>>, ApiError> {
    let posts = repo.list().await?;
    Ok(Json(posts))
}

pub async fn update_post<R: PostRepo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
    payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<Json<Post>, ApiError> {
    let Json(new) = payload?;
    check(&new)?;
    let post = repo.update(id, new).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn delete_post<R: PostRepo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
    if !repo.delete(id).await? {
        return Err(ApiError::NotFound(id));
    }
    Ok(StatusCode::NO_CONTENT)
}

7. The router is generic too

The router moves into a function, app, generic over R as well. main calls app with SqlitePostRepo:

rust
pub fn app<R: PostRepo>(repo: R) -> Router {
    Router::new()
        .route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
        .route(
            "/posts/{id}",
            get(handlers::get_post::<R>).put(handlers::update_post::<R>).delete(handlers::delete_post::<R>),
        )
        .with_state(repo)
        .layer(TraceLayer::new_for_http())
}

// in main():
let app = app(SqlitePostRepo { pool });

The final src/main.rs:

rust
mod config;
mod error;
mod handlers;
mod models;
mod repo;

use axum::{routing::get, Router};
use repo::{PostRepo, SqlitePostRepo};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;

pub fn app<R: PostRepo>(repo: R) -> Router {
    Router::new()
        .route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
        .route(
            "/posts/{id}",
            get(handlers::get_post::<R>).put(handlers::update_post::<R>).delete(handlers::delete_post::<R>),
        )
        .with_state(repo)
        .layer(TraceLayer::new_for_http())
}

#[tokio::main]
async fn main() {
    let cfg = config::config();
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
        .init();
    let addr = format!("{}:{}", cfg.host, cfg.port);
    let options = SqliteConnectOptions::from_str(&cfg.database_url)
        .unwrap()
        .create_if_missing(true)
        .journal_mode(SqliteJournalMode::Wal);
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect_with(options)
        .await
        .unwrap();
    sqlx::migrate!().run(&pool).await.unwrap();
    let app = app(SqlitePostRepo { pool });

    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    tracing::info!(%addr, "listening");
    axum::serve(listener, app).await.unwrap();
}

8. Prepare, run and try it

New queries, so run cargo sqlx prepare first (the query cache in .sqlx/ must match):

bash
cargo sqlx prepare
cargo run

Create two posts:

bash
curl -i localhost:8080/posts -H 'content-type: application/json' -d '{"title":"Hello","body":"First post"}'
curl -i localhost:8080/posts -H 'content-type: application/json' -d '{"title":"Second","body":"Another one"}'

Update post 1:

bash
curl -i -X PUT localhost:8080/posts/1 -H 'content-type: application/json' -d '{"title":"Hello again","body":"Edited"}'
text
HTTP/1.1 200 OK
content-type: application/json
content-length: 81

{"id":1,"title":"Hello again","body":"Edited","created_at":"2026-10-30 00:39:55"}

Delete post 2, twice:

bash
curl -i -X DELETE localhost:8080/posts/2
curl -i -X DELETE localhost:8080/posts/2
text
HTTP/1.1 204 No Content
text
HTTP/1.1 404 Not Found
content-type: application/json
content-length: 28

{"error":"post 2 not found"}

List: only post 1, edited. After a restart of the server it is still there.

Recap

  • a PostRepo trait with Send futures: the handlers say what they need, not how it is done
  • SqlitePostRepo holds the pool and has all five queries
  • Arc<dyn PostRepo> fails with E0038: impl Future in a trait is not dyn compatible
  • generic handlers (R: PostRepo) and a generic router app::<R> instead
  • cargo sqlx prepare after new queries

Next episode: comments on posts: a second table with a foreign key, and what happens when a comment points at a post that does not exist. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam