Skip to content
rusthacks.dev
Databases

Repository Pattern in Rust and Whether You Need It

A repository hides the database behind a trait. See when that helps (testing, swapping stores) and when it only adds indirection, with a concrete trait, an SQLx impl and an in-memory fake.

3 min read
Summarize in AI
Flat illustration for the post “Repository Pattern in Rust and Whether You Need It”, composed around traits.

The repository pattern turns "load and save things" into a trait. Whether that is worth a layer depends on what is above it. Everything compiles on Rust 1.90 with edition 2024, sqlx 0.8 and tokio 1; traits use native async fn.

The trait

rust
#[derive(Debug, Clone, PartialEq)]
pub struct User {
    pub id: u64,
    pub email: String,
}

#[derive(Debug)]
pub enum RepoError {
    Duplicate,
    Other(String),
}

pub trait UserRepo {
    async fn find_by_email(&self, email: &str) -> Result<Option<User>, RepoError>;
    async fn insert(&self, email: &str) -> Result<User, RepoError>;
}

The trait speaks the domain: a User, a Duplicate error. Nothing mentions rows, SQL or sqlx::Error. That separation is the reason to have it.

A database implementation

rust
use sqlx::PgPool;

pub struct PgUserRepo {
    pool: PgPool,
}

impl PgUserRepo {
    pub fn new(pool: PgPool) -> Self { Self { pool } }
}

impl UserRepo for PgUserRepo {
    async fn find_by_email(&self, email: &str) -> Result<Option<User>, RepoError> {
        let row: Option<(i64, String)> =
            sqlx::query_as("SELECT id, email FROM users WHERE email = $1")
                .bind(email)
                .fetch_optional(&self.pool)
                .await
                .map_err(|e| RepoError::Other(e.to_string()))?;
        Ok(row.map(|(id, email)| User { id: id as u64, email }))
    }

    async fn insert(&self, email: &str) -> Result<User, RepoError> {
        let res: Result<(i64,), sqlx::Error> =
            sqlx::query_as("INSERT INTO users (email) VALUES ($1) RETURNING id")
                .bind(email)
                .fetch_one(&self.pool)
                .await;
        match res {
            Ok((id,)) => Ok(User { id: id as u64, email: email.to_string() }),
            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(RepoError::Duplicate),
            Err(e) => Err(RepoError::Other(e.to_string())),
        }
    }
}

SQL errors are translated at the boundary, so callers handle Duplicate without knowing it came from a unique index.

An in-memory fake

rust
use std::sync::Mutex;

#[derive(Default)]
pub struct MemUserRepo {
    users: Mutex<Vec<User>>,
}

impl UserRepo for MemUserRepo {
    async fn find_by_email(&self, email: &str) -> Result<Option<User>, RepoError> {
        Ok(self.users.lock().unwrap().iter().find(|u| u.email == email).cloned())
    }

    async fn insert(&self, email: &str) -> Result<User, RepoError> {
        let mut users = self.users.lock().unwrap();
        if users.iter().any(|u| u.email == email) {
            return Err(RepoError::Duplicate);
        }
        let user = User { id: users.len() as u64 + 1, email: email.to_string() };
        users.push(user.clone());
        Ok(user)
    }
}

The domain code that uses it

rust
pub async fn register<R: UserRepo>(repo: &R, email: &str) -> Result<User, String> {
    if !email.contains('@') {
        return Err("invalid email".into());
    }
    if repo.find_by_email(email).await.map_err(|e| format!("{e:?}"))?.is_some() {
        return Err("email taken".into());
    }
    repo.insert(email).await.map_err(|e| format!("{e:?}"))
}

#[tokio::test]
async fn rejects_duplicates() {
    let repo = MemUserRepo::default();
    register(&repo, "[email protected]").await.unwrap();
    assert!(register(&repo, "[email protected]").await.is_err());
}

A generic R: UserRepo gives static dispatch and no allocation. If you need to choose the implementation at run time, Arc<dyn UserRepo> requires a dyn compatible trait, and native async fn in traits is not dyn compatible; use the async_trait macro or return boxed futures (async traits then and now, dyn compatibility).

When it is overhead

The fake above checks the logic and nothing about SQL. It does not know about the unique index, transaction isolation or a typo in a query. Many teams find that tests against a real database (database tests) catch more with less code, and that a repository which maps methods one-to-one onto queries is a layer to delete. Keep it when the domain logic is rich, when there are two stores, or when you want the compiler to enforce that business code never touches SQL (hexagonal architecture).

Middle path

Functions over &PgPool grouped in a db module give most of the structure with none of the traits (Axum and SQLx). Introduce the trait at the first moment a test actually needs the fake.

Taggedpatterns
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.