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

entity-derive

Una macro para gobernarlos a todos

English Русский 한국어 Español 中文


¿Qué es?

entity-derive es una macro procedural de Rust que genera una capa de dominio completa a partir de una única definición de entidad. No es solo CRUD — es un framework arquitectónico con eventos, hooks, comandos y filtrado type-safe.


El Problema

Un backend Rust típico con ~10 entidades significa:

ComponenteLíneas de CódigoProblemas
DTOs (Create, Update, Response)~60 por entidadSincronización manual, campos olvidados
Repository trait + impl~150 por entidadErrores SQL en runtime, copy-paste
Mapeo Entity ↔ DTO~40 por entidadFugas de datos (password_hash en Response)
Validación y hooksDispersos en serviciosDuplicación, sin fuente única
Eventos/auditoríaAusentes o ad-hocSin historial de cambios

Total: ~2500 líneas de boilerplate para 10 entidades. Y cada cambio de esquema requiere ediciones manuales en 5+ lugares.


La Solución

#[derive(Entity)]
#[entity(table = "users", events, hooks, commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    #[filter(like)]
    pub email: String,

    #[field(skip)]  // Nunca se filtra a la API
    pub password_hash: String,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,
}

15 líneas → capa de dominio completa:

  • CreateUserRequest, UpdateUserRequest, UserResponse
  • UserRepository con SQL type-safe
  • UserEvent::Created, Updated, Deleted
  • UserHooks para lógica de negocio
  • Comandos RegisterUser, DeactivateUser
  • UserQuery para filtrado

Simple para Principiantes

Ejemplo mínimo — 10 líneas:

#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub title: String,

    #[field(create, update, response)]
    pub content: String,
}

Listo. Tienes:

  • CreatePostRequest, UpdatePostRequest, PostResponse
  • PostRepository con create(), find_by_id(), update(), delete(), list()
  • SQL type-safe para PostgreSQL
  • Todo funciona de inmediato

Sin magia. Ejecuta cargo expand — verás exactamente el código que escribirías tú mismo. Solo que sin errores y en segundos.


¿Por qué Eventos?

Problema: Las aplicaciones CRUD no tienen historial. ¿Quién cambió el registro? ¿Cuándo? ¿Qué había antes? La auditoría requiere infraestructura separada y disciplina.

Solución: #[entity(events)] genera eventos tipados:

pub enum UserEvent {
    Created(User),
    Updated { id: Uuid, changes: UpdateUserRequest },
    Deleted(Uuid),
}

Beneficios:

  • Auditoría de serie — suscríbete a eventos, guarda en log
  • Event Sourcing — puedes restaurar estado desde historial de eventos
  • Integraciones — Kafka, notificaciones WebSocket, invalidación de caché
  • Depuración — historial completo de cambios para cada entidad

¿Por qué Hooks?

Problema: La lógica de negocio está dispersa. Validación de email — en controlador. Hashing de contraseña — en servicio. Envío de email — en worker separado. ¿Dónde buscar la lógica de creación de usuario?

Solución: #[entity(hooks)] centraliza el ciclo de vida:

impl UserHooks for MyHooks {
    async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Error> {
        dto.email = dto.email.to_lowercase();  // Normalización
        validate_email(&dto.email)?;            // Validación
        Ok(())
    }

    async fn after_create(&self, user: &User) -> Result<(), Error> {
        self.mailer.send_welcome(user).await?;  // Acción de negocio
        Ok(())
    }
}

Beneficios:

  • Lugar único — toda la lógica de entidad junto a la definición
  • Predecibilidad — claro cuándo se ejecuta qué
  • Testabilidad — los hooks se pueden mockear y probar aisladamente
  • Composición — diferentes implementaciones para diferentes contextos

¿Por qué Comandos?

Problema: La API REST oculta la intención. POST /users — ¿es registro? ¿Creación por admin? ¿Importación CSV? PATCH /users/123 — ¿desactivación? ¿Cambio de email? ¿Ban?

Solución: #[command(...)] expresa el dominio de negocio:

#[command(Register)]           // Auto-registro
#[command(Invite)]             // Invitación por admin
#[command(Deactivate, requires_id)]  // Desactivación de cuenta
#[command(Ban, requires_id)]   // Ban por violaciones

Compara:

// CRUD (¿qué está pasando?)
pool.update(user_id, UpdateUserRequest { active: Some(false), ..default() }).await?;

// Comandos (intención clara)
handler.handle(DeactivateUser { id: user_id }).await?;

Beneficios:

  • API auto-documentada — nombres de comandos = vocabulario de negocio
  • Lógica diferenteDeactivate y Ban pueden tener diferentes side-effects
  • CQRS listo — comandos fáciles de enrutar, loguear, reintentar
  • Type safety — el compilador verifica que el comando existe

¿Por qué Filtrado Type-Safe?

Problema: Los query params de string son fuente de errores en runtime:

GET /users?stauts=active  // Typo — ignorado silenciosamente
GET /users?created_at=tomorrow  // Fecha inválida — panic en runtime

Solución: #[filter] genera struct tipado:

let query = UserQuery {
    email: Some("@company.com".into()),  // ILIKE '%@company.com%'
    created_at_min: Some(week_ago),       // >= week_ago
    created_at_max: Some(now),            // <= now
    ..Default::default()
};

let users = pool.list_filtered(&query, 100, 0).await?;

Beneficios:

  • Verificación en tiempo de compilación — typo en nombre de campo = error de compilación
  • Type safety — no puedes comparar DateTime con String
  • Autocompletado — el IDE sugiere filtros disponibles
  • Protección contra SQL injection — parámetros se bindean, no se concatenan

Transparencia

La macro no oculta lógica. Todo lo generado es código Rust normal que puedes:

  • Leercargo expand muestra todo el código generado
  • Entender — sin reflection en runtime, solo structs y traits
  • Sobrescribirsql = "trait" y escribe tu propio SQL
  • Depurar — errores del compilador apuntan a tu código, no a internos de la macro
// ¿Quieres entender qué se genera?
cargo expand --lib | grep -A 50 "impl UserRepository"

Zero magic. Si la macro falla — siempre puedes escribir código a mano. Sin lock-in.


Todo el Poder de Rust

Garantías en Tiempo de Compilación

#[field(skip)]
pub password_hash: String,

Esto no es una verificación en runtime “no serialices este campo”. Es la ausencia física del campo en la struct UserResponse. No puedes devolverlo accidentalmente — el campo simplemente no existe.

Abstracciones Zero-cost

El código generado es:

  • struct regular sin Box/dyn
  • Llamadas directas a sqlx sin capas intermedias
  • #[inline] en hot paths
  • Sin allocaciones más allá de lo necesario

Benchmark: el repositorio generado corre a la misma velocidad que el escrito a mano. Porque es el mismo código.

Async de Serie

// Todo async, todo Send + Sync
let user = pool.find_by_id(id).await?;
let users = pool.list(100, 0).await?;

Compatibilidad total con tokio, async-std, cualquier runtime async.

Tipado Estricto

// Error de compilación: no existe ese campo
let query = UserQuery { naem: "test".into(), ..default() };
                        ^^^^ unknown field

// Error de compilación: tipo incorrecto
let query = UserQuery { created_at_min: "yesterday".into(), ..default() };
                                        ^^^^^^^^^^^^ expected DateTime<Utc>

Si el código compila — funciona correctamente.


Arquitectura Profesional

Clean Architecture Listo

Domain Layer (entity-derive)
├── Entities — #[derive(Entity)]
├── DTOs — CreateRequest, UpdateRequest, Response
├── Repository Trait — abstracción de almacenamiento
├── Events — eventos de dominio
├── Commands — operaciones de negocio
└── Hooks — lógica de ciclo de vida

Infrastructure Layer (tu código)
├── Repository Impl — PgPool automático o custom
├── Event Handlers — suscripciones a eventos
├── Command Handlers — implementación de lógica de negocio
└── External Services — integraciones

Separación limpia. Domain no sabe nada de HTTP, base de datos, Kafka. Esos son detalles de implementación.

CQRS/Event Sourcing Listo

// Command side
handler.handle(RegisterUser { email, name }).await?;

// Query side
let users = pool.list_filtered(&query, 100, 0).await?;

// Event side
match event {
    UserEvent::Created(user) => kafka.send("user.created", &user).await?,
    UserEvent::Updated { id, changes } => audit_log.record(id, changes).await?,
    _ => {}
}

¿Quieres CRUD simple? Lo tienes. ¿Quieres CQRS completo? Activa commands y events. La arquitectura crece con el proyecto.

Extensibilidad

Nivel 1: CRUD Básico

#[entity(table = "users")]

Nivel 2: + Filtrado

#[entity(table = "users")]
// + #[filter] en campos

Nivel 3: + Eventos y Hooks

#[entity(table = "users", events, hooks)]

Nivel 4: + Comandos CQRS

#[entity(table = "users", events, hooks, commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]

Nivel 5: Control Total

#[entity(table = "users", sql = "trait", events, hooks, commands)]
// Tu SQL, tu lógica, pero manteniendo todos los DTOs y tipos

Empieza simple. Añade features mientras creces. No reescribas — extiende.


Seguridad

#[field(skip)]
pub password_hash: String,

skip significa: este campo nunca aparecerá en:

  • CreateUserRequest (no se puede pasar desde fuera)
  • UpdateUserRequest (no se puede modificar vía API)
  • UserResponse (no se puede devolver accidentalmente al cliente)

La única forma de trabajar con password_hash — directamente a través de la entidad en código. Fuga imposible por diseño.


Por Qué Esto es Genial

AspectoLo Que Obtienes
Velocidad de Desarrollo10 entidades en una hora en lugar de un día
FiabilidadVerificación en tiempo de compilación de todo
SeguridadImposible filtrar datos accidentalmente
RendimientoZero-cost, como código escrito a mano
ClaridadGeneración transparente, sin magia
FlexibilidadDe CRUD simple a CQRS con un atributo
EscalabilidadLa arquitectura crece con el proyecto
MantenibilidadUna sola fuente de verdad, menos bugs

Documentación

TemaDescripción
[[Atributos|Atributos]]Referencia completa de atributos
[[Filtrado|Filtrado]]Filtrado de consultas type-safe
[[Relaciones|Relaciones]]belongs_to y has_many
[[Eventos|Eventos]]Eventos de ciclo de vida
[[Hooks|Ganchos]]Hooks before/after
[[Comandos|Comandos]]Patrón CQRS
[[SQL Personalizado|SQL-Personalizado]]Consultas complejas
[[Ejemplos|Ejemplos]]Casos de uso reales
[[Web Frameworks|Frameworks-Web]]Integración con Axum, Actix
[[Mejores Prácticas|Mejores-Prácticas]]Guías para producción

Esto no es un framework que dicta cómo vivir. Es una herramienta que elimina la rutina y te permite construir correctamente.


Atributos

Guía completa de todos los atributos soportados por entity-derive.

Atributos a Nivel de Entidad

Se aplican a la estructura con #[entity(...)]:

#[derive(Entity)]
#[entity(
    table = "users",
    schema = "core",
    sql = "full",
    dialect = "postgres",
    uuid = "v7",
    soft_delete,
    returning = "full",
    error = "AppError",
    events,
    hooks,
    commands
)]
pub struct User { /* ... */ }

Referencia Rápida

AtributoRequeridoPor DefectoDescripción
tableNombre de la tabla en BD
schemaNo"public"Esquema de BD
sqlNo"full"Nivel de generación SQL
dialectNo"postgres"Dialecto de BD
uuidNo"v7"Versión UUID para generación de ID
soft_deleteNofalseHabilitar borrado lógico
returningNo"full"Modo de cláusula RETURNING
upsert(...)NoGenera un método upsert con INSERT ... ON CONFLICT
api(guard = "...")NoGuard FromRequestParts aplicado en los handlers generados
errorNosqlx::ErrorTipo de error personalizado
eventsNofalseGenerar eventos de ciclo de vida
hooksNofalseGenerar trait de hooks
commandsNofalseHabilitar patrón CQRS

table (requerido)

Nombre de la tabla en base de datos.

#[entity(table = "users")]           // → FROM users
#[entity(table = "user_profiles")]   // → FROM user_profiles

schema (opcional)

Esquema de base de datos. Por defecto: "public".

#[entity(table = "users")]                    // → FROM public.users
#[entity(table = "users", schema = "core")]   // → FROM core.users
#[entity(table = "users", schema = "auth")]   // → FROM auth.users

sql (opcional)

Nivel de generación SQL. Por defecto: "full".

ValorTrait RepositoryImpl PgPoolCaso de Uso
"full"Entidades CRUD estándar
"trait"NoConsultas personalizadas (joins, CTEs)
"none"NoNoSolo DTOs, sin base de datos
#[entity(table = "users", sql = "full")]   // Automatización completa (defecto)
#[entity(table = "users", sql = "trait")]  // Solo trait, implementar SQL manualmente
#[entity(table = "users", sql = "none")]   // Sin capa de base de datos

dialect (opcional)

Dialecto de BD para generación SQL. Por defecto: "postgres".

DialectoAliasTipo ClienteEstado
"postgres""pg", "postgresql"sqlx::PgPoolEstable
"clickhouse""ch"clickhouse::ClientPlanificado
"mongodb""mongo"mongodb::ClientPlanificado

uuid (opcional)

Versión UUID para claves primarias auto-generadas. Por defecto: "v7".

VersiónMétodoPropiedades
"v7"Uuid::now_v7()Ordenado por tiempo, ordenable (recomendado)
"v4"Uuid::new_v4()Aleatorio, ampliamente compatible
#[entity(table = "users", uuid = "v7")]     // Ordenado por tiempo (defecto)
#[entity(table = "sessions", uuid = "v4")]  // UUID aleatorio

¿Por qué UUID v7?

  • Ordenado por tiempo: ordenamiento natural por fecha de creación
  • Mejor rendimiento de índices en BD
  • No requiere coordinación (a diferencia de secuencias)
  • Globalmente único en sistemas distribuidos

soft_delete (opcional)

Habilita borrado lógico para marcar registros como eliminados en lugar de borrarlos.

#[derive(Entity)]
#[entity(table = "documents", soft_delete)]
pub struct Document {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    pub title: String,

    #[field(skip)]
    pub deleted_at: Option<DateTime<Utc>>,  // Campo requerido
}

Métodos generados:

  • delete() — Establece deleted_at = NOW() en lugar de DELETE
  • hard_delete() — Elimina permanentemente el registro
  • restore() — Establece deleted_at = NULL
  • find_by_id() / list() — Filtran automáticamente registros eliminados
  • find_by_id_with_deleted() / list_with_deleted() — Incluyen registros eliminados

returning (opcional)

Controla qué datos se obtienen después de INSERT/UPDATE. Por defecto: "full".

ModoCláusula SQLCaso de Uso
"full"RETURNING *Obtener todos los campos incluyendo los generados por BD
"id"RETURNING idConfirmar inserción, devolver entidad pre-construida
"none"(sin RETURNING)Fire-and-forget, opción más rápida
"col1, col2"RETURNING col1, col2Devolver columnas específicas
#[entity(table = "logs", returning = "none")]              // Más rápido
#[entity(table = "users", returning = "full")]             // Obtener valores generados
#[entity(table = "events", returning = "id, created_at")]  // Columnas personalizadas

events(outbox) (opcional)

Entrega duradera de eventos mediante un outbox transaccional. events solo genera el enum; con streams, NOTIFY es fire-and-forget. events(outbox) hace que cada escritura generada inserte el evento serializado en la tabla entity_outbox dentro de la misma transacción, y el runtime OutboxDrainer (entity-core, feature outbox) entrega las filas con FOR UPDATE SKIP LOCKED, backoff exponencial y aparcamiento tras max_attempts. At-least-once — los handlers deben ser idempotentes. Se combina con streams.

#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "orders", events(outbox), migrations)]
pub struct Order { /* ... */ }

sqlx::query(Order::MIGRATION_OUTBOX).execute(&pool).await?;

struct Notifier;

#[async_trait::async_trait]
impl entity_core::outbox::OutboxHandler for Notifier {
    type Error = anyhow::Error;
    async fn handle(&self, row: &OutboxRow) -> Result<(), Self::Error> {
        deliver(&row.entity, &row.payload).await
    }
}

entity_core::outbox::OutboxDrainer::new(pool, Notifier).run().await;

upsert(...) (opcional)

Genera un método upsert del repositorio basado en INSERT ... ON CONFLICT.

#[derive(Entity)]
#[entity(table = "users", upsert(conflict = "email"))]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[column(unique)]
    pub email: String,

    #[field(create, update, response)]
    pub name: String,
}
OpciónRequeridaPor defectoDescripción
conflictColumnas de conflicto separadas por comas
actionNo"update""update" (DO UPDATE) o "nothing" (DO NOTHING)

Generado:

  • action = "update"async fn upsert(&self, dto: CreateUserRequest) -> Result<User, Error> — sobrescribe todas las columnas no conflictivas (DO UPDATE SET col = EXCLUDED.col) y devuelve la fila persistida
  • action = "nothing"async fn upsert(&self, dto: CreateUserRequest) -> Result<Option<User>, Error> — mantiene la fila existente; None significa que ya existía una fila en conflicto

Validación en tiempo de compilación:

  • las columnas de conflicto deben existir y tener garantía de unicidad (#[id], #[column(unique)] o un unique_index(...) coincidente)
  • requiere returning = "full" (el valor por defecto)
  • action = "update" necesita al menos una columna actualizable fuera del conflicto

Con streams habilitado, upsert publica una notificación Created por cada fila devuelta.

api(guard = "...") (opcional)

Aplica autenticación en los handlers generados. security = "..." solo documenta la autenticación en OpenAPI; guard inyecta un extractor axum (FromRequestParts) como primer argumento de cada handler CRUD y de comandos generado — una extracción fallida rechaza la petición antes de ejecutar el cuerpo del handler.

pub struct RequireAuth;

impl<S: Send + Sync> FromRequestParts<S> for RequireAuth {
    type Rejection = StatusCode;
    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
        parts.headers.contains_key("authorization")
            .then_some(Self)
            .ok_or(StatusCode::UNAUTHORIZED)
    }
}

#[derive(Entity)]
#[entity(table = "users", api(tag = "Users", handlers, guard = "RequireAuth", guard(list = "none")))]
pub struct User { /* ... */ }

Overrides por operación: guard(create = "Admin", list = "none", ...) con operaciones create, get, update, delete, list, commands; el literal "none" desactiva el guard. Los comandos en public = [...] nunca reciben guard.

error (opcional)

Tipo de error personalizado para repositorio. Por defecto: sqlx::Error.

#[derive(Debug)]
pub enum AppError {
    Database(sqlx::Error),
    NotFound,
    Validation(String),
}

impl std::error::Error for AppError {}
impl std::fmt::Display for AppError { /* ... */ }

// Requerido: convertir desde sqlx::Error
impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        AppError::Database(err)
    }
}

#[derive(Entity)]
#[entity(table = "users", error = "AppError")]
pub struct User { /* ... */ }

// El repositorio generado usa AppError:
// impl UserRepository for PgPool {
//     type Error = AppError;
//     ...
// }

events (opcional)

Genera enum de eventos del ciclo de vida. Ver [[Eventos|Eventos]] para detalles.

#[entity(table = "orders", events)]

Generado:

pub enum OrderEvent {
    Created(Order),
    Updated { id: Uuid, changes: UpdateOrderRequest },
    Deleted(Uuid),
}

hooks (opcional)

Genera trait de hooks del ciclo de vida. Ver [[Ganchos|Hooks]] para detalles.

#[entity(table = "users", hooks)]

Generado:

#[async_trait]
pub trait UserHooks: Send + Sync {
    type Error: std::error::Error + Send + Sync;

    async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;
    async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;
    async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>;
    async fn after_update(&self, entity: &User) -> Result<(), Self::Error>;
    async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
    async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}

commands (opcional)

Habilita patrón de comandos CQRS. Ver [[Comandos|Comandos]] para detalles.

#[entity(table = "users", commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]

Atributos a Nivel de Campo

Se aplican a campos individuales.

#[id]

Marca el campo de clave primaria.

Comportamiento:

  • Auto-genera UUID (v7 por defecto, configurable con atributo uuid)
  • Siempre incluido en DTO Response
  • Excluido de CreateRequest y UpdateRequest
#[id]
pub id: Uuid,

#[auto]

Marca campos auto-generados (timestamps, secuencias).

Comportamiento:

  • Obtiene Default::default() en From<CreateRequest>
  • Excluido de CreateRequest y UpdateRequest
  • Puede incluirse en Response con #[field(response)]
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,

#[field(...)]

Controla inclusión en DTOs. Combina múltiples opciones:

#[field(create)]                    // Solo en CreateRequest
#[field(update)]                    // Solo en UpdateRequest
#[field(response)]                  // Solo en Response
#[field(create, response)]          // En Create y Response
#[field(create, update, response)]  // En los tres
#[field(skip)]                      // Excluido de todos los DTOs

create

Incluye campo en DTO CreateRequest.

#[field(create)]
pub email: String,

// Generado:
pub struct CreateUserRequest {
    pub email: String,
}

update

Incluye campo en DTO UpdateRequest.

Importante: Los campos no opcionales se envuelven automáticamente en Option<T> para actualizaciones parciales.

#[field(update)]
pub name: String,  // No Option

// Generado:
pub struct UpdateUserRequest {
    pub name: Option<String>,  // Envuelto automáticamente
}

response

Incluye campo en DTO Response.

#[field(response)]
pub email: String,

// Generado:
pub struct UserResponse {
    pub id: Uuid,        // Siempre incluido (tiene #[id])
    pub email: String,   // Incluido
}

skip

Excluye campo de todos los DTOs. Usar para datos sensibles.

#[field(skip)]
pub password_hash: String,

Importante: skip anula todas las demás opciones de campo. El campo solo existirá en:

  • Estructura de entidad original
  • Estructura Row (para lecturas de BD)
  • Estructura Insertable (para escrituras en BD)

#[column(pg_enum = "...")]

Conecta un enum Postgres (ValueObject) a la generación de DDL.

#[derive(ValueObject, Debug, Clone, Serialize, Deserialize)]
#[value_object(pg_type = "order_status", sqlx)]
pub enum OrderStatus { Pending, Shipped, Delivered }

#[derive(Entity)]
#[entity(table = "orders", migrations)]
pub struct Order {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    #[column(pg_enum = "order_status")]
    pub status: OrderStatus,
}

for ddl in Order::MIGRATION_TYPES {
    sqlx::query(ddl).execute(&pool).await?;
}
sqlx::query(Order::MIGRATION_UP).execute(&pool).await?;
  • Define el tipo de columna en el DDL (de lo contrario los campos enum caen a TEXT)
  • Registra el DDL idempotente PG_CREATE_TYPE del enum en {Entity}::MIGRATION_TYPES — ejecútalos antes de MIGRATION_UP
  • El nombre declarado se verifica contra la constante PG_TYPE del enum en tiempo de compilación; una discrepancia rompe la compilación
  • El flag opcional sqlx de ValueObject genera impls sqlx::Type / Encode / Decode; omítelo si ya derivas sqlx::Type

#[owner]

Alcance por propietario a nivel de fila. Marca la columna con el id del propietario; el repositorio obtiene métodos con alcance que nunca revelan si una fila existe para otro propietario y respetan soft_delete.

#[derive(Entity)]
#[entity(table = "orders")]
pub struct Order {
    #[id]
    pub id: Uuid,

    #[owner]
    pub user_id: Uuid,

    #[field(create, update, response)]
    pub note: String,
}

let mine = pool.list_by_owner(user_id, 20, 0).await?;
let order = pool.find_by_id_scoped(id, user_id).await?;
let updated = pool.update_scoped(id, user_id, patch).await?;
let removed = pool.delete_scoped(id, user_id).await?;

Generado: find_by_id_scoped, list_by_owner, update_scoped (cuando hay campos update; None si la fila no es suya), delete_scoped. Como máximo un campo #[owner]; combinarlo con #[id] se rechaza en compilación.

#[filter] / #[filter(...)]

Genera campos de filtro de consulta. Ver [[Filtrado|Filtrado]] para detalles.

#[filter]              // Coincidencia exacta: WHERE field = $n
#[filter(eq)]          // Igual que arriba
#[filter(like)]        // Patrón: WHERE field ILIKE $n
#[filter(range)]       // Rango: WHERE field >= $n AND field <= $m

#[belongs_to(Entity)]

Relación de clave foránea. Ver [[Relaciones|Relaciones]] para detalles.

#[belongs_to(User)]
pub user_id: Uuid,

Generado: Método find_user() en repositorio.

#[has_many(Entity)]

Relación uno-a-muchos (nivel de entidad). Ver [[Relaciones|Relaciones]] para detalles.

#[has_many(Post)]
pub struct User { /* ... */ }

Generado: Método find_posts() en repositorio.

#[projection(Name: fields)]

Genera estructura de vista parcial (nivel de entidad).

#[projection(Public: id, name, avatar)]
#[projection(Admin: id, name, email, role)]
pub struct User { /* ... */ }

Generado:

  • UserPublic { id, name, avatar }
  • UserAdmin { id, name, email, role }
  • Implementaciones From<User>
  • Métodos find_by_id_public(), find_by_id_admin()

Atributos de Comando

Se aplican a nivel de entidad con #[command(...)].

Referencia Rápida

SintaxisEfecto
#[command(Name)]Usa todos los campos #[field(create)]
#[command(Name: field1, field2)]Usa solo campos especificados (añade requires_id)
#[command(Name, requires_id)]Añade campo ID, sin otros campos
#[command(Name, source = "create")]Usa explícitamente campos create (defecto)
#[command(Name, source = "update")]Usa campos update (opcionales, añade requires_id)
#[command(Name, source = "none")]Sin campos de payload
#[command(Name, payload = "Type")]Usa estructura payload personalizada
#[command(Name, result = "Type")]Usa tipo de resultado personalizado
#[command(Name, kind = "create")]Sugerencia: crea entidad (defecto)
#[command(Name, kind = "update")]Sugerencia: modifica entidad
#[command(Name, kind = "delete")]Sugerencia: elimina entidad (devuelve ())
#[command(Name, kind = "custom")]Sugerencia: operación personalizada

Ver [[Comandos|Comandos]] para documentación detallada.

Ejemplo Completo

#[derive(Entity)]
#[entity(
    table = "posts",
    schema = "blog",
    sql = "full",
    dialect = "postgres",
    uuid = "v7",
    soft_delete,
    returning = "full",
    events,
    hooks,
    commands
)]
#[has_many(Comment)]
#[projection(Summary: id, title, author_id, created_at)]
#[command(Publish)]
#[command(Archive, requires_id)]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    #[filter(like)]
    pub title: String,

    #[field(create, update, response)]
    pub content: String,

    #[field(create, response)]
    #[belongs_to(User)]
    #[filter]
    pub author_id: Uuid,

    #[field(update, response)]
    pub published: bool,

    #[field(response)]
    #[filter(range)]
    pub view_count: i64,

    #[field(skip)]
    pub moderation_notes: String,

    #[field(skip)]
    pub deleted_at: Option<DateTime<Utc>>,

    #[auto]
    #[field(response)]
    #[filter(range)]
    pub created_at: DateTime<Utc>,

    #[auto]
    #[field(response)]
    pub updated_at: DateTime<Utc>,
}

Matriz de Decisiones

Quiero…Atributos
Auto-generar clave primaria#[id]
Usar UUID aleatoriouuid = "v4" en entidad
Usar UUID ordenado por tiempouuid = "v7" (defecto)
Aceptar en cuerpo POST#[field(create)]
Aceptar en cuerpo PATCH#[field(update)]
Devolver en respuesta API#[field(response)]
Aceptar y devolver#[field(create, update, response)]
Ocultar de todas las APIs#[field(skip)]
Auto-generar timestamp#[auto] + #[field(response)]
Solo lectura (gestionado por BD)Solo #[field(response)]
Solo escritura (sin retorno)Solo #[field(create)]
Consultas SQL personalizadassql = "trait"
Solo DTOs, sin BDsql = "none"
Borrado lógico de registrossoft_delete en entidad
Tipo de error personalizadoerror = "MyError" en entidad
Filtrar por valor exacto#[filter] en campo
Filtrar por patrón#[filter(like)] en campo
Filtrar por rango#[filter(range)] en campo
Rastrear cambios de entidadevents en entidad
Ejecutar código en ciclo de vidahooks en entidad
Usar comandos de dominiocommands en entidad + #[command(...)]
Definir relación#[belongs_to(Entity)] o #[has_many(Entity)]
Vista parcial de entidad#[projection(Name: fields)]

DTO de actualización: semántica PATCH

Las actualizaciones generadas son parches parciales reales: la cláusula SET se construye en tiempo de ejecución con los campos realmente presentes; los omitidos no se tocan. Las columnas anulables usan doble Option (None = dejar, Some(None) = poner NULL, Some(Some(v)) = poner v) mediante entity_core::serde_helpers::double_option.

// {}                   → nothing changes
// {"nickname": null}   → nickname = NULL
// {"nickname": "neo"}  → nickname = 'neo'
let patch: UpdateProfileRequest = serde_json::from_str(body)?;
let profile = pool.update(id, patch).await?;

Opciones de migrations(...)

Además del flag simple, migrations acepta opciones DDL: touch_updated_at (función plpgsql compartida + trigger BEFORE UPDATE por tabla que mantiene updated_at fresco; requiere un campo updated_at, verificado en compilación), audit (tabla entity_audit_log + trigger con diffs to_jsonb(OLD/NEW)) y extensions = "pg_trgm, pgcrypto" (CREATE EXTENSION idempotente). Nuevas constantes: MIGRATION_TRIGGERS (ejecutar después de MIGRATION_UP) y MIGRATION_EXTENSIONS (antes).

#[entity(table = "articles", migrations(touch_updated_at, audit, extensions = "pg_trgm"))]
pub struct Article { /* ... */ }

for ddl in Article::MIGRATION_EXTENSIONS { sqlx::query(ddl).execute(&pool).await?; }
sqlx::query(Article::MIGRATION_UP).execute(&pool).await?;
for ddl in Article::MIGRATION_TRIGGERS { sqlx::query(ddl).execute(&pool).await?; }

#[version]

Bloqueo optimista. Marca una columna entera (i16/i32/i64); el DTO de actualización gana un expected_version obligatorio, el UPDATE generado incrementa la columna y solo se aplica mientras la versión almacenada coincide — una escritura obsoleta falla con un error de conflicto en lugar de sobrescribir datos más recientes. El DDL usa INTEGER NOT NULL DEFAULT 0. Aplica a updates normales, con alcance y transaccionales.

#[derive(Entity)]
#[entity(table = "orders", migrations)]
pub struct Order {
    #[id] pub id: Uuid,
    #[field(create, update, response)] pub note: String,
    #[version] #[field(response)] #[auto] pub version: i32,
}

let patch = UpdateOrderRequest { note: Some("v2".into()), expected_version: order.version };
let updated = pool.update(order.id, patch).await?;

typed_constraints (opcional)

La macro conoce cada constraint que crea. Con este flag, los métodos de escritura generados resuelven los nombres de constraints violados (columnas únicas, claves foráneas belongs_to, checks de columna, nombres de unique_index) y devuelven entity_core::ConstraintError { kind, constraint, field } en lugar de un error crudo del driver. Requiere un tipo error propio con From<ConstraintError>; sin el flag nada cambia.

#[entity(table = "users", typed_constraints, error = "AppError")]
pub struct User {
    #[id] pub id: Uuid,
    #[field(create, response)] #[column(unique)] pub email: String,
}

match pool.create(dto).await {
    Err(AppError::Constraint(v)) if v.field == Some("email") => conflict_409(),
    other => other?,
}

#[embed(prefix = "...", fields(...))]

Aplana un value object en columnas escalares con prefijo. El DDL, la estructura Row, el SQL CRUD y los PATCH dinámicos operan sobre price_amount_cents / price_currency, mientras los DTO y la entidad llevan la estructura misma. La forma declarada se desestructura contra la estructura real en compilación — un campo renombrado, retipado, ausente o extra rompe la compilación. Padres Option<T> aún no soportados.

pub struct Money { pub amount_cents: i64, pub currency: String }

#[derive(Entity)]
#[entity(table = "products", migrations)]
pub struct Product {
    #[id] pub id: Uuid,
    #[field(create, update, response)]
    #[embed(prefix = "price_", fields(amount_cents: i64, currency: String))]
    pub price: Money,
}

Feature garde (backend de validación)

Habilita garde::Validate en los DTO generados como alternativa mantenida a validator. Las reglas #[validate(...)] (length, range, email, url, pattern) se traducen a sintaxis garde; los campos sin restricciones reciben garde(skip); los campos Option del DTO de actualización validan el valor interno con inner(...). Con ambas features activas, validate tiene prioridad.

#[field(create, update, response)]
#[validate(length(min = 3, max = 8))]
pub name: String,

let dto: CreateUserRequest = serde_json::from_str(body)?;
garde::Validate::validate(&dto)?;

constraint(...) (opcional, junto con typed_constraints)

Declara constraints que la macro no puede inferir — claves foráneas sobre claves naturales, CHECKs con nombres personalizados, índices de migraciones manuales — para que las violaciones se resuelvan a ConstraintError con el campo declarado. Tipos: unique, foreign_key, check. Las entradas personalizadas tienen prioridad sobre las derivadas con el mismo nombre. Requiere typed_constraints.

#[entity(
    table = "orders",
    typed_constraints,
    constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"),
    constraint(name = "orders_window_check", kind = "check"),
)]

Upsert transaccional

Con transactions y upsert(...) habilitados, el adaptador {Entity}TransactionRepo expone upsert con la misma semántica SQL que el método del pool, ejecutado sobre el handle de la transacción — para flujos donde el upsert debe compartir atomicidad con sentencias adyacentes.

let mut tx = pool.begin().await?;
sqlx::query("UPDATE users SET username = NULL WHERE ...").execute(&mut *tx).await?;
let user = UserTransactionRepo::new(&mut tx).upsert(dto).await?;
tx.commit().await?;

Ejemplos

Ejemplos prácticos para casos de uso comunes.

Gestión de Usuarios

Entidad de usuario clásica con campos de autenticación:

use entity_derive::Entity;
use uuid::Uuid;
use chrono::{DateTime, Utc};

#[derive(Entity)]
#[entity(table = "users", schema = "auth")]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub username: String,

    #[field(create, update, response)]
    pub email: String,

    #[field(create, update)]  // Acepta pero nunca retorna
    pub password_hash: String,

    #[field(response)]
    pub email_verified: bool,

    #[field(update, response)]
    pub avatar_url: Option<String>,

    #[field(response)]
    pub role: String,

    #[field(skip)]  // Campo de auditoría interno
    pub last_login_ip: Option<String>,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,

    #[auto]
    #[field(response)]
    pub updated_at: DateTime<Utc>,
}

Uso:

// Crear usuario
let request = CreateUserRequest {
    username: "john_doe".into(),
    email: "john@example.com".into(),
    password_hash: hash_password("secret123"),
};
let user = pool.create(request).await?;

// Actualizar usuario
let update = UpdateUserRequest {
    avatar_url: Some(Some("https://cdn.example.com/avatar.jpg".into())),
    ..Default::default()
};
let user = pool.update(user.id, update).await?;

// La respuesta es segura - sin password_hash, sin last_login_ip
let response = UserResponse::from(&user);

Sistema de Blog

Posts con relación de autor:

#[derive(Entity)]
#[entity(table = "posts", schema = "blog")]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub title: String,

    #[field(create, update, response)]
    pub slug: String,

    #[field(create, update, response)]
    pub content: String,

    #[field(create, update, response)]
    pub excerpt: Option<String>,

    #[field(create, response)]  // Se establece una vez, no se puede cambiar autor
    pub author_id: Uuid,

    #[field(update, response)]
    pub published: bool,

    #[field(update, response)]
    pub published_at: Option<DateTime<Utc>>,

    #[field(response)]  // Solo lectura, gestionado por triggers
    pub view_count: i64,

    #[field(skip)]  // Moderación interna
    pub moderation_status: String,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,

    #[auto]
    #[field(response)]
    pub updated_at: DateTime<Utc>,
}

Categorías con muchos-a-muchos:

#[derive(Entity)]
#[entity(table = "categories", schema = "blog")]
pub struct Category {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub name: String,

    #[field(create, update, response)]
    pub slug: String,

    #[field(create, update, response)]
    pub description: Option<String>,

    #[field(response)]
    pub post_count: i64,  // Campo calculado

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,
}

E-Commerce

Catálogo de productos:

#[derive(Entity)]
#[entity(table = "products", schema = "catalog")]
pub struct Product {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub name: String,

    #[field(create, update, response)]
    pub sku: String,

    #[field(create, update, response)]
    pub description: Option<String>,

    #[field(create, update, response)]
    pub price_cents: i64,

    #[field(create, update, response)]
    pub currency: String,

    #[field(update, response)]
    pub stock_quantity: i32,

    #[field(update, response)]
    pub is_active: bool,

    #[field(create, response)]
    pub category_id: Uuid,

    #[field(skip)]  // Seguimiento interno de costos
    pub cost_cents: i64,

    #[field(skip)]  // Info del proveedor
    pub supplier_id: Option<Uuid>,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,

    #[auto]
    #[field(response)]
    pub updated_at: DateTime<Utc>,
}

Pedidos con seguimiento de estado:

#[derive(Entity)]
#[entity(table = "orders", schema = "sales")]
pub struct Order {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    pub customer_id: Uuid,

    #[field(response)]
    pub order_number: String,  // Generado por secuencia de BD

    #[field(update, response)]
    pub status: String,

    #[field(create, response)]
    pub total_cents: i64,

    #[field(create, response)]
    pub currency: String,

    #[field(create, update, response)]
    pub shipping_address: String,

    #[field(update, response)]
    pub tracking_number: Option<String>,

    #[field(skip)]  // Datos del procesador de pago
    pub payment_intent_id: Option<String>,

    #[field(skip)]  // Notas internas
    pub admin_notes: Option<String>,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,

    #[auto]
    #[field(response)]
    pub updated_at: DateTime<Utc>,
}

SaaS Multi-Tenant

Entidades con ámbito de organización:

#[derive(Entity)]
#[entity(table = "organizations", schema = "tenants")]
pub struct Organization {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub name: String,

    #[field(create, response)]
    pub slug: String,  // Inmutable después de creación

    #[field(update, response)]
    pub plan: String,

    #[field(response)]
    pub member_count: i32,

    #[field(skip)]  // Info de facturación
    pub stripe_customer_id: Option<String>,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,
}

#[derive(Entity)]
#[entity(table = "projects", schema = "tenants")]
pub struct Project {
    #[id]
    pub id: Uuid,

    #[field(create, response)]  // Se establece una vez
    pub organization_id: Uuid,

    #[field(create, update, response)]
    pub name: String,

    #[field(create, update, response)]
    pub description: Option<String>,

    #[field(update, response)]
    pub archived: bool,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,

    #[auto]
    #[field(response)]
    pub updated_at: DateTime<Utc>,
}

Solo DTOs (Sin Base de Datos)

Para contratos de API sin persistencia:

#[derive(Entity)]
#[entity(table = "webhooks", sql = "none")]
pub struct WebhookPayload {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    pub event_type: String,

    #[field(create, response)]
    pub payload: String,

    #[field(create, response)]
    pub timestamp: DateTime<Utc>,

    #[field(response)]
    pub signature: String,
}

Esto genera solo:

  • CreateWebhookPayloadRequest
  • WebhookPayloadResponse
  • Implementaciones From

Sin repositorio, sin SQL, sin estructuras Row/Insertable.

Solo Consultas Personalizadas

Cuando el CRUD estándar no es suficiente:

#[derive(Entity)]
#[entity(table = "analytics_events", schema = "analytics", sql = "trait")]
pub struct AnalyticsEvent {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    pub event_name: String,

    #[field(create, response)]
    pub user_id: Option<Uuid>,

    #[field(create, response)]
    pub properties: serde_json::Value,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,
}

// Implementa consultas personalizadas tú mismo:
impl AnalyticsEventRepository for PgPool {
    type Error = sqlx::Error;

    async fn create(&self, dto: CreateAnalyticsEventRequest) -> Result<AnalyticsEvent, Self::Error> {
        // Inserción por lotes, tablas particionadas, etc.
    }

    async fn find_by_id(&self, id: Uuid) -> Result<Option<AnalyticsEvent>, Self::Error> {
        // Consulta con particionamiento basado en tiempo
    }

    // Métodos personalizados más allá del CRUD:
    async fn aggregate_by_event(&self, start: DateTime<Utc>, end: DateTime<Utc>)
        -> Result<Vec<EventAggregate>, Self::Error> {
        // Consulta de agregación compleja
    }
}

Ver También

  • [[Atributos|Atributos]] — Referencia completa de atributos
  • [[SQL Personalizado|SQL-Personalizado]] — Implementación de repositorio personalizado
  • [[Frameworks Web|Frameworks-Web]] — Integración con Axum/Actix

Operaciones masivas

Cada repositorio incluye primitivas por lotes: find_by_ids (WHERE id = ANY($1), un solo viaje), create_many atómico (una transacción; una fila fallida revierte todo el lote) y delete_many consciente del soft delete que devuelve el número de filas realmente afectadas. Con entrega de eventos (streams u outbox) se emiten eventos por fila dentro de la misma transacción.

let posts: Vec<Post> = pool.find_by_ids(ids).await?;
let created: Vec<Post> = pool.create_many(dtos).await?;
let removed: u64 = pool.delete_many(stale_ids).await?;

Filtrado

Genera estructuras de consulta type-safe para filtrar entidades. El filtrado permite paginación, búsqueda y consultas de rango con seguridad en tiempo de compilación.

Inicio Rápido

#[derive(Entity)]
#[entity(table = "products")]
pub struct Product {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    #[filter]
    pub name: String,

    #[field(create, update, response)]
    #[filter(like)]
    pub description: String,

    #[field(create, update, response)]
    #[filter(range)]
    pub price: i64,

    #[field(create, response)]
    #[filter]
    pub category_id: Uuid,

    #[field(response)]
    #[auto]
    #[filter(range)]
    pub created_at: DateTime<Utc>,
}

Código Generado

Estructura de Query

/// Parámetros de consulta para filtrar entidades Product.
#[derive(Debug, Clone, Default)]
pub struct ProductQuery {
    /// Filtrar por coincidencia exacta de name.
    pub name: Option<String>,

    /// Filtrar por patrón de description (ILIKE).
    pub description: Option<String>,

    /// Filtrar por precio mínimo.
    pub price_from: Option<i64>,

    /// Filtrar por precio máximo.
    pub price_to: Option<i64>,

    /// Filtrar por coincidencia exacta de category_id.
    pub category_id: Option<Uuid>,

    /// Filtrar por created_at mínimo.
    pub created_at_from: Option<DateTime<Utc>>,

    /// Filtrar por created_at máximo.
    pub created_at_to: Option<DateTime<Utc>>,

    /// Número máximo de resultados.
    pub limit: Option<i64>,

    /// Número de resultados a omitir.
    pub offset: Option<i64>,
}

Método de Repository

#[async_trait]
pub trait ProductRepository: Send + Sync {
    // ... métodos CRUD estándar

    /// Consultar productos con filtros.
    async fn query(&self, query: ProductQuery) -> Result<Vec<Product>, Self::Error>;
}

SQL Generado

SELECT id, name, description, price, category_id, created_at
FROM products
WHERE ($1 IS NULL OR name = $1)
  AND ($2 IS NULL OR description ILIKE $2)
  AND ($3 IS NULL OR price >= $3)
  AND ($4 IS NULL OR price <= $4)
  AND ($5 IS NULL OR category_id = $5)
  AND ($6 IS NULL OR created_at >= $6)
  AND ($7 IS NULL OR created_at <= $7)
ORDER BY created_at DESC
LIMIT $8 OFFSET $9

Tipos de Filtro

Coincidencia Exacta (#[filter] o #[filter(eq)])

Filtra donde el campo es igual al valor proporcionado.

#[filter]
pub status: String,

#[filter(eq)]  // Igual que arriba
pub category_id: Uuid,

Generado:

pub status: Option<String>,
pub category_id: Option<Uuid>,

SQL:

WHERE status = $1
  AND category_id = $2

Coincidencia de Patrón (#[filter(like)])

Filtra usando coincidencia de patrones case-insensitive (ILIKE).

#[filter(like)]
pub name: String,

#[filter(like)]
pub description: String,

Generado:

pub name: Option<String>,
pub description: Option<String>,

SQL:

WHERE name ILIKE $1
  AND description ILIKE $2

Uso:

let query = ProductQuery {
    name: Some("%widget%".into()),  // Contiene "widget"
    description: Some("premium%".into()),  // Empieza con "premium"
    ..Default::default()
};

Filtro de Rango (#[filter(range)])

Filtra dentro de un rango (inclusivo).

#[filter(range)]
pub price: i64,

#[filter(range)]
pub created_at: DateTime<Utc>,

Generado:

pub price_from: Option<i64>,
pub price_to: Option<i64>,
pub created_at_from: Option<DateTime<Utc>>,
pub created_at_to: Option<DateTime<Utc>>,

SQL:

WHERE price >= $1 AND price <= $2
  AND created_at >= $3 AND created_at <= $4

Ejemplos de Uso

Filtrado Básico

// Encontrar productos por categoría
let query = ProductQuery {
    category_id: Some(electronics_category_id),
    ..Default::default()
};
let products = repo.query(query).await?;

Paginación

// Obtener página 2 (20 items por página)
let query = ProductQuery {
    limit: Some(20),
    offset: Some(20),
    ..Default::default()
};
let products = repo.query(query).await?;

Filtros Combinados

// Buscar electrónicos asequibles
let query = ProductQuery {
    category_id: Some(electronics_category_id),
    price_from: Some(0),
    price_to: Some(10000),  // $100.00
    name: Some("%phone%".into()),
    limit: Some(50),
    ..Default::default()
};
let products = repo.query(query).await?;

Rango de Fechas

// Obtener productos creados este mes
let now = Utc::now();
let month_start = now.with_day(1).unwrap().date_naive().and_hms_opt(0, 0, 0).unwrap();

let query = ProductQuery {
    created_at_from: Some(month_start.and_utc()),
    created_at_to: Some(now),
    ..Default::default()
};
let products = repo.query(query).await?;

Integración con Endpoint API

use axum::{extract::Query, Json};

#[derive(Deserialize)]
pub struct ProductQueryParams {
    pub name: Option<String>,
    pub category_id: Option<Uuid>,
    pub min_price: Option<i64>,
    pub max_price: Option<i64>,
    pub page: Option<i64>,
    pub per_page: Option<i64>,
}

async fn list_products(
    Query(params): Query<ProductQueryParams>,
    pool: Extension<PgPool>,
) -> Result<Json<Vec<ProductResponse>>, AppError> {
    let page = params.page.unwrap_or(1);
    let per_page = params.per_page.unwrap_or(20).min(100);

    let query = ProductQuery {
        name: params.name.map(|n| format!("%{}%", n)),
        category_id: params.category_id,
        price_from: params.min_price,
        price_to: params.max_price,
        limit: Some(per_page),
        offset: Some((page - 1) * per_page),
        ..Default::default()
    };

    let products = pool.query(query).await?;
    let responses: Vec<_> = products.into_iter().map(ProductResponse::from).collect();

    Ok(Json(responses))
}

Con Borrado Lógico

Cuando soft_delete está habilitado, la consulta excluye automáticamente registros eliminados:

#[derive(Entity)]
#[entity(table = "documents", soft_delete)]
pub struct Document {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[filter(like)]
    pub title: String,

    #[field(skip)]
    pub deleted_at: Option<DateTime<Utc>>,
}

SQL Generado:

SELECT * FROM documents
WHERE deleted_at IS NULL
  AND ($1 IS NULL OR title ILIKE $1)
LIMIT $2 OFFSET $3

Método adicional para incluir eliminados:

async fn query_with_deleted(&self, query: DocumentQuery) -> Result<Vec<Document>, Self::Error>;

Extensiones de Query Personalizadas

Para consultas complejas, usa sql = "trait" e implementa filtrado personalizado:

#[derive(Entity)]
#[entity(table = "products", sql = "trait")]
pub struct Product { /* ... */ }

pub trait ProductQueryExt {
    async fn search_fulltext(&self, term: &str, limit: i64) -> Result<Vec<Product>, sqlx::Error>;
    async fn find_by_tags(&self, tags: &[String]) -> Result<Vec<Product>, sqlx::Error>;
}

#[async_trait]
impl ProductQueryExt for PgPool {
    async fn search_fulltext(&self, term: &str, limit: i64) -> Result<Vec<Product>, sqlx::Error> {
        let rows: Vec<ProductRow> = sqlx::query_as(
            r#"
            SELECT * FROM products
            WHERE to_tsvector('english', name || ' ' || description)
                  @@ plainto_tsquery('english', $1)
            ORDER BY ts_rank(to_tsvector('english', name || ' ' || description),
                            plainto_tsquery('english', $1)) DESC
            LIMIT $2
            "#
        )
        .bind(term)
        .bind(limit)
        .fetch_all(self)
        .await?;

        Ok(rows.into_iter().map(Product::from).collect())
    }

    async fn find_by_tags(&self, tags: &[String]) -> Result<Vec<Product>, sqlx::Error> {
        let rows: Vec<ProductRow> = sqlx::query_as(
            "SELECT * FROM products WHERE tags && $1"
        )
        .bind(tags)
        .fetch_all(self)
        .await?;

        Ok(rows.into_iter().map(Product::from).collect())
    }
}

Mejores Prácticas

  1. Paginación por defecto — Siempre aplica límites sensatos para prevenir conjuntos de resultados grandes
  2. Validar patrones — Sanitiza patrones LIKE para prevenir problemas SQL
  3. Indexar columnas filtradas — Crea índices de BD para campos filtrados frecuentemente
  4. Usar filtros específicos — Prefiere coincidencia exacta sobre coincidencia de patrón cuando sea posible
  5. Combinar con ordenación — Considera agregar campos de ordenación a tu estructura de query

Ver También

  • [[Atributos|Atributos]] — Referencia completa de atributos
  • [[SQL Personalizado|SQL-Personalizado]] — Consultas personalizadas complejas
  • [[Relaciones|Relaciones]] — Filtrado con relaciones

Ordenación y paginación keyset

Marca las columnas ordenables con #[sort]: la estructura Query obtiene un selector con lista blanca {Entity}SortField (una variante Asc/Desc por columna, JSON en snake_case), de modo que la entrada del usuario nunca puede inyectar SQL. Cada repositorio también obtiene paginación keyset list_after: con ids UUIDv7 el recorrido por id es cronológicamente estable y no se degrada en páginas profundas, a diferencia de OFFSET.

#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    #[sort]
    #[filter(like)]
    pub title: String,

    #[field(create, response)]
    #[sort]
    pub views: i64,
}

let query = PostQuery {
    sort: Some(PostSortField::ViewsDesc),
    limit: Some(20),
    ..Default::default()
};
let top: Vec<Post> = pool.query(query).await?;

let page: Vec<Post> = pool.list_after(None, 20).await?;
let next: Vec<Post> = pool.list_after(page.last().map(|p| p.id), 20).await?;

Búsqueda por trigramas

#[filter(search)] en una columna de texto añade un filtro difuso de subcadena (col ILIKE '%' || $n || '%', el término va ligado). Con migrations, el índice gin_trgm_ops correspondiente aterriza en MIGRATION_UP y pg_trgm se añade automáticamente a MIGRATION_EXTENSIONS. Verificación en compilación: el campo debe ser String.

#[field(create, update, response)]
#[filter(search)]
pub title: String,

let hits = pool.query(ArticleQuery { title: Some("rust".into()), ..Default::default() }).await?;

Relaciones

Define relaciones entre entidades usando #[belongs_to] y #[has_many]. Las relaciones generan métodos de navegación type-safe en repositorios.

Inicio Rápido

// Entidad padre
#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub name: String,
}

// Entidad hija
#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[belongs_to(User)]
    pub user_id: Uuid,

    #[field(create, update, response)]
    pub title: String,
}

Código Generado

Para #[has_many(Post)] en User

#[async_trait]
impl UserRepository for PgPool {
    // ... métodos CRUD estándar

    /// Encontrar todos los posts de este usuario.
    async fn find_posts(&self, user_id: Uuid) -> Result<Vec<Post>, Self::Error> {
        let rows: Vec<PostRow> = sqlx::query_as(
            "SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC"
        )
        .bind(&user_id)
        .fetch_all(self)
        .await?;

        Ok(rows.into_iter().map(Post::from).collect())
    }
}

Para #[belongs_to(User)] en Post

#[async_trait]
impl PostRepository for PgPool {
    // ... métodos CRUD estándar

    /// Encontrar el usuario al que pertenece este post.
    async fn find_user(&self, id: Uuid) -> Result<Option<User>, Self::Error> {
        // Primero obtener el post para encontrar user_id
        let post = self.find_by_id(id).await?;

        if let Some(post) = post {
            let row: Option<UserRow> = sqlx::query_as(
                "SELECT * FROM users WHERE id = $1"
            )
            .bind(&post.user_id)
            .fetch_optional(self)
            .await?;

            Ok(row.map(User::from))
        } else {
            Ok(None)
        }
    }
}

Tipos de Relación

belongs_to (Muchos-a-Uno)

Una entidad hija referencia a un padre via clave foránea.

#[derive(Entity)]
#[entity(table = "comments")]
pub struct Comment {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[belongs_to(Post)]
    pub post_id: Uuid,

    #[field(create, response)]
    #[belongs_to(User)]
    pub author_id: Uuid,

    #[field(create, response)]
    pub content: String,
}

Métodos generados:

  • find_post(comment_id)Option<Post>
  • find_author(comment_id)Option<User> (nota: nombre derivado del campo sin _id)

has_many (Uno-a-Muchos)

Una entidad padre tiene múltiples hijos.

#[derive(Entity)]
#[entity(table = "users")]
#[has_many(Post)]
#[has_many(Comment)]
pub struct User {
    #[id]
    pub id: Uuid,
    // ...
}

Métodos generados:

  • find_posts(user_id)Vec<Post>
  • find_comments(user_id)Vec<Comment>

Ejemplos de Uso

Cargando Datos Relacionados

// Obtener usuario con sus posts
async fn get_user_with_posts(
    pool: &PgPool,
    user_id: Uuid,
) -> Result<Option<(User, Vec<Post>)>, sqlx::Error> {
    let user = pool.find_by_id(user_id).await?;

    if let Some(user) = user {
        let posts = pool.find_posts(user_id).await?;
        Ok(Some((user, posts)))
    } else {
        Ok(None)
    }
}

// Obtener post con autor
async fn get_post_with_author(
    pool: &PgPool,
    post_id: Uuid,
) -> Result<Option<(Post, User)>, sqlx::Error> {
    let post = pool.find_by_id(post_id).await?;

    if let Some(post) = post {
        let user = pool.find_user(post.id).await?;
        if let Some(user) = user {
            return Ok(Some((post, user)));
        }
    }

    Ok(None)
}

Construyendo DTOs de Respuesta

#[derive(Serialize)]
pub struct PostWithAuthor {
    #[serde(flatten)]
    pub post: PostResponse,
    pub author: UserResponse,
}

async fn get_posts_with_authors(
    pool: &PgPool,
    limit: i64,
) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
    let posts = pool.list(limit, 0).await?;

    let mut results = Vec::with_capacity(posts.len());

    for post in posts {
        if let Some(user) = pool.find_user(post.id).await? {
            results.push(PostWithAuthor {
                post: PostResponse::from(&post),
                author: UserResponse::from(&user),
            });
        }
    }

    Ok(results)
}

Relaciones Múltiples

Una entidad puede tener múltiples relaciones:

#[derive(Entity)]
#[entity(table = "organizations")]
#[has_many(User)]
#[has_many(Project)]
#[has_many(Team)]
pub struct Organization {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub name: String,
}

#[derive(Entity)]
#[entity(table = "projects")]
pub struct Project {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[belongs_to(Organization)]
    pub organization_id: Uuid,

    #[field(create, response)]
    #[belongs_to(User)]
    pub owner_id: Uuid,

    #[field(create, update, response)]
    pub name: String,
}

Generado para Organization:

  • find_users(org_id)
  • find_projects(org_id)
  • find_teams(org_id)

Generado para Project:

  • find_organization(project_id)
  • find_owner(project_id)

Joins Personalizados con sql = “trait”

Para consultas complejas con eager loading, usa SQL personalizado:

#[derive(Entity)]
#[entity(table = "posts", sql = "trait")]
pub struct Post { /* ... */ }

pub struct PostWithRelations {
    pub post: Post,
    pub author: User,
    pub comments: Vec<Comment>,
}

pub trait PostRepositoryExt {
    async fn find_with_relations(&self, id: Uuid) -> Result<Option<PostWithRelations>, sqlx::Error>;
    async fn list_with_authors(&self, limit: i64) -> Result<Vec<(Post, User)>, sqlx::Error>;
}

#[async_trait]
impl PostRepositoryExt for PgPool {
    async fn find_with_relations(&self, id: Uuid) -> Result<Option<PostWithRelations>, sqlx::Error> {
        // Consulta única con joins
        let row = sqlx::query_as::<_, (PostRow, UserRow)>(
            r#"
            SELECT p.*, u.*
            FROM posts p
            JOIN users u ON u.id = p.user_id
            WHERE p.id = $1
            "#
        )
        .bind(&id)
        .fetch_optional(self)
        .await?;

        if let Some((post_row, user_row)) = row {
            let comments: Vec<CommentRow> = sqlx::query_as(
                "SELECT * FROM comments WHERE post_id = $1 ORDER BY created_at"
            )
            .bind(&id)
            .fetch_all(self)
            .await?;

            Ok(Some(PostWithRelations {
                post: Post::from(post_row),
                author: User::from(user_row),
                comments: comments.into_iter().map(Comment::from).collect(),
            }))
        } else {
            Ok(None)
        }
    }

    async fn list_with_authors(&self, limit: i64) -> Result<Vec<(Post, User)>, sqlx::Error> {
        let rows = sqlx::query_as::<_, (PostRow, UserRow)>(
            r#"
            SELECT p.*, u.*
            FROM posts p
            JOIN users u ON u.id = p.user_id
            ORDER BY p.created_at DESC
            LIMIT $1
            "#
        )
        .bind(limit)
        .fetch_all(self)
        .await?;

        Ok(rows.into_iter()
            .map(|(p, u)| (Post::from(p), User::from(u)))
            .collect())
    }
}

Con Filtrado

Combina relaciones con filtrado de consultas:

#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[belongs_to(User)]
    #[filter]  // Habilitar filtrado por user_id
    pub user_id: Uuid,

    #[field(create, update, response)]
    #[filter(like)]
    pub title: String,

    #[field(response)]
    #[auto]
    #[filter(range)]
    pub created_at: DateTime<Utc>,
}

Uso:

// Obtener posts de un usuario específico con filtro de título
let query = PostQuery {
    user_id: Some(user_id),
    title: Some("%rust%".into()),
    limit: Some(20),
    ..Default::default()
};
let posts = pool.query(query).await?;

Mejores Prácticas

  1. Evitar consultas N+1 — Usa joins para eager loading al obtener múltiples entidades relacionadas
  2. Usar paginación — Siempre limita resultados de has_many
  3. Considerar patrones de acceso a datos — Añade índices en columnas de clave foránea
  4. Cachear cuando sea apropiado — Cachea datos relacionados accedidos frecuentemente
  5. Usar proyecciones — Obtén solo campos necesarios para entidades relacionadas

Ver También

  • [[Filtrado|Filtrado]] — Filtrado de consultas
  • [[SQL Personalizado|SQL-Personalizado]] — Joins y consultas complejas
  • [[Mejores Prácticas|Mejores-Prácticas]] — Consejos de rendimiento

has_many through (muchos-a-muchos)

Declara la tabla de unión con through y el repositorio obtiene una consulta con JOIN más gestión de vínculos; migrations emite MIGRATION_JUNCTIONS (clave primaria compuesta sobre ambas claves foráneas, ON DELETE CASCADE en ambos lados).

#[derive(Entity)]
#[entity(table = "teams", migrations)]
#[has_many(User, through = "team_members")]
pub struct Team { /* ... */ }

for ddl in Team::MIGRATION_JUNCTIONS {
    sqlx::query(ddl).execute(&pool).await?;
}

pool.add_user(team_id, user_id).await?;
let members: Vec<User> = pool.find_users(team_id).await?;
let linked = pool.has_user(team_id, user_id).await?;
let removed = pool.remove_user(team_id, user_id).await?;

Métodos generados: find_users (INNER JOIN), add_user (idempotente, ON CONFLICT DO NOTHING), remove_user (false si no estaba vinculado), has_user (SELECT EXISTS).

Eventos

Genera eventos de dominio para cambios en el ciclo de vida de entidades. Los eventos permiten registro de auditoría, event sourcing e integración con colas de mensajes.

Inicio Rápido

#[derive(Entity)]
#[entity(table = "orders", events)]
pub struct Order {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    pub customer_id: Uuid,

    #[field(create, update, response)]
    pub status: String,

    #[field(create, response)]
    pub total_cents: i64,

    #[field(response)]
    #[auto]
    pub created_at: DateTime<Utc>,
}

Código Generado

El atributo events genera un enum de eventos:

/// Generado por entity-derive
#[derive(Debug, Clone)]
pub enum OrderEvent {
    /// Entidad creada.
    Created(Order),

    /// Entidad actualizada.
    Updated {
        id: Uuid,
        changes: UpdateOrderRequest,
    },

    /// Entidad eliminada.
    Deleted(Uuid),
}

Ejemplos de Uso

Publicación Básica de Eventos

use async_trait::async_trait;

#[async_trait]
pub trait EventBus: Send + Sync {
    async fn publish<E: Send + Sync>(&self, event: E);
}

async fn create_order(
    repo: &impl OrderRepository,
    bus: &impl EventBus,
    dto: CreateOrderRequest,
) -> Result<Order, sqlx::Error> {
    let order = repo.create(dto).await?;

    // Publicar evento después de creación exitosa
    bus.publish(OrderEvent::Created(order.clone())).await;

    Ok(order)
}

async fn update_order(
    repo: &impl OrderRepository,
    bus: &impl EventBus,
    id: Uuid,
    dto: UpdateOrderRequest,
) -> Result<Order, sqlx::Error> {
    let order = repo.update(id, dto.clone()).await?;

    bus.publish(OrderEvent::Updated { id, changes: dto }).await;

    Ok(order)
}

async fn delete_order(
    repo: &impl OrderRepository,
    bus: &impl EventBus,
    id: Uuid,
) -> Result<bool, sqlx::Error> {
    let deleted = repo.delete(id).await?;

    if deleted {
        bus.publish(OrderEvent::Deleted(id)).await;
    }

    Ok(deleted)
}

Registro de Auditoría

struct AuditLogger {
    pool: PgPool,
}

#[async_trait]
impl EventHandler<OrderEvent> for AuditLogger {
    async fn handle(&self, event: OrderEvent) {
        let (action, entity_id, details) = match &event {
            OrderEvent::Created(order) => (
                "created",
                order.id,
                serde_json::to_string(order).unwrap(),
            ),
            OrderEvent::Updated { id, changes } => (
                "updated",
                *id,
                serde_json::to_string(changes).unwrap(),
            ),
            OrderEvent::Deleted(id) => (
                "deleted",
                *id,
                String::new(),
            ),
        };

        sqlx::query(
            "INSERT INTO audit_log (entity_type, entity_id, action, details, created_at)
             VALUES ('order', $1, $2, $3, NOW())"
        )
        .bind(entity_id)
        .bind(action)
        .bind(details)
        .execute(&self.pool)
        .await
        .ok();
    }
}

Integración con Cola de Mensajes

use rdkafka::producer::FutureProducer;

struct KafkaEventBus {
    producer: FutureProducer,
    topic: String,
}

#[async_trait]
impl EventBus for KafkaEventBus {
    async fn publish<E: Serialize + Send + Sync>(&self, event: E) {
        let payload = serde_json::to_vec(&event).unwrap();

        self.producer
            .send(
                FutureRecord::to(&self.topic)
                    .payload(&payload)
                    .key(&Uuid::new_v4().to_string()),
                Duration::from_secs(5),
            )
            .await
            .ok();
    }
}

Patrón Event Sourcing

struct OrderAggregate {
    events: Vec<OrderEvent>,
    current_state: Option<Order>,
}

impl OrderAggregate {
    fn apply(&mut self, event: OrderEvent) {
        match &event {
            OrderEvent::Created(order) => {
                self.current_state = Some(order.clone());
            }
            OrderEvent::Updated { changes, .. } => {
                if let Some(ref mut order) = self.current_state {
                    if let Some(status) = &changes.status {
                        order.status = status.clone();
                    }
                }
            }
            OrderEvent::Deleted(_) => {
                self.current_state = None;
            }
        }
        self.events.push(event);
    }

    fn replay(events: Vec<OrderEvent>) -> Self {
        let mut aggregate = Self {
            events: Vec::new(),
            current_state: None,
        };
        for event in events {
            aggregate.apply(event);
        }
        aggregate
    }
}

Con Borrado Lógico

Cuando soft_delete está habilitado, se generan eventos adicionales:

#[derive(Entity)]
#[entity(table = "documents", events, soft_delete)]
pub struct Document {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    pub title: String,

    #[field(skip)]
    pub deleted_at: Option<DateTime<Utc>>,
}

Generado:

pub enum DocumentEvent {
    Created(Document),
    Updated { id: Uuid, changes: UpdateDocumentRequest },
    Deleted(Uuid),       // Borrado lógico
    Restored(Uuid),      // Restaurado de borrado lógico
    HardDeleted(Uuid),   // Eliminación permanente
}

Mejores Prácticas

  1. Publicar después del commit — Solo publicar eventos después de que la transacción de BD tenga éxito
  2. Handlers idempotentes — Los handlers de eventos deben ser idempotentes para entrega at-least-once
  3. Incluir contexto — Considera agregar metadatos (user_id, timestamp, correlation_id)
  4. Procesamiento async — Usa workers en segundo plano para procesamiento pesado de eventos
  5. Cola de errores — Maneja eventos fallidos de forma elegante

Combinando con Hooks

Eventos y hooks funcionan bien juntos:

#[derive(Entity)]
#[entity(table = "orders", events, hooks)]
pub struct Order { /* ... */ }

struct OrderService {
    repo: PgPool,
    bus: EventBus,
}

#[async_trait]
impl OrderHooks for OrderService {
    type Error = AppError;

    async fn after_create(&self, entity: &Order) -> Result<(), Self::Error> {
        // Publicar evento en hook
        self.bus.publish(OrderEvent::Created(entity.clone())).await;
        Ok(())
    }

    async fn after_update(&self, entity: &Order) -> Result<(), Self::Error> {
        // Los eventos también pueden publicarse aquí
        Ok(())
    }

    async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
        self.bus.publish(OrderEvent::Deleted(*id)).await;
        Ok(())
    }
}

Ver También

  • [[Hooks|Ganchos]] — Ejecutar lógica personalizada en eventos del ciclo de vida
  • [[Comandos|Comandos]] — Patrón CQRS con eventos de comandos
  • [[Mejores Prácticas|Mejores-Prácticas]] — Consejos de producción

Streams

Suscríbete a cambios de entidades en tiempo real usando Postgres LISTEN/NOTIFY. Los streams permiten dashboards en vivo, notificaciones instantáneas, invalidación de caché y arquitecturas orientadas a eventos.

Inicio Rápido

#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "orders", events, streams)]
pub struct Order {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub status: String,

    #[field(create, response)]
    pub customer_id: Uuid,
}

Requisitos:

  • La entidad debe derivar Serialize y Deserialize (para payloads JSON)
  • Se requieren ambos atributos events y streams
  • Habilita la feature streams en Cargo.toml
[dependencies]
entity-derive = { version = "0.3", features = ["postgres", "streams"] }
serde = { version = "1", features = ["derive"] }

Código Generado

El atributo streams genera:

Constante de Canal

impl Order {
    /// Nombre del canal Postgres NOTIFY.
    pub const CHANNEL: &'static str = "entity_orders";
}

Estructura Subscriber

/// Suscriptor para cambios de Order en tiempo real.
pub struct OrderSubscriber {
    listener: PgListener,
}

impl OrderSubscriber {
    /// Conectar y suscribirse al canal.
    pub async fn new(pool: &PgPool) -> Result<Self, sqlx::Error>;

    /// Esperar el próximo evento (bloqueante).
    pub async fn recv(&mut self) -> Result<OrderEvent, StreamError<sqlx::Error>>;

    /// Verificar evento sin bloquear.
    pub async fn try_recv(&mut self) -> Result<Option<OrderEvent>, StreamError<sqlx::Error>>;
}

Notificaciones Automáticas

Las operaciones CRUD emiten eventos automáticamente:

// En el método create() generado:
async fn create(&self, dto: CreateOrderRequest) -> Result<Order, Self::Error> {
    let order = /* insert */;

    // Notificación auto-generada
    let event = OrderEvent::created(order.clone());
    let payload = serde_json::to_string(&event)?;
    sqlx::query("SELECT pg_notify($1, $2)")
        .bind(Order::CHANNEL)
        .bind(&payload)
        .execute(self)
        .await?;

    Ok(order)
}

Ejemplos de Uso

Suscripción Básica

use entity_derive::StreamError;

async fn watch_orders(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
    let mut subscriber = OrderSubscriber::new(pool).await?;

    loop {
        match subscriber.recv().await {
            Ok(event) => {
                match event {
                    OrderEvent::Created(order) => {
                        println!("Nueva orden: {}", order.id);
                    }
                    OrderEvent::Updated { old, new } => {
                        println!("Orden {} actualizada: {} -> {}", new.id, old.status, new.status);
                    }
                    OrderEvent::HardDeleted { id } => {
                        println!("Orden {} eliminada", id);
                    }
                    _ => {}
                }
            }
            Err(StreamError::Database(e)) => {
                eprintln!("Error de base de datos: {}", e);
                break;
            }
            Err(StreamError::Deserialize(e)) => {
                eprintln!("Payload de evento inválido: {}", e);
            }
        }
    }

    Ok(())
}

Dashboard en Tiempo Real (Axum WebSocket)

use axum::{
    extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}},
    response::IntoResponse,
};

async fn ws_handler(
    ws: WebSocketUpgrade,
    State(pool): State<PgPool>,
) -> impl IntoResponse {
    ws.on_upgrade(|socket| handle_socket(socket, pool))
}

async fn handle_socket(mut socket: WebSocket, pool: PgPool) {
    let mut subscriber = match OrderSubscriber::new(&pool).await {
        Ok(s) => s,
        Err(_) => return,
    };

    loop {
        match subscriber.recv().await {
            Ok(event) => {
                let json = serde_json::to_string(&event).unwrap();
                if socket.send(Message::Text(json)).await.is_err() {
                    break;
                }
            }
            Err(_) => break,
        }
    }
}

Invalidación de Caché

struct CacheInvalidator {
    cache: Redis,
    pool: PgPool,
}

impl CacheInvalidator {
    async fn run(&self) -> Result<(), StreamError<sqlx::Error>> {
        let mut subscriber = OrderSubscriber::new(&self.pool).await
            .map_err(StreamError::Database)?;

        loop {
            let event = subscriber.recv().await?;
            let key = format!("order:{}", event.entity_id());

            match event {
                OrderEvent::Created(_) | OrderEvent::Updated { .. } => {
                    self.cache.del(&key).await.ok();
                }
                OrderEvent::HardDeleted { id } | OrderEvent::SoftDeleted { id } => {
                    self.cache.del(&format!("order:{}", id)).await.ok();
                }
                _ => {}
            }
        }
    }
}

Worker en Segundo Plano con Graceful Shutdown

use tokio::sync::watch;

async fn notification_worker(
    pool: PgPool,
    mut shutdown: watch::Receiver<bool>,
) {
    let mut subscriber = OrderSubscriber::new(&pool).await.unwrap();

    loop {
        tokio::select! {
            result = subscriber.recv() => {
                match result {
                    Ok(event) => process_event(event).await,
                    Err(e) => {
                        eprintln!("Error de stream: {:?}", e);
                        tokio::time::sleep(Duration::from_secs(1)).await;
                    }
                }
            }
            _ = shutdown.changed() => {
                println!("Apagando worker de notificaciones");
                break;
            }
        }
    }
}

Manejo de Errores

use entity_derive::StreamError;

match subscriber.recv().await {
    Ok(event) => { /* procesar */ }
    Err(StreamError::Database(sqlx_error)) => {
        // Conexión perdida, query fallido, etc.
        // El subscriber se reconectará automáticamente en el próximo recv()
    }
    Err(StreamError::Deserialize(message)) => {
        // Payload JSON inválido
        // Loguear y continuar - no romper el loop
    }
}

Arquitectura

Operación CRUD (create/update/delete)
         │
         ▼
    pg_notify(channel, event_json)
         │
         ▼
    Postgres NOTIFY
         │
    ┌────┴────┐
    ▼         ▼
Subscriber  Subscriber  (múltiples listeners)
    │         │
    ▼         ▼
 WebSocket   Invalidador
 Dashboard   de Caché

Mejores Prácticas

  1. Reconexión — PgListener se reconecta automáticamente; diseña tu loop para manejar fallos temporales
  2. Idempotencia — Los eventos pueden entregarse múltiples veces; los handlers deben ser idempotentes
  3. Tamaño del payload — Mantén las entidades pequeñas; payloads grandes pueden exceder límites de Postgres
  4. Pools separados — Usa un pool de conexiones dedicado para listeners
  5. Monitoreo — Loguea errores de stream y rastrea la latencia de procesamiento de eventos
  6. Graceful shutdown — Usa select! con señal de shutdown para limpiar recursos

Con Soft Delete

Cuando soft_delete está habilitado, hay eventos adicionales disponibles:

#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "documents", events, streams, soft_delete)]
pub struct Document {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    pub title: String,

    #[field(skip)]
    pub deleted_at: Option<DateTime<Utc>>,
}

// Los eventos incluyen:
// - DocumentEvent::SoftDeleted { id }
// - DocumentEvent::Restored { id }
// - DocumentEvent::HardDeleted { id }

Ver También

  • [[Eventos|Eventos]] — Enum de eventos sin streaming en tiempo real
  • [[Hooks]] — Ejecutar lógica personalizada en eventos del ciclo de vida
  • [[Mejores Prácticas|Mejores-Prácticas]] — Tips para producción

Hooks

Ejecuta lógica personalizada antes y después de operaciones de entidad. Los hooks permiten validación, normalización, efectos secundarios y autorización.

Inicio Rápido

#[derive(Entity)]
#[entity(table = "users", hooks)]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub email: String,

    #[field(create, response)]
    pub name: String,

    #[field(skip)]
    pub password_hash: String,

    #[field(response)]
    #[auto]
    pub created_at: DateTime<Utc>,
}

Código Generado

El atributo hooks genera un trait async:

/// Generado por entity-derive
#[async_trait]
pub trait UserHooks: Send + Sync {
    type Error: std::error::Error + Send + Sync;

    /// Llamado antes de crear una nueva entidad.
    /// Modifica el DTO o devuelve error para abortar.
    async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;

    /// Llamado después de la creación de entidad.
    async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;

    /// Llamado antes de actualizar una entidad.
    /// Modifica el DTO o devuelve error para abortar.
    async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>;

    /// Llamado después de actualizar entidad.
    async fn after_update(&self, entity: &User) -> Result<(), Self::Error>;

    /// Llamado antes de eliminar una entidad.
    /// Devuelve error para abortar.
    async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>;

    /// Llamado después de eliminar entidad.
    async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}

Ejemplo de Implementación

use async_trait::async_trait;

struct UserService {
    pool: PgPool,
    cache: RedisPool,
    email_sender: EmailService,
}

#[async_trait]
impl UserHooks for UserService {
    type Error = AppError;

    async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
        // Normalizar email
        dto.email = dto.email.trim().to_lowercase();

        // Validar formato de email
        if !dto.email.contains('@') {
            return Err(AppError::Validation("Formato de email inválido".into()));
        }

        // Verificar email duplicado
        let exists = sqlx::query_scalar::<_, bool>(
            "SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)"
        )
        .bind(&dto.email)
        .fetch_one(&self.pool)
        .await?;

        if exists {
            return Err(AppError::Conflict("Email ya registrado".into()));
        }

        Ok(())
    }

    async fn after_create(&self, entity: &User) -> Result<(), Self::Error> {
        // Enviar email de bienvenida
        self.email_sender
            .send_welcome(&entity.email, &entity.name)
            .await?;

        // Cachear el nuevo usuario
        self.cache.set(&format!("user:{}", entity.id), entity).await?;

        Ok(())
    }

    async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error> {
        // Normalizar email si se proporciona
        if let Some(ref mut email) = dto.email {
            *email = email.trim().to_lowercase();

            // Verificar duplicado (excluyendo usuario actual)
            let exists = sqlx::query_scalar::<_, bool>(
                "SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 AND id != $2)"
            )
            .bind(&*email)
            .bind(id)
            .fetch_one(&self.pool)
            .await?;

            if exists {
                return Err(AppError::Conflict("Email ya en uso".into()));
            }
        }

        Ok(())
    }

    async fn after_update(&self, entity: &User) -> Result<(), Self::Error> {
        // Invalidar caché
        self.cache.del(&format!("user:{}", entity.id)).await?;

        Ok(())
    }

    async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
        // Verificar si el usuario puede ser eliminado
        let has_orders = sqlx::query_scalar::<_, bool>(
            "SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = $1 AND status = 'pending')"
        )
        .bind(id)
        .fetch_one(&self.pool)
        .await?;

        if has_orders {
            return Err(AppError::Forbidden("No se puede eliminar usuario con pedidos pendientes".into()));
        }

        Ok(())
    }

    async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
        // Invalidar caché
        self.cache.del(&format!("user:{}", id)).await?;

        // Limpiar datos relacionados
        sqlx::query("DELETE FROM user_sessions WHERE user_id = $1")
            .bind(id)
            .execute(&self.pool)
            .await?;

        Ok(())
    }
}

Casos de Uso

Validación

async fn before_create(&self, dto: &mut CreateProductRequest) -> Result<(), Self::Error> {
    // Validación de precio
    if dto.price_cents <= 0 {
        return Err(AppError::Validation("El precio debe ser positivo".into()));
    }

    // Validación de formato SKU
    if !dto.sku.chars().all(|c| c.is_alphanumeric() || c == '-') {
        return Err(AppError::Validation("Formato de SKU inválido".into()));
    }

    Ok(())
}

Normalización

async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
    // Normalizar email
    dto.email = dto.email.trim().to_lowercase();

    // Normalizar nombre
    dto.name = dto.name.trim().to_string();

    // Capitalizar primera letra de cada palabra
    dto.name = dto.name
        .split_whitespace()
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => first.to_uppercase().chain(chars).collect(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ");

    Ok(())
}

Autorización

async fn before_update(&self, id: &Uuid, _dto: &mut UpdatePostRequest) -> Result<(), Self::Error> {
    // Obtener usuario actual del contexto
    let current_user = self.current_user()?;

    // Verificar propiedad
    let post = sqlx::query_as::<_, Post>(
        "SELECT * FROM posts WHERE id = $1"
    )
    .bind(id)
    .fetch_optional(&self.pool)
    .await?
    .ok_or(AppError::NotFound)?;

    if post.author_id != current_user.id && !current_user.is_admin {
        return Err(AppError::Forbidden("No puede editar posts de otros usuarios".into()));
    }

    Ok(())
}

Efectos Secundarios

async fn after_create(&self, entity: &Order) -> Result<(), Self::Error> {
    // Actualizar inventario
    for item in &entity.items {
        sqlx::query(
            "UPDATE products SET stock = stock - $1 WHERE id = $2"
        )
        .bind(item.quantity)
        .bind(item.product_id)
        .execute(&self.pool)
        .await?;
    }

    // Enviar notificación
    self.notifications.send_order_confirmation(entity).await?;

    // Programar trabajo de fulfillment
    self.job_queue.enqueue(FulfillOrderJob { order_id: entity.id }).await?;

    Ok(())
}

Con Borrado Lógico

Cuando soft_delete está habilitado, se generan hooks adicionales:

#[derive(Entity)]
#[entity(table = "documents", hooks, soft_delete)]
pub struct Document { /* ... */ }

Hooks generados:

#[async_trait]
pub trait DocumentHooks: Send + Sync {
    type Error: std::error::Error + Send + Sync;

    // Hooks CRUD estándar...
    async fn before_create(&self, dto: &mut CreateDocumentRequest) -> Result<(), Self::Error>;
    async fn after_create(&self, entity: &Document) -> Result<(), Self::Error>;
    async fn before_update(&self, id: &Uuid, dto: &mut UpdateDocumentRequest) -> Result<(), Self::Error>;
    async fn after_update(&self, entity: &Document) -> Result<(), Self::Error>;
    async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>;  // Borrado lógico
    async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;

    // Hooks específicos de borrado lógico
    async fn before_restore(&self, id: &Uuid) -> Result<(), Self::Error>;
    async fn after_restore(&self, entity: &Document) -> Result<(), Self::Error>;
    async fn before_hard_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
    async fn after_hard_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}

Con Comandos

Cuando commands y hooks están habilitados, se generan hooks de comandos:

#[derive(Entity)]
#[entity(table = "orders", hooks, commands)]
#[command(Place)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }

Hooks adicionales:

#[async_trait]
pub trait OrderHooks: Send + Sync {
    type Error: std::error::Error + Send + Sync;

    // Hooks CRUD estándar...

    // Hooks de comandos
    async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
    async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}

Mejores Prácticas

  1. Mantén los hooks rápidos — Las operaciones largas deben ser trabajos async
  2. Usa transacciones — Envuelve hook + llamada a repository en una transacción
  3. Maneja errores elegantemente — Devuelve tipos de error significativos
  4. No dupliques lógica — Usa hooks para concerns transversales
  5. Prueba hooks independientemente — Pruebas unitarias para implementaciones de hooks

Patrón de Manejo de Errores

#[derive(Debug)]
pub enum HookError {
    Validation(String),
    Authorization(String),
    Conflict(String),
    Database(sqlx::Error),
}

impl std::error::Error for HookError {}
impl std::fmt::Display for HookError { /* ... */ }

impl From<sqlx::Error> for HookError {
    fn from(err: sqlx::Error) -> Self {
        HookError::Database(err)
    }
}

#[async_trait]
impl UserHooks for UserService {
    type Error = HookError;

    async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error> {
        if dto.email.is_empty() {
            return Err(HookError::Validation("Email requerido".into()));
        }
        Ok(())
    }
}

Ver También

  • [[Eventos|Eventos]] — Eventos del ciclo de vida para auditoría
  • [[Comandos|Comandos]] — Patrón CQRS con hooks de comandos
  • [[Mejores Prácticas|Mejores-Prácticas]] — Consejos de producción

Comandos

Define comandos orientados al negocio en lugar de CRUD genérico. Los comandos traen el lenguaje del dominio a tu API y habilitan el patrón Command Query Responsibility Segregation (CQRS).

Inicio Rápido

#[derive(Entity)]
#[entity(table = "users", commands)]
#[command(Register)]
#[command(UpdateEmail: email)]
#[command(Deactivate, requires_id)]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub email: String,

    #[field(create, response)]
    pub name: String,

    #[field(response)]
    pub active: bool,
}

Código Generado

Estructuras de Comando

/// Payload de comando para operación Register en User.
#[derive(Debug, Clone)]
pub struct RegisterUser {
    pub email: String,
    pub name: String,
}

/// Payload de comando para operación UpdateEmail en User.
#[derive(Debug, Clone)]
pub struct UpdateEmailUser {
    pub id: Uuid,
    pub email: String,
}

/// Payload de comando para operación Deactivate en User.
#[derive(Debug, Clone)]
pub struct DeactivateUser {
    pub id: Uuid,
}

Enum de Comando

/// Enum de comando para entidad User.
#[derive(Debug, Clone)]
pub enum UserCommand {
    Register(RegisterUser),
    UpdateEmail(UpdateEmailUser),
    Deactivate(DeactivateUser),
}

impl EntityCommand for UserCommand {
    fn kind(&self) -> CommandKind {
        match self {
            UserCommand::Register(_) => CommandKind::Create,
            UserCommand::UpdateEmail(_) => CommandKind::Update,
            UserCommand::Deactivate(_) => CommandKind::Custom,
        }
    }

    fn name(&self) -> &'static str {
        match self {
            UserCommand::Register(_) => "Register",
            UserCommand::UpdateEmail(_) => "UpdateEmail",
            UserCommand::Deactivate(_) => "Deactivate",
        }
    }
}

Enum de Resultado

/// Enum de resultado para ejecución de comando User.
#[derive(Debug, Clone)]
pub enum UserCommandResult {
    Register(User),
    UpdateEmail(User),
    Deactivate,
}

Trait de Handler

/// Trait async para manejar comandos User.
#[async_trait]
pub trait UserCommandHandler: Send + Sync {
    type Error: std::error::Error + Send + Sync;
    type Context: Send + Sync;

    /// Despachar comando al handler apropiado.
    async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
        -> Result<UserCommandResult, Self::Error>;

    /// Manejar comando Register.
    async fn handle_register(&self, cmd: RegisterUser, ctx: &Self::Context)
        -> Result<User, Self::Error>;

    /// Manejar comando UpdateEmail.
    async fn handle_update_email(&self, cmd: UpdateEmailUser, ctx: &Self::Context)
        -> Result<User, Self::Error>;

    /// Manejar comando Deactivate.
    async fn handle_deactivate(&self, cmd: DeactivateUser, ctx: &Self::Context)
        -> Result<(), Self::Error>;
}

Referencia de Sintaxis de Comandos

Comando Básico

Usa todos los campos #[field(create)]:

#[command(Register)]
// Generado: RegisterUser { email, name }

Campos Específicos

Usa solo campos especificados (añade requires_id automáticamente):

#[command(UpdateEmail: email)]
// Generado: UpdateEmailUser { id, email }

#[command(UpdateProfile: name, bio, avatar)]
// Generado: UpdateProfileUser { id, name, bio, avatar }

Comando Solo ID

Añade solo el campo ID:

#[command(Deactivate, requires_id)]
// Generado: DeactivateUser { id }

#[command(Delete, requires_id, kind = "delete")]
// Generado: DeleteUser { id }, devuelve ()

Payload Personalizado

Usa una estructura externa:

pub struct TransferPayload {
    pub from_account: Uuid,
    pub to_account: Uuid,
    pub amount: i64,
}

#[command(Transfer, payload = "TransferPayload")]
// Usa TransferPayload directamente

Resultado Personalizado

Usa un tipo de resultado personalizado:

pub struct TransferResult {
    pub transaction_id: Uuid,
    pub success: bool,
}

#[command(Transfer, payload = "TransferPayload", result = "TransferResult")]
// Devuelve TransferResult en lugar de entidad

Opciones de Source

Controla qué campos se usan:

#[command(Create, source = "create")]     // Usa campos #[field(create)] (defecto)
#[command(Modify, source = "update")]     // Usa campos #[field(update)] (opcional)
#[command(Ping, source = "none")]         // Sin campos de payload

Hints de Kind

Afectan la inferencia del tipo de resultado:

#[command(Create, kind = "create")]   // Devuelve entidad (defecto)
#[command(Update, kind = "update")]   // Devuelve entidad
#[command(Remove, kind = "delete")]   // Devuelve ()
#[command(Process, kind = "custom")]  // Inferido de source

Ejemplo de Implementación

use async_trait::async_trait;

struct UserHandler {
    pool: PgPool,
    email_service: EmailService,
}

struct RequestContext {
    user_id: Option<Uuid>,
    correlation_id: Uuid,
}

#[async_trait]
impl UserCommandHandler for UserHandler {
    type Error = AppError;
    type Context = RequestContext;

    async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
        -> Result<UserCommandResult, Self::Error>
    {
        match cmd {
            UserCommand::Register(c) => {
                let user = self.handle_register(c, ctx).await?;
                Ok(UserCommandResult::Register(user))
            }
            UserCommand::UpdateEmail(c) => {
                let user = self.handle_update_email(c, ctx).await?;
                Ok(UserCommandResult::UpdateEmail(user))
            }
            UserCommand::Deactivate(c) => {
                self.handle_deactivate(c, ctx).await?;
                Ok(UserCommandResult::Deactivate)
            }
        }
    }

    async fn handle_register(&self, cmd: RegisterUser, ctx: &Self::Context)
        -> Result<User, Self::Error>
    {
        // Validar
        if cmd.email.is_empty() {
            return Err(AppError::Validation("Email requerido".into()));
        }

        // Crear usuario
        let user = User {
            id: Uuid::now_v7(),
            email: cmd.email.to_lowercase(),
            name: cmd.name,
            active: true,
        };

        // Persistir
        sqlx::query(
            "INSERT INTO users (id, email, name, active) VALUES ($1, $2, $3, $4)"
        )
        .bind(user.id)
        .bind(&user.email)
        .bind(&user.name)
        .bind(user.active)
        .execute(&self.pool)
        .await?;

        // Efectos secundarios
        self.email_service.send_welcome(&user.email).await?;

        Ok(user)
    }

    async fn handle_update_email(&self, cmd: UpdateEmailUser, ctx: &Self::Context)
        -> Result<User, Self::Error>
    {
        // Verificar autorización
        if ctx.user_id != Some(cmd.id) {
            return Err(AppError::Forbidden("No puede actualizar email de otro usuario".into()));
        }

        // Actualizar
        let user: User = sqlx::query_as(
            "UPDATE users SET email = $1 WHERE id = $2 RETURNING *"
        )
        .bind(&cmd.email.to_lowercase())
        .bind(cmd.id)
        .fetch_one(&self.pool)
        .await?;

        // Enviar verificación
        self.email_service.send_verification(&user.email).await?;

        Ok(user)
    }

    async fn handle_deactivate(&self, cmd: DeactivateUser, ctx: &Self::Context)
        -> Result<(), Self::Error>
    {
        sqlx::query("UPDATE users SET active = false WHERE id = $1")
            .bind(cmd.id)
            .execute(&self.pool)
            .await?;

        Ok(())
    }
}

Usando Comandos

async fn register_user(
    handler: &impl UserCommandHandler,
    email: String,
    name: String,
) -> Result<User, AppError> {
    let cmd = RegisterUser { email, name };
    let ctx = RequestContext {
        user_id: None,
        correlation_id: Uuid::new_v4(),
    };

    match handler.handle(UserCommand::Register(cmd), &ctx).await? {
        UserCommandResult::Register(user) => Ok(user),
        _ => unreachable!(),
    }
}

// O llamar handler específico directamente
async fn update_email(
    handler: &impl UserCommandHandler,
    user_id: Uuid,
    new_email: String,
    ctx: &RequestContext,
) -> Result<User, AppError> {
    let cmd = UpdateEmailUser {
        id: user_id,
        email: new_email,
    };

    handler.handle_update_email(cmd, ctx).await
}

Trait EntityCommand

Todos los enums de comando implementan el trait EntityCommand:

use entity_derive::{EntityCommand, CommandKind};

let cmd = UserCommand::Register(register_data);

// Obtener metadatos del comando
assert_eq!(cmd.name(), "Register");
assert!(matches!(cmd.kind(), CommandKind::Create));

// Pattern matching
match cmd.kind() {
    CommandKind::Create => println!("Creando entidad"),
    CommandKind::Update => println!("Actualizando entidad"),
    CommandKind::Delete => println!("Eliminando entidad"),
    CommandKind::Custom => println!("Operación personalizada"),
}

Hooks de Comandos

Cuando commands y hooks están habilitados:

#[derive(Entity)]
#[entity(table = "orders", commands, hooks)]
#[command(Place)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }

Hooks generados:

#[async_trait]
pub trait OrderHooks: Send + Sync {
    type Error: std::error::Error + Send + Sync;

    // Hooks CRUD estándar...

    // Hooks específicos de comandos
    async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
    async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}

Mejores Prácticas

  1. Lenguaje de dominio — Usa términos de negocio: RegisterUser no CreateUser
  2. Responsabilidad única — Un comando = una operación de negocio
  3. Intención explícita — Los nombres de comandos deben describir la acción
  4. Validación en handlers — Mantén la lógica de validación en los handlers
  5. Idempotente cuando sea posible — Diseña comandos para reintentos seguros
  6. Usa contexto — Pasa metadatos de request (usuario, ID de correlación) vía contexto

Patrón CQRS

Los comandos son una mitad de CQRS. Combina con proyecciones para el lado de consultas:

#[derive(Entity)]
#[entity(table = "orders", commands)]
#[projection(Summary: id, status, total_cents, created_at)]
#[projection(Details: id, status, items, shipping_address, total_cents)]
#[command(Place)]
#[command(Ship, requires_id)]
#[command(Cancel, requires_id)]
pub struct Order { /* ... */ }

// Comandos (lado de escritura)
let result = handler.handle(OrderCommand::Place(place_order), &ctx).await?;

// Consultas (lado de lectura)
let summary = repo.find_by_id_summary(order_id).await?;
let details = repo.find_by_id_details(order_id).await?;

Ver También

  • [[Hooks|Ganchos]] — Hooks del ciclo de vida incluyendo hooks de comandos
  • [[Eventos|Eventos]] — Generación de eventos para auditoría
  • [[Atributos|Atributos]] — Referencia completa de atributos

Transacciones

Transacciones multi-entidad con tipado seguro y commit/rollback automatico.

Que son las transacciones?

Una transaccion de base de datos es una forma de agrupar multiples operaciones de base de datos en una sola unidad atomica. Esto significa:

  • Todo o nada: O TODAS las operaciones tienen exito, o NINGUNA se aplica
  • Rollback automatico: Si alguna operacion falla, todos los cambios anteriores se deshacen automaticamente
  • Consistencia de datos: Tu base de datos nunca termina en un estado inconsistente

Por que necesitas transacciones?

Imagina que estas construyendo una app bancaria y necesitas transferir dinero entre cuentas:

1. Restar $100 de la Cuenta A
2. Sumar $100 a la Cuenta B

Sin transacciones, si el paso 1 tiene exito pero el paso 2 falla (error de red, caida de BD, etc.), acabas de perder $100! El dinero se resto de A pero nunca se agrego a B.

Con transacciones, si el paso 2 falla, el paso 1 se revierte automaticamente. El dinero permanece en la Cuenta A como si nada hubiera pasado.

Habilitando Transacciones

Agrega el atributo transactions a tu entidad:

use entity_derive::Entity;
use uuid::Uuid;

#[derive(Entity)]
#[entity(table = "accounts", transactions)]  // <- Agrega esto
pub struct Account {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub user_id: Uuid,

    #[field(create, update, response)]
    pub balance: i64,
}

Que se genera?

Para una entidad Account con #[entity(transactions)], el macro genera:

1. Adaptador de Repositorio para Transacciones

pub struct AccountTransactionRepo<'t> {
    tx: &'t mut sqlx::Transaction<'static, sqlx::Postgres>,
}

Es como tu repositorio regular, pero todas las operaciones ocurren dentro de la transaccion.

2. Trait de Extension del Builder

pub trait TransactionWithAccount<'p> {
    fn with_accounts(self) -> Transaction<'p, PgPool, AccountTransactionRepo<'static>>;
}

Esto agrega el metodo with_accounts() al builder de transacciones.

Metodos Disponibles

Dentro de una transaccion, tienes acceso a estos metodos:

MetodoFirmaDescripcion
createcreate(dto) -> Result<Entity, Error>Insertar nuevo registro
find_by_idfind_by_id(id) -> Result<Option<Entity>, Error>Buscar por clave primaria
updateupdate(id, dto) -> Result<Entity, Error>Actualizar registro existente
deletedelete(id) -> Result<bool, Error>Eliminar registro (o soft-delete)
listlist(limit, offset) -> Result<Vec<Entity>, Error>Lista paginada

Ejemplo Basico

use entity_core::prelude::*;

async fn create_account(pool: &PgPool, user_id: Uuid) -> Result<Account, AppError> {
    Transaction::new(pool)           // 1. Comenzar a construir transaccion
        .with_accounts()              // 2. Agregar repositorio Account
        .run(|mut ctx| async move {   // 3. Ejecutar operaciones
            let account = ctx.accounts().create(CreateAccountRequest {
                user_id,
                balance: 0,
            }).await?;

            Ok(account)               // 4. Retornar resultado (auto-commit)
        })
        .await
}

Paso a Paso:

  1. Transaction::new(pool) - Crea un nuevo builder de transaccion con tu pool de base de datos
  2. .with_accounts() - Agrega el repositorio Account al contexto de transaccion
  3. .run(|mut ctx| async move { ... }) - Ejecuta tus operaciones dentro de la transaccion
  4. Ok(account) - Retornar Ok hace commit de la transaccion. Retornar Err hace rollback.

Ejemplo Completo: Transferencia de Dinero

Este ejemplo muestra todo el poder de las transacciones:

use entity_core::prelude::*;
use uuid::Uuid;

#[derive(Debug)]
pub enum TransferError {
    Database(sqlx::Error),
    AccountNotFound(Uuid),
    InsufficientFunds { available: i64, requested: i64 },
}

impl From<sqlx::Error> for TransferError {
    fn from(e: sqlx::Error) -> Self {
        TransferError::Database(e)
    }
}

impl From<TransactionError<sqlx::Error>> for TransferError {
    fn from(e: TransactionError<sqlx::Error>) -> Self {
        TransferError::Database(e.into_inner())
    }
}

/// Transferir dinero entre dos cuentas atomicamente.
///
/// Si CUALQUIER paso falla, todos los cambios se revierten automaticamente.
pub async fn transfer(
    pool: &PgPool,
    from_id: Uuid,
    to_id: Uuid,
    amount: i64,
) -> Result<(), TransferError> {
    Transaction::new(pool)
        .with_accounts()
        .run(|mut ctx| async move {
            // Paso 1: Obtener cuenta origen
            let from = ctx.accounts()
                .find_by_id(from_id)
                .await?
                .ok_or(TransferError::AccountNotFound(from_id))?;

            // Paso 2: Verificar si tiene suficiente dinero
            if from.balance < amount {
                return Err(TransferError::InsufficientFunds {
                    available: from.balance,
                    requested: amount,
                });
            }

            // Paso 3: Obtener cuenta destino
            let to = ctx.accounts()
                .find_by_id(to_id)
                .await?
                .ok_or(TransferError::AccountNotFound(to_id))?;

            // Paso 4: Restar del origen
            // Si esto tiene exito pero el paso 5 falla, esto se REVERTIRA
            ctx.accounts().update(from_id, UpdateAccountRequest {
                balance: Some(from.balance - amount),
                user_id: None,
            }).await?;

            // Paso 5: Sumar al destino
            ctx.accounts().update(to_id, UpdateAccountRequest {
                balance: Some(to.balance + amount),
                user_id: None,
            }).await?;

            // Todas las operaciones exitosas - la transaccion hara COMMIT
            Ok(())
        })
        .await
}

Que pasa en diferentes escenarios:

EscenarioResultado
Ambas actualizaciones exitosasTransaccion hace commit, dinero transferido
Cuenta origen no encontradaTransaccion hace rollback (sin cambios)
Fondos insuficientesTransaccion hace rollback (sin cambios)
Primera actualizacion exitosa, segunda fallaTransaccion hace rollback (primera actualizacion deshecha!)
Error de red a mitad de transaccionTransaccion hace rollback (sin cambios parciales)

Multiples Entidades en Una Transaccion

Puedes operar en multiples entidades atomicamente:

#[derive(Entity)]
#[entity(table = "accounts", transactions)]
pub struct Account {
    #[id]
    pub id: Uuid,
    #[field(create, update, response)]
    pub balance: i64,
}

#[derive(Entity)]
#[entity(table = "transfer_logs", transactions)]
pub struct TransferLog {
    #[id]
    pub id: Uuid,
    #[field(create, response)]
    pub from_account_id: Uuid,
    #[field(create, response)]
    pub to_account_id: Uuid,
    #[field(create, response)]
    pub amount: i64,
    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,
}

async fn transfer_with_logging(
    pool: &PgPool,
    from_id: Uuid,
    to_id: Uuid,
    amount: i64,
) -> Result<TransferLog, AppError> {
    Transaction::new(pool)
        .with_accounts()      // Agregar repo Account
        .with_transfer_logs() // Agregar repo TransferLog
        .run(|mut ctx| async move {
            // Actualizar saldos
            let from = ctx.accounts().find_by_id(from_id).await?
                .ok_or(AppError::NotFound)?;

            ctx.accounts().update(from_id, UpdateAccountRequest {
                balance: Some(from.balance - amount),
            }).await?;

            let to = ctx.accounts().find_by_id(to_id).await?
                .ok_or(AppError::NotFound)?;

            ctx.accounts().update(to_id, UpdateAccountRequest {
                balance: Some(to.balance + amount),
            }).await?;

            // Crear entrada de log - todo en la misma transaccion!
            let log = ctx.transfer_logs().create(CreateTransferLogRequest {
                from_account_id: from_id,
                to_account_id: to_id,
                amount,
            }).await?;

            Ok(log)
        })
        .await
}

Si la creacion del log falla, ambas actualizaciones de cuentas se revierten!

Manejo de Errores

Rollback Automatico

Cualquier error retornado desde el closure dispara un rollback:

Transaction::new(pool)
    .with_accounts()
    .run(|mut ctx| async move {
        ctx.accounts().update(id, dto).await?;  // Exito

        // Alguna validacion falla
        if amount < 0 {
            return Err(AppError::InvalidAmount);  // <- Dispara rollback!
        }

        // Esto nunca se ejecuta, y la actualizacion de arriba se deshace
        ctx.accounts().update(other_id, other_dto).await?;

        Ok(())
    })
    .await

Tipos de Error de Transaccion

El enum TransactionError te dice que salio mal:

use entity_core::transaction::TransactionError;

let result = Transaction::new(pool)
    .with_accounts()
    .run(|mut ctx| async move { /* ... */ })
    .await;

match result {
    Ok(value) => {
        println!("Exito: {:?}", value);
    }
    Err(e) => {
        if e.is_begin() {
            println!("Fallo al iniciar transaccion");
        } else if e.is_operation() {
            println!("Operacion fallo: {}", e);
        } else if e.is_commit() {
            println!("Fallo al hacer commit");
        } else if e.is_rollback() {
            println!("Fallo al hacer rollback");
        }

        // Obtener error interno de base de datos
        let db_error: sqlx::Error = e.into_inner();
    }
}

Con Soft Delete

Las transacciones respetan el atributo soft_delete:

#[derive(Entity)]
#[entity(table = "documents", transactions, soft_delete)]
pub struct Document {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    pub title: String,

    #[field(skip)]
    pub deleted_at: Option<DateTime<Utc>>,  // Requerido para soft_delete
}

async fn archive_document(pool: &PgPool, id: Uuid) -> Result<bool, AppError> {
    Transaction::new(pool)
        .with_documents()
        .run(|mut ctx| async move {
            // Esto establece deleted_at = NOW() en lugar de DELETE
            let deleted = ctx.documents().delete(id).await?;
            Ok(deleted)
        })
        .await
}

Mejores Practicas

1. Manten las Transacciones Cortas

Malo: Transacciones largas

Transaction::new(pool)
    .with_accounts()
    .run(|mut ctx| async move {
        let account = ctx.accounts().find_by_id(id).await?;

        // NO: Llamar APIs externas dentro de transacciones
        let rate = external_api.get_exchange_rate().await?;  // <- LENTO!

        ctx.accounts().update(id, dto).await?;
        Ok(())
    })
    .await

Bueno: Operaciones lentas afuera

// Obtener datos externos ANTES de iniciar transaccion
let rate = external_api.get_exchange_rate().await?;

Transaction::new(pool)
    .with_accounts()
    .run(|mut ctx| async move {
        ctx.accounts().update(id, UpdateAccountRequest {
            balance: Some(calculate_new_balance(rate)),
        }).await?;
        Ok(())
    })
    .await

2. No Uses Transacciones para Operaciones Simples

Innecesario:

Transaction::new(pool)
    .with_users()
    .run(|mut ctx| async move {
        ctx.users().find_by_id(id).await  // Solo una operacion!
    })
    .await

Mejor: Usa repositorio regular

pool.find_by_id(id).await  // No se necesita transaccion

3. Maneja Todos los Errores Correctamente

Siempre propaga errores con ?:

Transaction::new(pool)
    .with_accounts()
    .run(|mut ctx| async move {
        let result = ctx.accounts().update(id, dto).await;

        // NO: Tragarse errores
        if let Err(e) = result {
            log::error!("Update fallo: {}", e);
            // La transaccion no hara rollback correctamente!
        }

        // SI: Propagar errores
        ctx.accounts().update(id, dto).await?;  // <- Usa ?

        Ok(())
    })
    .await

Patrones Comunes

Verificar-Luego-Actualizar

Transaction::new(pool)
    .with_products()
    .run(|mut ctx| async move {
        let product = ctx.products().find_by_id(id).await?
            .ok_or(AppError::NotFound)?;

        if product.stock < quantity {
            return Err(AppError::OutOfStock);
        }

        ctx.products().update(id, UpdateProductRequest {
            stock: Some(product.stock - quantity),
            ..Default::default()
        }).await?;

        Ok(product)
    })
    .await

Crear Multiples Registros Relacionados

Transaction::new(pool)
    .with_orders()
    .with_order_items()
    .run(|mut ctx| async move {
        // Crear padre
        let order = ctx.orders().create(CreateOrderRequest {
            customer_id,
            status: "pending".to_string(),
        }).await?;

        // Crear hijos
        for item in items {
            ctx.order_items().create(CreateOrderItemRequest {
                order_id: order.id,
                product_id: item.product_id,
                quantity: item.quantity,
            }).await?;
        }

        Ok(order)
    })
    .await

Ver Tambien

  • [[Atributos|Referencia de Atributos]] - Documentacion completa de atributos
  • [[Ganchos|Hooks de Ciclo de Vida]] - Ejecutar codigo antes/despues de operaciones
  • [[Comandos|Comandos]] - Patron de comandos CQRS
  • [[Eventos|Eventos]] - Rastrear cambios de entidades

SQL Personalizado

Cuando el SQL generado automáticamente no es suficiente, usa sql = "trait" para control total.

Cuándo Usar SQL Personalizado

  • Joins — Entidades relacionadas en una sola consulta
  • CTEs — Consultas recursivas o de múltiples pasos
  • Búsqueda full-text — PostgreSQL tsvector/tsquery
  • AgregacionesGROUP BY, HAVING, funciones de ventana
  • Tablas particionadas — Particiones basadas en tiempo o rango
  • Operaciones bulk — Inserciones/actualizaciones por lotes
  • Borrado lógico — Lógica de eliminación personalizada
  • Bloqueo optimista — Concurrencia basada en versión

Configuración Básica

#[derive(Entity)]
#[entity(table = "posts", schema = "blog", sql = "trait")]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub title: String,

    #[field(create, response)]
    pub author_id: Uuid,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,
}

Esto genera:

  • Todos los DTOs (CreatePostRequest, UpdatePostRequest, PostResponse)
  • PostRow e InsertablePost
  • Trait PostRepository
  • Todas las implementaciones From

Pero no genera impl PostRepository for PgPool.

Implementando el Repository

use async_trait::async_trait;
use sqlx::PgPool;

#[async_trait]
impl PostRepository for PgPool {
    type Error = sqlx::Error;

    async fn create(&self, dto: CreatePostRequest) -> Result<Post, Self::Error> {
        let entity = Post::from(dto);
        let insertable = InsertablePost::from(&entity);

        sqlx::query(
            r#"
            INSERT INTO blog.posts (id, title, author_id, created_at)
            VALUES ($1, $2, $3, $4)
            "#
        )
        .bind(insertable.id)
        .bind(&insertable.title)
        .bind(insertable.author_id)
        .bind(insertable.created_at)
        .execute(self)
        .await?;

        Ok(entity)
    }

    async fn find_by_id(&self, id: Uuid) -> Result<Option<Post>, Self::Error> {
        let row: Option<PostRow> = sqlx::query_as(
            "SELECT id, title, author_id, created_at FROM blog.posts WHERE id = $1"
        )
        .bind(&id)
        .fetch_optional(self)
        .await?;

        Ok(row.map(Post::from))
    }

    async fn update(&self, id: Uuid, dto: UpdatePostRequest) -> Result<Post, Self::Error> {
        // Tu lógica de actualización personalizada
        todo!()
    }

    async fn delete(&self, id: Uuid) -> Result<bool, Self::Error> {
        let result = sqlx::query("DELETE FROM blog.posts WHERE id = $1")
            .bind(&id)
            .execute(self)
            .await?;

        Ok(result.rows_affected() > 0)
    }

    async fn list(&self, limit: i64, offset: i64) -> Result<Vec<Post>, Self::Error> {
        let rows: Vec<PostRow> = sqlx::query_as(
            "SELECT id, title, author_id, created_at FROM blog.posts ORDER BY created_at DESC LIMIT $1 OFFSET $2"
        )
        .bind(limit)
        .bind(offset)
        .fetch_all(self)
        .await?;

        Ok(rows.into_iter().map(Post::from).collect())
    }
}

Ejemplo: Posts con Join de Autor

// Respuesta extendida con datos del autor
pub struct PostWithAuthor {
    pub post: Post,
    pub author: User,
}

// Extensión de repository personalizada
pub trait PostRepositoryExt: PostRepository {
    async fn find_with_author(&self, id: Uuid) -> Result<Option<PostWithAuthor>, Self::Error>;
    async fn list_with_authors(&self, limit: i64, offset: i64) -> Result<Vec<PostWithAuthor>, Self::Error>;
}

#[async_trait]
impl PostRepositoryExt for PgPool {
    async fn find_with_author(&self, id: Uuid) -> Result<Option<PostWithAuthor>, Self::Error> {
        let row = sqlx::query_as::<_, (PostRow, UserRow)>(
            r#"
            SELECT
                p.id, p.title, p.author_id, p.created_at,
                u.id, u.username, u.email, u.created_at
            FROM blog.posts p
            JOIN auth.users u ON u.id = p.author_id
            WHERE p.id = $1
            "#
        )
        .bind(&id)
        .fetch_optional(self)
        .await?;

        Ok(row.map(|(p, u)| PostWithAuthor {
            post: Post::from(p),
            author: User::from(u),
        }))
    }

    async fn list_with_authors(&self, limit: i64, offset: i64) -> Result<Vec<PostWithAuthor>, Self::Error> {
        // Consulta similar con join y paginación
        todo!()
    }
}

Ejemplo: Búsqueda Full-Text

pub trait PostSearchRepository {
    async fn search(&self, query: &str, limit: i64) -> Result<Vec<Post>, sqlx::Error>;
}

#[async_trait]
impl PostSearchRepository for PgPool {
    async fn search(&self, query: &str, limit: i64) -> Result<Vec<Post>, sqlx::Error> {
        let rows: Vec<PostRow> = sqlx::query_as(
            r#"
            SELECT id, title, author_id, created_at
            FROM blog.posts
            WHERE to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', $1)
            ORDER BY ts_rank(to_tsvector('english', title || ' ' || content), plainto_tsquery('english', $1)) DESC
            LIMIT $2
            "#
        )
        .bind(query)
        .bind(limit)
        .fetch_all(self)
        .await?;

        Ok(rows.into_iter().map(Post::from).collect())
    }
}

Ejemplo: Borrado Lógico

#[derive(Entity)]
#[entity(table = "posts", sql = "trait")]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub title: String,

    #[field(response)]
    pub deleted_at: Option<DateTime<Utc>>,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,
}

#[async_trait]
impl PostRepository for PgPool {
    // ... otros métodos

    async fn delete(&self, id: Uuid) -> Result<bool, Self::Error> {
        // Borrado lógico en lugar de borrado físico
        let result = sqlx::query(
            "UPDATE blog.posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL"
        )
        .bind(&id)
        .execute(self)
        .await?;

        Ok(result.rows_affected() > 0)
    }

    async fn list(&self, limit: i64, offset: i64) -> Result<Vec<Post>, Self::Error> {
        // Excluir borrados lógicos
        let rows: Vec<PostRow> = sqlx::query_as(
            r#"
            SELECT id, title, deleted_at, created_at
            FROM blog.posts
            WHERE deleted_at IS NULL
            ORDER BY created_at DESC
            LIMIT $1 OFFSET $2
            "#
        )
        .bind(limit)
        .bind(offset)
        .fetch_all(self)
        .await?;

        Ok(rows.into_iter().map(Post::from).collect())
    }
}

// Método adicional para administradores
pub trait PostAdminRepository {
    async fn restore(&self, id: Uuid) -> Result<bool, sqlx::Error>;
    async fn hard_delete(&self, id: Uuid) -> Result<bool, sqlx::Error>;
    async fn list_deleted(&self, limit: i64, offset: i64) -> Result<Vec<Post>, sqlx::Error>;
}

Ejemplo: Bloqueo Optimista

#[derive(Entity)]
#[entity(table = "documents", sql = "trait")]
pub struct Document {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub content: String,

    #[field(response)]
    pub version: i64,

    #[auto]
    #[field(response)]
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug)]
pub enum DocumentError {
    Sqlx(sqlx::Error),
    ConcurrentModification,
}

#[async_trait]
impl DocumentRepository for PgPool {
    type Error = DocumentError;

    async fn update(&self, id: Uuid, dto: UpdateDocumentRequest) -> Result<Document, Self::Error> {
        // Requiere versión actual para bloqueo optimista
        let expected_version = dto.version.ok_or(DocumentError::ConcurrentModification)?;

        let row: Option<DocumentRow> = sqlx::query_as(
            r#"
            UPDATE documents
            SET content = COALESCE($1, content),
                version = version + 1,
                updated_at = NOW()
            WHERE id = $2 AND version = $3
            RETURNING id, content, version, updated_at
            "#
        )
        .bind(&dto.content)
        .bind(&id)
        .bind(expected_version)
        .fetch_optional(self)
        .await
        .map_err(DocumentError::Sqlx)?;

        row.map(Document::from)
            .ok_or(DocumentError::ConcurrentModification)
    }

    // ... otros métodos
}

Mejores Prácticas para SQL Personalizado

  1. Usa query_as con estructuras Row — Mapeo type-safe
  2. Vincula todos los parámetros — Nunca interpoles strings
  3. Retorna Row, convierte a Entity — Usa las impl From generadas
  4. Extiende, no reemplaces — Añade traits personalizados junto a Repository
  5. Prueba con base de datos real — Los tests de integración son esenciales

Ver También

  • [[Atributos|Atributos]] — Referencia completa de atributos
  • [[Ejemplos|Ejemplos]] — Ejemplos del mundo real
  • [[Mejores Prácticas|Mejores-Prácticas]] — Consejos de rendimiento

Frameworks Web

Cómo usar entity-derive con frameworks web populares de Rust.

Axum

Estructura del Proyecto

src/
├── main.rs
├── entities/
│   ├── mod.rs
│   └── user.rs
├── handlers/
│   ├── mod.rs
│   └── users.rs
└── routes.rs

Definición de Entidad

// src/entities/user.rs
use entity_derive::Entity;
use uuid::Uuid;
use chrono::{DateTime, Utc};

#[derive(Entity, Clone)]
#[entity(table = "users", schema = "auth")]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub username: String,

    #[field(create, update, response)]
    pub email: String,

    #[field(skip)]
    pub password_hash: String,

    #[auto]
    #[field(response)]
    pub created_at: DateTime<Utc>,
}

Handlers

// src/handlers/users.rs
use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use sqlx::PgPool;
use uuid::Uuid;

use crate::entities::user::*;

pub async fn create_user(
    State(pool): State<PgPool>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), StatusCode> {
    let user = pool
        .create(payload)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((StatusCode::CREATED, Json(UserResponse::from(&user))))
}

pub async fn get_user(
    State(pool): State<PgPool>,
    Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, StatusCode> {
    let user = pool
        .find_by_id(id)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .ok_or(StatusCode::NOT_FOUND)?;

    Ok(Json(UserResponse::from(&user)))
}

pub async fn update_user(
    State(pool): State<PgPool>,
    Path(id): Path<Uuid>,
    Json(payload): Json<UpdateUserRequest>,
) -> Result<Json<UserResponse>, StatusCode> {
    let user = pool
        .update(id, payload)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(UserResponse::from(&user)))
}

pub async fn delete_user(
    State(pool): State<PgPool>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
    let deleted = pool
        .delete(id)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if deleted {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

pub async fn list_users(
    State(pool): State<PgPool>,
) -> Result<Json<Vec<UserResponse>>, StatusCode> {
    let users = pool
        .list(100, 0)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let responses: Vec<UserResponse> = users.iter().map(UserResponse::from).collect();
    Ok(Json(responses))
}

Rutas

// src/routes.rs
use axum::{
    routing::{get, post, put, delete},
    Router,
};
use sqlx::PgPool;

use crate::handlers::users;

pub fn create_router(pool: PgPool) -> Router {
    Router::new()
        .route("/users", post(users::create_user))
        .route("/users", get(users::list_users))
        .route("/users/:id", get(users::get_user))
        .route("/users/:id", put(users::update_user))
        .route("/users/:id", delete(users::delete_user))
        .with_state(pool)
}

Main

// src/main.rs
use sqlx::postgres::PgPoolOptions;
use std::net::SocketAddr;

mod entities;
mod handlers;
mod routes;

#[tokio::main]
async fn main() {
    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to create pool");

    let app = routes::create_router(pool);

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("Listening on {}", addr);

    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Actix Web

Handlers

// src/handlers/users.rs
use actix_web::{web, HttpResponse, Responder};
use sqlx::PgPool;
use uuid::Uuid;

use crate::entities::user::*;

pub async fn create_user(
    pool: web::Data<PgPool>,
    payload: web::Json<CreateUserRequest>,
) -> impl Responder {
    match pool.create(payload.into_inner()).await {
        Ok(user) => HttpResponse::Created().json(UserResponse::from(&user)),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

pub async fn get_user(
    pool: web::Data<PgPool>,
    path: web::Path<Uuid>,
) -> impl Responder {
    let id = path.into_inner();

    match pool.find_by_id(id).await {
        Ok(Some(user)) => HttpResponse::Ok().json(UserResponse::from(&user)),
        Ok(None) => HttpResponse::NotFound().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

pub async fn update_user(
    pool: web::Data<PgPool>,
    path: web::Path<Uuid>,
    payload: web::Json<UpdateUserRequest>,
) -> impl Responder {
    let id = path.into_inner();

    match pool.update(id, payload.into_inner()).await {
        Ok(user) => HttpResponse::Ok().json(UserResponse::from(&user)),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

pub async fn delete_user(
    pool: web::Data<PgPool>,
    path: web::Path<Uuid>,
) -> impl Responder {
    let id = path.into_inner();

    match pool.delete(id).await {
        Ok(true) => HttpResponse::NoContent().finish(),
        Ok(false) => HttpResponse::NotFound().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

pub async fn list_users(pool: web::Data<PgPool>) -> impl Responder {
    match pool.list(100, 0).await {
        Ok(users) => {
            let responses: Vec<UserResponse> = users.iter().map(UserResponse::from).collect();
            HttpResponse::Ok().json(responses)
        }
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

Rutas

// src/routes.rs
use actix_web::web;

use crate::handlers::users;

pub fn configure(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/users")
            .route("", web::post().to(users::create_user))
            .route("", web::get().to(users::list_users))
            .route("/{id}", web::get().to(users::get_user))
            .route("/{id}", web::put().to(users::update_user))
            .route("/{id}", web::delete().to(users::delete_user)),
    );
}

Main

// src/main.rs
use actix_web::{App, HttpServer, web};
use sqlx::postgres::PgPoolOptions;

mod entities;
mod handlers;
mod routes;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Failed to create pool");

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(pool.clone()))
            .configure(routes::configure)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Manejo de Errores

Mejor manejo de errores con tipos personalizados:

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

pub enum AppError {
    NotFound,
    Database(sqlx::Error),
    Validation(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "Recurso no encontrado"),
            AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error de base de datos"),
            AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
        };

        (status, Json(json!({ "error": message }))).into_response()
    }
}

impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        AppError::Database(err)
    }
}

// Uso en handler:
pub async fn get_user(
    State(pool): State<PgPool>,
    Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>, AppError> {
    let user = pool
        .find_by_id(id)
        .await?
        .ok_or(AppError::NotFound)?;

    Ok(Json(UserResponse::from(&user)))
}

Validación

Añade validación con el crate validator:

use validator::Validate;

// Añade manualmente el derive Validate a la estructura generada
// o valida antes de llamar métodos del repositorio

pub async fn create_user(
    State(pool): State<PgPool>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
    // Validación manual
    if payload.username.len() < 3 {
        return Err(AppError::Validation("Nombre de usuario muy corto".into()));
    }

    if !payload.email.contains('@') {
        return Err(AppError::Validation("Email inválido".into()));
    }

    let user = pool.create(payload).await?;
    Ok((StatusCode::CREATED, Json(UserResponse::from(&user))))
}

Paginación

Ejemplo de helper de paginación:

use serde::Deserialize;

#[derive(Deserialize)]
pub struct Pagination {
    #[serde(default = "default_limit")]
    pub limit: i64,
    #[serde(default)]
    pub offset: i64,
}

fn default_limit() -> i64 {
    20
}

pub async fn list_users(
    State(pool): State<PgPool>,
    Query(pagination): Query<Pagination>,
) -> Result<Json<Vec<UserResponse>>, AppError> {
    let users = pool
        .list(pagination.limit.min(100), pagination.offset)
        .await?;

    let responses: Vec<UserResponse> = users.iter().map(UserResponse::from).collect();
    Ok(Json(responses))
}

Ver También

  • [[Ejemplos|Ejemplos]] — Ejemplos del mundo real
  • [[Mejores Prácticas|Mejores-Prácticas]] — Guías de producción
  • [[Atributos|Atributos]] — Referencia completa de atributos

Mejores Prácticas

Guías para usar entity-derive efectivamente en producción.

Diseño de Entidades

Mantén las Entidades Enfocadas

Una entidad por tabla de base de datos. No intentes modelar relaciones complejas en una sola entidad.

// Bueno: Entidades separadas
#[derive(Entity)]
#[entity(table = "users")]
pub struct User {
    #[id]
    pub id: Uuid,
    #[field(create, update, response)]
    pub name: String,
}

#[derive(Entity)]
#[entity(table = "posts")]
pub struct Post {
    #[id]
    pub id: Uuid,
    #[field(create, response)]
    pub author_id: Uuid,  // Referencia, no embeber
    #[field(create, update, response)]
    pub title: String,
}

// Malo: Intentar embeber relaciones
pub struct User {
    pub id: Uuid,
    pub posts: Vec<Post>,  // No hagas esto
}

Usa Atributos de Campo Significativos

Sé explícito sobre el propósito de cada campo:

// Bueno: Intención clara
#[field(create, response)]      // Se establece una vez, siempre visible
pub email: String,

#[field(update, response)]      // Puede cambiar, siempre visible
pub display_name: Option<String>,

#[field(response)]              // Solo lectura, calculado/gestionado externamente
pub post_count: i64,

#[field(skip)]                  // Nunca expuesto
pub password_hash: String,

// Malo: Todo en todas partes
#[field(create, update, response)]  // ¿Es realmente necesario para todo?
pub internal_id: String,

Prefiere Option para Campos Nulables

Haz coincidir con tu esquema de base de datos:

// Base de datos: email VARCHAR NOT NULL
#[field(create, update, response)]
pub email: String,

// Base de datos: bio TEXT NULL
#[field(update, response)]
pub bio: Option<String>,

Seguridad

Siempre Usa #[field(skip)] para Datos Sensibles

// Contraseñas
#[field(skip)]
pub password_hash: String,

// Claves API
#[field(skip)]
pub api_key: String,

// Tokens internos
#[field(skip)]
pub refresh_token: Option<String>,

// PII que no debería estar en respuestas
#[field(skip)]
pub ssn: String,

// Datos de auditoría interna
#[field(skip)]
pub created_by_ip: String,

Separa Entidades Internas y Externas

Para datos solo de administrador, considera entidades separadas:

// Entidad pública
#[derive(Entity)]
#[entity(table = "users")]
pub struct User {
    #[id]
    pub id: Uuid,
    #[field(create, update, response)]
    pub name: String,
    #[field(skip)]
    pub admin_notes: Option<String>,
}

// Entidad solo admin (misma tabla, vista diferente)
#[derive(Entity)]
#[entity(table = "users", sql = "trait")]
pub struct AdminUser {
    #[id]
    pub id: Uuid,
    #[field(response)]
    pub name: String,
    #[field(update, response)]  // Ahora visible y editable
    pub admin_notes: Option<String>,
    #[field(response)]
    pub last_login_ip: Option<String>,
}

Rendimiento

Usa sql = "trait" para Consultas Complejas

No luches contra el SQL generado. Si necesitas joins o lógica compleja, impleméntalo tú mismo:

// CRUD simple - usa generación completa
#[entity(table = "categories", sql = "full")]

// Consultas complejas necesarias - implementa tú mismo
#[entity(table = "posts", sql = "trait")]

Operaciones por Lotes

Para inserciones masivas, implementa métodos personalizados:

#[entity(table = "events", sql = "trait")]
pub struct Event { /* ... */ }

pub trait EventBatchRepository {
    async fn create_batch(&self, events: Vec<CreateEventRequest>) -> Result<(), sqlx::Error>;
}

#[async_trait]
impl EventBatchRepository for PgPool {
    async fn create_batch(&self, events: Vec<CreateEventRequest>) -> Result<(), sqlx::Error> {
        let mut tx = self.begin().await?;

        for event in events {
            let entity = Event::from(event);
            let insertable = InsertableEvent::from(&entity);
            // Insertar dentro de transacción
        }

        tx.commit().await?;
        Ok(())
    }
}

Evita Consultas N+1

Usa joins en lugar de cargar entidades relacionadas una por una:

// Malo: Consultas N+1
let posts = pool.list(100, 0).await?;
for post in &posts {
    let author = pool.find_user_by_id(post.author_id).await?;  // ¡N consultas!
}

// Bueno: Una sola consulta con join
let posts_with_authors = pool.list_with_authors(100, 0).await?;  // 1 consulta

Testing

Usa Base de Datos de Prueba Separada

#[cfg(test)]
mod tests {
    use sqlx::PgPool;

    async fn setup_test_db() -> PgPool {
        let url = std::env::var("TEST_DATABASE_URL")
            .expect("TEST_DATABASE_URL must be set");

        let pool = PgPool::connect(&url).await.unwrap();

        // Ejecutar migraciones
        sqlx::migrate!("./migrations")
            .run(&pool)
            .await
            .unwrap();

        pool
    }

    #[tokio::test]
    async fn test_create_user() {
        let pool = setup_test_db().await;

        let request = CreateUserRequest {
            username: "test_user".into(),
            email: "test@example.com".into(),
        };

        let user = pool.create(request).await.unwrap();
        assert_eq!(user.username, "test_user");
    }
}

Prueba DTOs Separadamente

#[test]
fn test_user_response_excludes_password() {
    let user = User {
        id: Uuid::new_v4(),
        username: "test".into(),
        email: "test@example.com".into(),
        password_hash: "secret_hash".into(),
        created_at: Utc::now(),
    };

    let response = UserResponse::from(&user);

    // password_hash no está en UserResponse
    assert_eq!(response.username, "test");
    // No hay forma de acceder a password_hash a través de response
}

#[test]
fn test_update_request_is_partial() {
    let update = UpdateUserRequest {
        username: Some("new_name".into()),
        email: None,  // No actualizando email
    };

    assert!(update.username.is_some());
    assert!(update.email.is_none());
}

Organización del Proyecto

Estructura Recomendada

src/
├── entities/           # Definiciones de entidades
│   ├── mod.rs
│   ├── user.rs
│   ├── post.rs
│   └── comment.rs
├── repositories/       # Extensiones de repository personalizadas
│   ├── mod.rs
│   └── post_search.rs
├── handlers/           # Handlers HTTP
│   ├── mod.rs
│   ├── users.rs
│   └── posts.rs
├── services/           # Lógica de negocio
│   ├── mod.rs
│   └── auth.rs
└── main.rs

Re-exporta Tipos Generados

// src/entities/mod.rs
mod user;
mod post;

pub use user::*;
pub use post::*;

Agrupa Entidades Relacionadas

// src/entities/auth/mod.rs
mod user;
mod session;
mod api_key;

pub use user::*;
pub use session::*;
pub use api_key::*;

Errores Comunes

1. Olvidar #[field(skip)] en Campos Sensibles

// Incorrecto: password_hash estará en Response!
pub struct User {
    pub password_hash: String,
}

// Correcto
#[field(skip)]
pub password_hash: String,

2. Usar sql = "full" Cuando Necesitas Joins

Si necesitas datos relacionados, usa sql = "trait" e implementa tú mismo.

3. No Manejar Actualizaciones Opcionales

Recuerda: los campos de UpdateRequest son Option<T>. Verifica antes de aplicar:

// UpdateUserRequest generado tiene Option<String> para name
// Tu lógica de actualización debería manejar None (sin cambio) vs Some (cambio)

4. Duplicar Lógica de Negocio

Pon validación y reglas de negocio en una capa de servicio, no en handlers:

// Bueno: Capa de servicio
impl UserService {
    pub async fn create_user(&self, request: CreateUserRequest) -> Result<User, AppError> {
        self.validate_email(&request.email)?;
        self.check_username_available(&request.username).await?;
        self.pool.create(request).await.map_err(Into::into)
    }
}

// Malo: Lógica dispersa en handlers
pub async fn create_user(pool: State<PgPool>, request: Json<CreateUserRequest>) -> ... {
    // Validación aquí
    // Reglas de negocio aquí
    // Llamada al repository aquí
    // Todo mezclado
}

Lista de Verificación

Antes de desplegar:

  • Todos los campos sensibles tienen #[field(skip)]
  • Los DTOs coinciden con las expectativas del contrato API
  • Las consultas complejas usan sql = "trait"
  • Los tests de integración cubren métodos del repository
  • El manejo de errores es consistente
  • La paginación está implementada para endpoints de lista
  • Existen índices de base de datos para patrones de consulta

Ver También

  • [[Atributos|Atributos]] — Referencia completa de atributos
  • [[Ejemplos|Ejemplos]] — Ejemplos del mundo real
  • [[Frameworks Web|Frameworks-Web]] — Integración con frameworks