Capítulo 8: Infrastructure - Persistencia SQLite con SQLx

Por: SiempreListo
rustwasmaxumleptos

Capítulo 8: Infrastructure - Persistencia SQLite con SQLx

Introducción

Hasta ahora tenemos dominio y casos de uso que dependen de un trait CardRepository. En este capítulo implementamos el adaptador concreto que conecta con SQLite usando SQLx.

Schema SQL

El archivo de migración define la tabla cards:

CREATE TABLE IF NOT EXISTS cards (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    lane TEXT NOT NULL DEFAULT 'todo',
    position INTEGER NOT NULL DEFAULT 0,
    completed BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

Por qué TEXT para id y timestamps

SQLite no tiene tipo nativo UUID ni DATETIME con timezone. Usamos:

  • id TEXT: almacenamos el UUID como string. Es legible, portable y evita problemas de endianness con BLOB.
  • created_at TEXT / updated_at TEXT: almacenamos en formato ISO 8601 (2026-02-15T10:30:00+00:00). SQLite puede ordenar strings ISO correctamente.
  • lane TEXT: el enum Lane se serializa como string ("todo", "doing", "done").
  • completed BOOLEAN: SQLite lo almacena como INTEGER (0/1), pero SQLx lo mapea transparentemente.

SQLx: queries async sin ORM

SQLx es una librería de queries SQL async para Rust. A diferencia de ORMs como Diesel o SeaORM:

CaracterísticaSQLxDieselSeaORM
QueriesRaw SQLDSL propioActiveRecord
AsyncNativoRequiere adapterNativo
Compile-time checksOpcional (macro)No
Curva de aprendizajeBajaAltaMedia

Elegimos SQLx porque escribimos SQL directo, sin capas de abstracción. Si sabes SQL, sabes SQLx.

Estructura del repositorio

apps/app-backend/src/infrastructure/persistence/
└── sqlite_card_repo.rs

Implementación completa

use chrono::{DateTime, Utc};
use sqlx::SqlitePool;

use crate::domain::entities::Card;
use crate::domain::errors::DomainError;
use crate::domain::ports::CardRepository;
use crate::domain::value_objects::{CardId, Lane};

Los imports traen:

  • SqlitePool: pool de conexiones async a SQLite.
  • DateTime<Utc>: tipo para timestamps con timezone UTC.
  • Las entidades y traits de nuestro dominio.

La struct SqliteCardRepo

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

impl SqliteCardRepo {
    pub fn new(pool: SqlitePool) -> Self {
        Self { pool }
    }
}

SqlitePool es un pool de conexiones que se comparte entre threads. Derivamos Clone porque Axum necesita clonar el estado para cada request. Clonar un pool es barato: solo copia el Arc interno.

Helpers de conversión

fn map_err(e: sqlx::Error) -> DomainError {
    DomainError::Persistence(e.to_string())
}

Convierte errores de SQLx en nuestro DomainError::Persistence. El dominio nunca conoce sqlx::Error.

fn parse_dt(s: &str) -> DateTime<Utc> {
    s.parse().unwrap_or_default()
}

Parsea strings ISO 8601 a DateTime<Utc>. Usamos unwrap_or_default() como fallback seguro (epoch 1970-01-01).

fn row_to_card(row: &sqlx::sqlite::SqliteRow) -> Result<Card, DomainError> {
    use sqlx::Row;
    let lane_str: String = row.get("lane");
    let lane = Lane::try_from(lane_str.as_str())
        .map_err(DomainError::InvalidLane)?;

    Ok(Card {
        id: CardId::from_string(row.get("id")),
        title: row.get("title"),
        description: row.get("description"),
        lane,
        position: row.get("position"),
        completed: row.get("completed"),
        created_at: parse_dt(row.get::<&str, _>("created_at")),
        updated_at: parse_dt(row.get::<&str, _>("updated_at")),
    })
}

Este helper mapea una fila SQLite a nuestra entidad Card:

  • row.get("column"): extrae el valor por nombre de columna. SQLx infiere el tipo de Rust.
  • row.get::<&str, _>("created_at"): especificamos &str porque necesitamos pasarlo a parse_dt.
  • Lane::try_from: convierte el string "todo" al enum Lane::Todo, propagando error si es inválido.

Implementación del trait CardRepository

find_all

async fn find_all(&self) -> Result<Vec<Card>, DomainError> {
    let rows = sqlx::query("SELECT * FROM cards ORDER BY lane, position")
        .fetch_all(&self.pool)
        .await
        .map_err(map_err)?;

    rows.iter().map(row_to_card).collect()
}
  • sqlx::query(sql): construye una query.
  • fetch_all(&self.pool): ejecuta y retorna todas las filas.
  • rows.iter().map(row_to_card).collect(): transforma cada fila en Card. Si alguna falla, collect() retorna el primer Err.

find_by_id

async fn find_by_id(&self, id: &CardId) -> Result<Card, DomainError> {
    let row = sqlx::query("SELECT * FROM cards WHERE id = ?")
        .bind(id.as_str())
        .fetch_optional(&self.pool)
        .await
        .map_err(map_err)?
        .ok_or_else(|| DomainError::CardNotFound(id.to_string()))?;

    row_to_card(&row)
}
  • .bind(id.as_str()): parametriza el ? con el valor del ID. Previene SQL injection.
  • fetch_optional: retorna Option<Row>. Si es None, convertimos a DomainError::CardNotFound.

save

async fn save(&self, card: &Card) -> Result<(), DomainError> {
    let created = card.created_at.to_rfc3339();
    let updated = card.updated_at.to_rfc3339();
    sqlx::query(
        "INSERT INTO cards (id, title, description, lane, position, completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
    )
    .bind(card.id.as_str())
    .bind(&card.title)
    .bind(&card.description)
    .bind(card.lane.to_string())
    .bind(card.position)
    .bind(card.completed)
    .bind(&created)
    .bind(&updated)
    .execute(&self.pool)
    .await
    .map_err(map_err)?;

    Ok(())
}
  • to_rfc3339(): serializa DateTime<Utc> a ISO 8601 para almacenar como TEXT.
  • card.lane.to_string(): convierte el enum Lane a string.
  • .execute(): ejecuta sin retornar filas (INSERT no necesita resultados).

update

async fn update(&self, card: &Card) -> Result<(), DomainError> {
    let updated = card.updated_at.to_rfc3339();
    sqlx::query(
        "UPDATE cards SET title=?, description=?, lane=?, position=?, completed=?, updated_at=? WHERE id=?"
    )
    .bind(&card.title)
    .bind(&card.description)
    .bind(card.lane.to_string())
    .bind(card.position)
    .bind(card.completed)
    .bind(&updated)
    .bind(card.id.as_str())
    .execute(&self.pool)
    .await
    .map_err(map_err)?;

    Ok(())
}

Notar que id va al final del .bind() porque en el SQL el WHERE id=? es el último parámetro.

delete

async fn delete(&self, id: &CardId) -> Result<(), DomainError> {
    sqlx::query("DELETE FROM cards WHERE id = ?")
        .bind(id.as_str())
        .execute(&self.pool)
        .await
        .map_err(map_err)?;

    Ok(())
}

La operación más simple: un DELETE parametrizado.

Raw SQL vs query macro

SQLx ofrece la macro sqlx::query!() que valida SQL en tiempo de compilación contra una base de datos real. No la usamos porque:

  1. Requiere una DB disponible durante cargo build
  2. Agrega complejidad al CI/CD
  3. Nuestras queries son simples y el raw SQL es más explícito

Para proyectos con queries complejas, la macro es una excelente opción.

Glosario

TérminoDefinición
SqlitePoolPool de conexiones async a SQLite, gestiona reutilización de conexiones
bindParametrización de queries para evitar SQL injection
fetch_allEjecuta query y retorna todas las filas
fetch_optionalEjecuta query y retorna Option<Row>
map_errTransforma un tipo de error en otro
RFC 3339Formato estándar para representar fechas como texto