Capítulo 8: Infrastructure - Persistencia SQLite con SQLx
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 enumLanese 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ística | SQLx | Diesel | SeaORM |
|---|---|---|---|
| Queries | Raw SQL | DSL propio | ActiveRecord |
| Async | Nativo | Requiere adapter | Nativo |
| Compile-time checks | Opcional (macro) | Sí | No |
| Curva de aprendizaje | Baja | Alta | Media |
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&strporque necesitamos pasarlo aparse_dt.Lane::try_from: convierte el string"todo"al enumLane::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 enCard. Si alguna falla,collect()retorna el primerErr.
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: retornaOption<Row>. Si esNone, convertimos aDomainError::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(): serializaDateTime<Utc>a ISO 8601 para almacenar como TEXT.card.lane.to_string(): convierte el enumLanea 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:
- Requiere una DB disponible durante
cargo build - Agrega complejidad al CI/CD
- 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érmino | Definición |
|---|---|
| SqlitePool | Pool de conexiones async a SQLite, gestiona reutilización de conexiones |
| bind | Parametrización de queries para evitar SQL injection |
| fetch_all | Ejecuta query y retorna todas las filas |
| fetch_optional | Ejecuta query y retorna Option<Row> |
| map_err | Transforma un tipo de error en otro |
| RFC 3339 | Formato estándar para representar fechas como texto |