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

This User is a plain struct, not a #[derive(Orm)] model (which implements FromRow itself), so it derives SQLx’s FromRow. Rullst does not enable SQLx’s derive macros (since 13.0.0-alpha.4): add them to your manifest with sqlx = { version = "0.9", default-features = false, features = ["derive"] }, or implement FromRow by hand.

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

#[derive(Debug, Clone, sqlx::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.

This adapter targets PostgreSQL (PgPool, $1 placeholders) and a users table with BIGINT ids. Inside a generated project, rullst::db::Orm::pool()? returns the application’s pool as RullstPool, which is sqlx::SqlitePool with the SQLite starter and sqlx::PgPool with PostgreSQL: clone it into the adapter and write the queries for that database.


Step 2: Inject the concrete adapter through Axum state

use std::sync::Arc;
use rullst::web::axum::{
    extract::{Query, State},
    http::StatusCode,
    Json,
};
use serde::Deserialize;

#[derive(Deserialize)]
pub struct EmailQuery {
    email: String,
}

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

rullst::Router has no with_state: build this route on an axum::Router, call .with_state(Arc::new(repository)) and mount it with merge_axum, as Tutorial 08 shows. Returning Json<User> also requires User to derive serde::Serialize. 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.