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.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
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.
handler โ PostRepo โ SQLite (or a fake in tests)2. src/repo.rs: the trait
Create src/repo.rs:
touch src/repo.rsThe 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:
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:
use crate::repo::PostRepo;
use std::sync::Arc;
pub type Db = Arc<dyn PostRepo>;5. Error E0038
cargo builderror[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:
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:
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:
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):
cargo sqlx prepare
cargo runCreate two posts:
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:
curl -i -X PUT localhost:8080/posts/1 -H 'content-type: application/json' -d '{"title":"Hello again","body":"Edited"}'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:
curl -i -X DELETE localhost:8080/posts/2
curl -i -X DELETE localhost:8080/posts/2HTTP/1.1 204 No ContentHTTP/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
PostRepotrait withSendfutures: the handlers say what they need, not how it is done SqlitePostRepoholds the pool and has all five queriesArc<dyn PostRepo>fails with E0038:impl Futurein a trait is not dyn compatible- generic handlers (
R: PostRepo) and a generic routerapp::<R>instead cargo sqlx prepareafter 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.