Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tutorial 04: Data Mapper & Repository Pattern 🏗️

For domain-heavy code, Rullst exposes a small Repository<T> contract. The framework does not invent SQL for this trait: your adapter owns its pool, queries, transactions, and error type. This keeps persistence behavior explicit and makes an in-memory implementation straightforward in unit tests.

GenericRepository<T> is currently only a zero-state marker/helper. It does not accept a pool and does not provide methods such as find_one_by.


Step 1: Implement a PostgreSQL repository

#![allow(unused)]
fn main() {
use rullst_orm::{async_trait, sqlx, FromRow, Repository};

#[derive(Debug, Clone, FromRow)]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
}

#[derive(Clone)]
pub struct PgUserRepository {
    pool: sqlx::PgPool,
}

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

    pub async fn find_by_email(
        &self,
        email: &str,
    ) -> Result<Option<User>, sqlx::Error> {
        sqlx::query_as::<_, User>(
            "SELECT id, name, email FROM users WHERE email = $1",
        )
        .bind(email)
        .fetch_optional(&self.pool)
        .await
    }
}

#[async_trait]
impl Repository<User> for PgUserRepository {
    type Id = i64;
    type Error = sqlx::Error;

    async fn find_by_id(&self, id: i64) -> Result<Option<User>, Self::Error> {
        sqlx::query_as::<_, User>(
            "SELECT id, name, email FROM users WHERE id = $1",
        )
        .bind(id)
        .fetch_optional(&self.pool)
        .await
    }

    async fn find_all(&self) -> Result<Vec<User>, Self::Error> {
        sqlx::query_as::<_, User>(
            "SELECT id, name, email FROM users ORDER BY id",
        )
        .fetch_all(&self.pool)
        .await
    }

    async fn save(&self, user: &User) -> Result<(), Self::Error> {
        sqlx::query(
            "INSERT INTO users (id, name, email) VALUES ($1, $2, $3) \
             ON CONFLICT (id) DO UPDATE SET name = $2, email = $3",
        )
        .bind(user.id)
        .bind(&user.name)
        .bind(&user.email)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn delete(&self, id: i64) -> Result<(), Self::Error> {
        sqlx::query("DELETE FROM users WHERE id = $1")
            .bind(id)
            .execute(&self.pool)
            .await?;
        Ok(())
    }
}
}

All values are parameterized. SQL identifiers and the query shape remain application-owned source code.


Step 2: Inject the concrete adapter through Axum state

use std::sync::Arc;
use axum::{extract::State, http::StatusCode, Json};

pub async fn get_user_by_email(
    State(repository): State<Arc<PgUserRepository>>,
    email: String,
) -> Result<Json<User>, StatusCode> {
    repository
        .find_by_email(&email)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

Production handlers should map internal database errors to an application error without returning query or credential details to clients.


Key takeaways

  • Use derived Active Record models for direct typed CRUD.
  • Use Repository<T> when the domain needs an explicit persistence boundary.
  • The repository implementation, not the trait, determines the supported backend and SQL dialect.
  • Add tenant/owner predicates inside repository queries where the resource is tenant- or user-scoped; dependency injection is not an authorization check.