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

One macro to rule them all

English Русский ν•œκ΅­μ–΄ EspaΓ±ol δΈ­ζ–‡


What is it?

entity-derive is a Rust procedural macro that generates a complete domain layer from a single entity definition. Not just CRUD β€” an architectural framework with events, hooks, commands, and type-safe filtering.


The Problem

A typical Rust backend with ~10 entities means:

ComponentLines of CodeProblems
DTOs (Create, Update, Response)~60 per entityManual sync, forgotten fields
Repository trait + impl~150 per entityRuntime SQL errors, copy-paste
Entity ↔ DTO mapping~40 per entityData leaks (password_hash in Response)
Validation and hooksScattered across servicesDuplication, no single source
Events/auditMissing or ad-hocNo change history

Total: ~2500 lines of boilerplate for 10 entities. And every schema change requires manual edits in 5+ places.


The Solution

#[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)]  // Never leaks to API
    pub password_hash: String,

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

15 lines β†’ complete domain layer:

  • CreateUserRequest, UpdateUserRequest, UserResponse
  • UserRepository with type-safe SQL
  • UserEvent::Created, Updated, Deleted
  • UserHooks for business logic
  • RegisterUser, DeactivateUser commands
  • UserQuery for filtering

Simple for Beginners

Minimal example β€” 10 lines:

#[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,
}

Done. You have:

  • CreatePostRequest, UpdatePostRequest, PostResponse
  • PostRepository with create(), find_by_id(), update(), delete(), list()
  • Type-safe SQL for PostgreSQL
  • Everything works out of the box

No magic. Run cargo expand β€” you’ll see exactly the code you would write yourself. Just without errors and in seconds.


Why Events?

Problem: CRUD applications have no history. Who changed the record? When? What was there before? Audit requires separate infrastructure and discipline.

Solution: #[entity(events)] generates typed events:

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

Benefits:

  • Audit out of the box β€” subscribe to events, save to log
  • Event Sourcing β€” can restore state from event history
  • Integrations β€” Kafka, WebSocket notifications, cache invalidation
  • Debugging β€” complete change history for every entity

Why Hooks?

Problem: Business logic is scattered. Email validation β€” in controller. Password hashing β€” in service. Sending email β€” in separate worker. Where to look for user creation logic?

Solution: #[entity(hooks)] centralizes lifecycle:

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

    async fn after_create(&self, user: &User) -> Result<(), Error> {
        self.mailer.send_welcome(user).await?;  // Business action
        Ok(())
    }
}

Benefits:

  • Single place β€” all entity logic next to definition
  • Predictability β€” clear when what executes
  • Testability β€” hooks can be mocked and tested in isolation
  • Composition β€” different implementations for different contexts

Why Commands?

Problem: REST API hides intent. POST /users β€” is it registration? Admin creation? CSV import? PATCH /users/123 β€” deactivation? Email change? Ban?

Solution: #[command(...)] expresses business domain:

#[command(Register)]           // Self-registration
#[command(Invite)]             // Admin invitation
#[command(Deactivate, requires_id)]  // Account deactivation
#[command(Ban, requires_id)]   // Ban for violations

Compare:

// CRUD (what's happening?)
pool.update(user_id, UpdateUserRequest { active: Some(false), ..default() }).await?;

// Commands (clear intent)
handler.handle(DeactivateUser { id: user_id }).await?;

Benefits:

  • Self-documenting API β€” command names = business vocabulary
  • Different logic β€” Deactivate and Ban can have different side-effects
  • CQRS ready β€” commands easy to route, log, retry
  • Type safety β€” compiler verifies command exists

Why Type-Safe Filtering?

Problem: String query params are runtime error sources:

GET /users?stauts=active  // Typo β€” silently ignored
GET /users?created_at=tomorrow  // Invalid date β€” runtime panic

Solution: #[filter] generates typed struct:

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?;

Benefits:

  • Compile-time check β€” field name typo = compilation error
  • Type safety β€” can’t compare DateTime with String
  • Autocomplete β€” IDE suggests available filters
  • SQL injection protection β€” parameters are bound, not concatenated

Transparency

The macro doesn’t hide logic. Everything generated is regular Rust code you can:

  • Read β€” cargo expand shows all generated code
  • Understand β€” no runtime reflection, just structs and traits
  • Override β€” sql = "trait" and write your own SQL
  • Debug β€” compiler errors point to your code, not macro internals
// Want to understand what's generated?
cargo expand --lib | grep -A 50 "impl UserRepository"

Zero magic. If the macro breaks β€” you can always write code by hand. No lock-in.


Full Power of Rust

Compile-time Guarantees

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

This isn’t a runtime check β€œdon’t serialize this field”. It’s physical absence of the field in UserResponse struct. Can’t accidentally return it β€” the field simply doesn’t exist.

Zero-cost Abstractions

Generated code is:

  • Regular struct without Box/dyn
  • Direct sqlx calls without intermediate layers
  • #[inline] on hot paths
  • No allocations beyond necessary

Benchmark: generated repository runs at the same speed as hand-written. Because it is the same code.

Async Out of the Box

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

Full compatibility with tokio, async-std, any async runtime.

Strict Typing

// Compilation error: no such field
let query = UserQuery { naem: "test".into(), ..default() };
                        ^^^^ unknown field

// Compilation error: wrong type
let query = UserQuery { created_at_min: "yesterday".into(), ..default() };
                                        ^^^^^^^^^^^^ expected DateTime<Utc>

If code compiles β€” it works correctly.


Professional Architecture

Clean Architecture Ready

Domain Layer (entity-derive)
β”œβ”€β”€ Entities β€” #[derive(Entity)]
β”œβ”€β”€ DTOs β€” CreateRequest, UpdateRequest, Response
β”œβ”€β”€ Repository Trait β€” storage abstraction
β”œβ”€β”€ Events β€” domain events
β”œβ”€β”€ Commands β€” business operations
└── Hooks β€” lifecycle logic

Infrastructure Layer (your code)
β”œβ”€β”€ Repository Impl β€” PgPool automatic or custom
β”œβ”€β”€ Event Handlers β€” event subscriptions
β”œβ”€β”€ Command Handlers β€” business logic implementation
└── External Services β€” integrations

Clean separation. Domain knows nothing about HTTP, database, Kafka. Those are implementation details.

CQRS/Event Sourcing Ready

// 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?,
    _ => {}
}

Want simple CRUD? You got it. Want full CQRS? Enable commands and events. Architecture grows with project.

Extensibility

Level 1: Basic CRUD

#[entity(table = "users")]

Level 2: + Filtering

#[entity(table = "users")]
// + #[filter] on fields

Level 3: + Events and Hooks

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

Level 4: + CQRS Commands

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

Level 5: Full Control

#[entity(table = "users", sql = "trait", events, hooks, commands)]
// Your SQL, your logic, but keeping all DTOs and types

Start simple. Add features as you grow. Don’t rewrite β€” extend.


Security

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

skip means: this field will never appear in:

  • CreateUserRequest (can’t pass from outside)
  • UpdateUserRequest (can’t modify via API)
  • UserResponse (can’t accidentally return to client)

The only way to work with password_hash β€” directly through entity in code. Leak impossible by design.


Why This is Awesome

AspectWhat You Get
Development Speed10 entities in an hour instead of a day
ReliabilityCompile-time verification of everything
SecurityImpossible to accidentally leak data
PerformanceZero-cost, like hand-written code
ClarityTransparent generation, no magic
FlexibilityFrom simple CRUD to CQRS with one attribute
ScalabilityArchitecture grows with project
MaintainabilitySingle source of truth, fewer bugs

Documentation

TopicDescription
[[Attributes|Attributes-en]]Complete attribute reference
[[Filtering|Filtering-en]]Type-safe query filtering
[[Relations|Relations-en]]belongs_to and has_many
[[Events|Events-en]]Lifecycle events
[[Hooks|Hooks-en]]Before/after hooks
[[Commands|Commands-en]]CQRS pattern
[[Custom SQL|Custom-SQL-en]]Complex queries
[[Examples|Examples-en]]Real-world use cases
[[Web Frameworks|Web-Frameworks-en]]Axum, Actix integration
[[Best Practices|Best-Practices-en]]Production guidelines

This is not a framework that dictates how to live. This is a tool that removes routine and lets you build right.


Attributes Reference

Complete guide to all attributes supported by entity-derive.

Entity-Level Attributes

Applied to the struct with #[entity(...)]:

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

Quick Reference

AttributeRequiredDefaultDescription
tableYesβ€”Database table name
schemaNo"public"Database schema
sqlNo"full"SQL generation level
dialectNo"postgres"Database dialect
uuidNo"v7"UUID version for ID generation
soft_deleteNofalseEnable soft delete
returningNo"full"RETURNING clause mode
upsert(...)Noβ€”Generate INSERT ... ON CONFLICT upsert method
api(guard = "...")Noβ€”Enforced FromRequestParts guard in generated handlers
errorNosqlx::ErrorCustom error type
eventsNofalseGenerate lifecycle events
hooksNofalseGenerate lifecycle hooks trait
commandsNofalseEnable CQRS command pattern
transactionsNofalseGenerate transaction repository adapter

table (required)

Database table name.

#[entity(table = "users")]           // β†’ FROM users
#[entity(table = "user_profiles")]   // β†’ FROM user_profiles

schema (optional)

Database schema. Default: "public".

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

sql (optional)

SQL generation level. Default: "full".

ValueRepository TraitPgPool ImplementationUse Case
"full"YesYesStandard CRUD entities
"trait"YesNoCustom queries (joins, CTEs)
"none"NoNoDTOs only, no database
#[entity(table = "users", sql = "full")]   // Full automation (default)
#[entity(table = "users", sql = "trait")]  // Only trait, implement SQL yourself
#[entity(table = "users", sql = "none")]   // No database layer at all

dialect (optional)

Database dialect for SQL generation. Default: "postgres".

DialectAliasesClient TypeStatus
"postgres""pg", "postgresql"sqlx::PgPoolStable
"clickhouse""ch"clickhouse::ClientPlanned
"mongodb""mongo"mongodb::ClientPlanned

uuid (optional)

UUID version for auto-generated primary keys. Default: "v7".

VersionMethodProperties
"v7"Uuid::now_v7()Time-ordered, sortable (recommended)
"v4"Uuid::new_v4()Random, widely compatible
#[entity(table = "users", uuid = "v7")]     // Time-ordered (default)
#[entity(table = "sessions", uuid = "v4")]  // Random UUID

Why UUID v7?

  • Time-ordered: natural sorting by creation time
  • Better database index performance
  • No coordination required (unlike sequences)
  • Globally unique across distributed systems

soft_delete (optional)

Enable soft delete to mark records as deleted instead of removing them.

#[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>>,  // Required field
}

Generated methods:

  • delete() β€” Sets deleted_at = NOW() instead of DELETE
  • hard_delete() β€” Permanently removes the record
  • restore() β€” Sets deleted_at = NULL
  • find_by_id() / list() β€” Automatically filter deleted records
  • find_by_id_with_deleted() / list_with_deleted() β€” Include deleted records

returning (optional)

Control what data is fetched back after INSERT/UPDATE. Default: "full".

ModeSQL ClauseUse Case
"full"RETURNING *Get all fields including DB-generated (default)
"id"RETURNING idConfirm insert, return pre-built entity
"none"(no RETURNING)Fire-and-forget, fastest option
"col1, col2"RETURNING col1, col2Return specific columns
#[entity(table = "logs", returning = "none")]              // Fastest
#[entity(table = "users", returning = "full")]             // Get DB-generated values
#[entity(table = "events", returning = "id, created_at")]  // Custom columns

events(outbox) (optional)

Durable event delivery via a transactional outbox. Plain events only generates the enum; with streams, NOTIFY is fire-and-forget. events(outbox) makes every generated write insert the serialized event into the entity_outbox table in the same transaction, and the OutboxDrainer runtime (entity-core, feature outbox) delivers rows with FOR UPDATE SKIP LOCKED, exponential backoff and parking after max_attempts. At-least-once β€” handlers must be idempotent. Composes with 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(...) (optional)

Generate an upsert repository method backed by 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,
}
OptionRequiredDefaultDescription
conflictYesβ€”Comma-separated conflict target columns
actionNo"update""update" (DO UPDATE) or "nothing" (DO NOTHING)

Generated:

  • action = "update" β†’ async fn upsert(&self, dto: CreateUserRequest) -> Result<User, Error> β€” overwrites the non-conflict #[field(update)] columns (DO UPDATE SET col = EXCLUDED.col) and returns the persisted row; columns not marked updatable keep their stored values on conflict
  • action = "nothing" β†’ async fn upsert(&self, dto: CreateUserRequest) -> Result<Option<User>, Error> β€” keeps the existing row untouched; None means a conflicting row already existed

Compile-time validation:

  • conflict columns must exist and carry a uniqueness guarantee (#[id], #[column(unique)] or a matching unique_index(...))
  • requires returning = "full" (the default)
  • action = "update" needs at least one non-conflict #[field(update)] column

With streams enabled, upsert publishes a Created notification for every row it returns.

api(guard = "...") (optional)

Enforce authentication in generated handlers. security = "..." only documents auth in OpenAPI; guard injects an axum FromRequestParts extractor as a leading argument of every generated CRUD and command handler, so a failed extraction rejects the request before the handler body runs.

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 { /* ... */ }

Per-operation overrides: guard(create = "Admin", list = "none", ...) with operations create, get, update, delete, list, commands; the literal "none" disables the guard. Commands listed in public = [...] never receive a guard.

error (optional)

Custom error type for repository. Default: 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 { /* ... */ }

// Required: convert from 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 { /* ... */ }

// Generated repository uses AppError:
// impl UserRepository for PgPool {
//     type Error = AppError;
//     ...
// }

events (optional)

Generate lifecycle events enum. See [[Events]] for details.

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

Generated:

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

hooks (optional)

Generate lifecycle hooks trait. See [[Hooks]] for details.

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

Generated:

#[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 (optional)

Enable CQRS command pattern. See [[Commands]] for details.

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

transactions (optional)

Generate transaction repository adapter for type-safe multi-entity transactions.

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

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

Generated:

  • AccountTransactionRepo<'t> β€” Repository adapter for transaction context
  • TransactionWithAccount trait with with_accounts() method

Usage:

use entity_core::prelude::*;

async fn transfer(pool: &PgPool, from: Uuid, to: Uuid, amount: i64) -> Result<(), AppError> {
    Transaction::new(pool)
        .with_accounts()
        .run(|mut ctx| async move {
            let from_acc = ctx.accounts().find_by_id(from).await?
                .ok_or(AppError::NotFound)?;

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

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

            Ok(())
        })
        .await
}

Transaction methods:

  • create(dto) β€” Create entity within transaction
  • find_by_id(id) β€” Find entity by ID
  • update(id, dto) β€” Update entity
  • delete(id) β€” Delete entity (respects soft_delete)
  • list(limit, offset) β€” List entities

Features:

  • Automatic rollback on error or panic
  • Type-safe builder pattern
  • Full CRUD support within transaction

Field-Level Attributes

Applied to individual fields.

#[id]

Marks the primary key field.

Behavior:

  • Auto-generates UUID (v7 by default, configurable with uuid attribute)
  • Always included in Response DTO
  • Excluded from CreateRequest and UpdateRequest
#[id]
pub id: Uuid,

#[auto]

Marks auto-generated fields (timestamps, sequences).

Behavior:

  • Gets Default::default() in From<CreateRequest>
  • Excluded from CreateRequest and UpdateRequest
  • Can be included in Response with #[field(response)]
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,

#[field(...)]

Controls DTO inclusion. Combine multiple options:

#[field(create)]                    // Only in CreateRequest
#[field(update)]                    // Only in UpdateRequest
#[field(response)]                  // Only in Response
#[field(create, response)]          // In Create and Response
#[field(create, update, response)]  // In all three
#[field(skip)]                      // Excluded from all DTOs

create

Include field in CreateRequest DTO.

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

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

update

Include field in UpdateRequest DTO.

Important: Non-optional fields are automatically wrapped in Option<T> for partial updates.

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

// Generated:
pub struct UpdateUserRequest {
    pub name: Option<String>,  // Wrapped automatically
}

response

Include field in Response DTO.

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

// Generated:
pub struct UserResponse {
    pub id: Uuid,        // Always included (has #[id])
    pub email: String,   // Included
}

skip

Exclude field from all DTOs. Use for sensitive data.

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

Important: skip overrides all other field options. The field will only exist in:

  • Original entity struct
  • Row struct (for database reads)
  • Insertable struct (for database writes)

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

Wire a ValueObject Postgres enum into DDL generation.

#[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?;
  • Sets the DDL column type (enum fields otherwise fall back to TEXT)
  • Registers the enum’s idempotent PG_CREATE_TYPE DDL in {Entity}::MIGRATION_TYPES β€” run those before MIGRATION_UP
  • The declared name is checked against the enum’s PG_TYPE constant at compile time; a mismatch fails the build
  • The ValueObject’s opt-in sqlx flag emits sqlx::Type / Encode / Decode impls; omit it if you already derive sqlx::Type

#[column(ci)]

Case-insensitive text column. Generated find_by_{field} / exists_by_{field} compare via LOWER(col) = LOWER($1), and Option<T> fields unwrap to T in the lookup signature (a NULL column never matches a probe).

#[field(create, update, response)]
#[column(unique, ci)]
pub username: Option<String>,

let user = pool.find_by_username(handle).await?;   // handle: String
let taken = pool.exists_by_username(handle).await?;
  • With migrations, unique + ci emits CREATE UNIQUE INDEX {table}_{column}_lower_key ON {table} (LOWER({column})) instead of an inline UNIQUE constraint
  • With typed_constraints, violations of that index resolve to the field like any other unique constraint

#[owner]

Row-level ownership scoping. Marks the column carrying the owning principal’s id; the repository gains scoped methods that never reveal whether a row exists for another owner and respect 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?;

Generated: find_by_id_scoped, list_by_owner, update_scoped (when update fields exist, returns None if the row isn’t theirs), delete_scoped. At most one #[owner] field; combining with #[id] is rejected at compile time.

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

Generate query filter fields. See [[Filtering]] for details.

#[filter]              // Exact match: WHERE field = $n
#[filter(eq)]          // Same as above
#[filter(like)]        // Pattern match: WHERE field ILIKE $n
#[filter(range)]       // Range: WHERE field >= $n AND field <= $m

#[belongs_to(Entity)]

Foreign key relation. See [[Relations]] for details.

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

Generated: find_user() method in repository.

#[join(...)] β€” joined read models

Declares an INNER JOIN contributing columns to a generated {Entity}View read model. Repeatable; the joined columns’ Rust types are part of the declaration (the macro cannot see the foreign table’s schema).

#[derive(Entity)]
#[entity(table = "tickets")]
#[join(airports as origin, on = origin_iata = iata, fields(
    lat as origin_lat: f64,
    lon as origin_lon: f64,
    city as origin_city: String
))]
#[join(airports as dest, on = destination_iata = iata, fields(
    lat as destination_lat: f64,
    lon as destination_lon: f64,
    city as destination_city: String
))]
pub struct Ticket { /* ... */ }

Generated:

  • TicketView β€” flat struct with every entity column plus the joined columns; derives sqlx::FromRow and serde::Serialize
  • TicketView::SELECT β€” the canonical SELECT ... FROM ... JOIN ... fragment (no WHERE) for custom filters:
    let rows: Vec<TicketView> = sqlx::query_as(::sqlx::AssertSqlSafe(format!(
        "{} WHERE tickets.verified = true ORDER BY tickets.departs_at ASC",
        TicketView::SELECT
    ))).fetch_all(&pool).await?;
  • TicketView::find_by_id(pool, id) and TicketView::list(pool, limit, offset)

Base-table columns are qualified with the table name; a join column that does not match an entity column fails the build.

#[has_many(Entity)]

One-to-many relation (entity-level). See [[Relations]] for details.

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

Generated: find_posts() method in repository.

#[projection(Name: fields)]

Generate partial view struct (entity-level).

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

Generated:

  • UserPublic { id, name, avatar }
  • UserAdmin { id, name, email, role }
  • From<User> implementations
  • find_by_id_public(), find_by_id_admin() methods

Command Attributes

Applied at entity level with #[command(...)].

Quick Reference

SyntaxEffect
#[command(Name)]Uses all #[field(create)] fields
#[command(Name: field1, field2)]Uses only specified fields (adds requires_id)
#[command(Name, requires_id)]Adds ID field, no other fields
#[command(Name, source = "create")]Explicitly use create fields (default)
#[command(Name, source = "update")]Use update fields (optional, adds requires_id)
#[command(Name, source = "none")]No payload fields
#[command(Name, payload = "Type")]Uses custom payload struct
#[command(Name, result = "Type")]Uses custom result type
#[command(Name, kind = "create")]Hint: creates entity (default)
#[command(Name, kind = "update")]Hint: modifies entity
#[command(Name, kind = "delete")]Hint: removes entity (returns ())
#[command(Name, kind = "custom")]Hint: custom operation

See [[Commands]] for detailed documentation.

Complete Example

#[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>,
}

Decision Matrix

I want to…Attributes
Auto-generate primary key#[id]
Use random UUIDuuid = "v4" on entity
Use time-ordered UUIDuuid = "v7" (default)
Accept in POST body#[field(create)]
Accept in PATCH body#[field(update)]
Return in API response#[field(response)]
Accept and return#[field(create, update, response)]
Hide from all APIs#[field(skip)]
Auto-generate timestamp#[auto] + #[field(response)]
Read-only (DB managed)#[field(response)] only
Write-only (no return)#[field(create)] only
Custom SQL queriessql = "trait"
DTOs only, no DBsql = "none"
Soft delete recordssoft_delete on entity
Custom error typeerror = "MyError" on entity
Filter by exact value#[filter] on field
Filter by pattern#[filter(like)] on field
Filter by range#[filter(range)] on field
Track entity changesevents on entity
Run code on lifecyclehooks on entity
Use domain commandscommands on entity + #[command(...)]
Use multi-entity transactionstransactions on entity
Define relationship#[belongs_to(Entity)] or #[has_many(Entity)]
Partial entity view#[projection(Name: fields)]

Update DTOs: PATCH semantics

Generated updates are true partial patches: the UPDATE SET clause is built at runtime from the fields actually present, so omitted fields stay untouched. Nullable columns use double-Option (None = leave, Some(None) = SET NULL, Some(Some(v)) = SET v) via 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?;

migrations(...) options

Beyond the plain flag, migrations accepts DDL options: touch_updated_at (shared plpgsql function + per-table BEFORE UPDATE trigger keeping updated_at fresh; requires an updated_at field, checked at compile time), audit (entity_audit_log table + trigger recording to_jsonb(OLD/NEW) diffs) and extensions = "pg_trgm, pgcrypto" (idempotent CREATE EXTENSION). New constants: MIGRATION_TRIGGERS (run after MIGRATION_UP) and MIGRATION_EXTENSIONS (run before).

#[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]

Optimistic locking. Marks an integer column (i16/i32/i64); the Update DTO gains a required expected_version, the generated UPDATE bumps the column and only applies while the stored version still matches β€” a stale write fails with a conflict error instead of overwriting newer data. DDL defaults to INTEGER NOT NULL DEFAULT 0. Applies to plain, scoped and transactional updates.

#[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 (optional)

The macro knows every constraint it creates. With this flag, generated write methods resolve violated constraint names (unique columns, belongs_to foreign keys, column checks, unique_index names) and surface entity_core::ConstraintError { kind, constraint, field } instead of a raw driver error. Requires a custom error type implementing From<ConstraintError>; without the flag nothing changes.

#[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(...))]

Flatten a value object to prefixed scalar columns. DDL, Row struct, CRUD SQL and dynamic PATCH updates operate on price_amount_cents / price_currency, while DTOs and the entity carry the struct itself. The declared shape is destructured against the real struct at compile time β€” a renamed, retyped, missing or extra field fails the build. Option<T> parents are not supported yet.

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,
}

garde feature (validation backend)

Enables garde::Validate on generated DTOs as a maintained alternative to validator. Field #[validate(...)] rules (length, range, email, url, pattern) are translated to garde syntax; unconstrained fields get garde(skip); Option-wrapped Update DTO fields validate the inner value via inner(...). When both validate and garde are enabled, validate takes precedence.

#[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(...) (optional, with typed_constraints)

Declare constraints the macro cannot infer β€” foreign keys over natural keys, custom-named CHECK constraints, indexes from hand-written migrations β€” so violations resolve to ConstraintError with the declared field. Kinds: unique, foreign_key, check. Custom entries take precedence over derived entries with the same name. Requires typed_constraints.

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

Composite lookups from unique_index(...)

Every multi-column unique_index(a, b) declaration generates a lookup pair on the repository:

#[derive(Entity)]
#[entity(table = "kyc_sessions", unique_index(provider, external_id))]
pub struct KycSession {
    #[id] pub id: Uuid,
    #[field(create, response)] pub provider: String,
    #[field(create, response)] pub external_id: String,
}

let session = pool.find_by_provider_and_external_id(provider, external_id).await?;
let taken = pool.exists_by_provider_and_external_id(provider, external_id).await?;
  • find_by_{a}_and_{b}(a, b) -> Option<Entity> and exists_by_{a}_and_{b}(a, b) -> bool
  • Parameter types resolve from the matching entity fields; an index column that does not match any entity column fails the build (same rule as upsert conflict columns)
  • Non-unique index(...) declarations generate no lookups

Transactional upsert

With both transactions and upsert(...), the {Entity}TransactionRepo adapter exposes upsert with the same SQL and action semantics as the pool method, executed on the transaction handle β€” for flows where the upsert must share atomicity with adjacent statements.

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?;

Real-World Examples

Practical examples for common use cases.

User Management

Classic user entity with authentication fields:

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)]  // Accept but never return
    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)]  // Internal audit field
    pub last_login_ip: Option<String>,

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

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

Usage:

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

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

// Response is safe - no password_hash, no last_login_ip
let response = UserResponse::from(&user);

Blog System

Posts with author relationship:

#[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)]  // Set once, can't change author
    pub author_id: Uuid,

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

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

    #[field(response)]  // Read-only, managed by triggers
    pub view_count: i64,

    #[field(skip)]  // Internal moderation
    pub moderation_status: String,

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

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

Categories with many-to-many:

#[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,  // Computed field

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

E-Commerce

Product catalog:

#[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)]  // Internal cost tracking
    pub cost_cents: i64,

    #[field(skip)]  // Supplier info
    pub supplier_id: Option<Uuid>,

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

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

Orders with status tracking:

#[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,  // Generated by DB sequence

    #[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)]  // Payment processor data
    pub payment_intent_id: Option<String>,

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

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

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

Multi-Tenant SaaS

Organization-scoped entities:

#[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,  // Immutable after creation

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

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

    #[field(skip)]  // Billing info
    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)]  // Set once
    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>,
}

DTOs Only (No Database)

For API contracts without persistence:

#[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,
}

This generates only:

  • CreateWebhookPayloadRequest
  • WebhookPayloadResponse
  • From implementations

No repository, no SQL, no Row/Insertable structs.

Custom Queries Only

When standard CRUD isn’t enough:

#[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>,
}

// Implement custom queries yourself:
impl AnalyticsEventRepository for PgPool {
    type Error = sqlx::Error;

    async fn create(&self, dto: CreateAnalyticsEventRequest) -> Result<AnalyticsEvent, Self::Error> {
        // Batch insert, partitioned tables, etc.
    }

    async fn find_by_id(&self, id: Uuid) -> Result<Option<AnalyticsEvent>, Self::Error> {
        // Query with time-based partitioning
    }

    // Custom methods beyond CRUD:
    async fn aggregate_by_event(&self, start: DateTime<Utc>, end: DateTime<Utc>)
        -> Result<Vec<EventAggregate>, Self::Error> {
        // Complex aggregation query
    }
}

Bulk Operations

Every repository ships batch primitives: find_by_ids (WHERE id = ANY($1), single round-trip), atomic create_many (one transaction, a failing row rolls back the whole batch) and soft-delete-aware delete_many returning the actually-affected count. With events delivery (streams or outbox) per-row events are emitted inside the same transaction.

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?;

Query Filtering

Generate type-safe query structs for filtering entities. Filtering enables pagination, search, and range queries with compile-time safety.

Quick Start

#[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>,
}

Generated Code

Query Struct

/// Query parameters for filtering Product entities.
#[derive(Debug, Clone, Default)]
pub struct ProductQuery {
    /// Filter by exact name match.
    pub name: Option<String>,

    /// Filter by description pattern (ILIKE).
    pub description: Option<String>,

    /// Filter by minimum price.
    pub price_from: Option<i64>,

    /// Filter by maximum price.
    pub price_to: Option<i64>,

    /// Filter by exact category_id match.
    pub category_id: Option<Uuid>,

    /// Filter by minimum created_at.
    pub created_at_from: Option<DateTime<Utc>>,

    /// Filter by maximum created_at.
    pub created_at_to: Option<DateTime<Utc>>,

    /// Maximum number of results.
    pub limit: Option<i64>,

    /// Number of results to skip.
    pub offset: Option<i64>,
}

Repository Method

#[async_trait]
pub trait ProductRepository: Send + Sync {
    // ... standard CRUD methods

    /// Query products with filters.
    async fn query(&self, query: ProductQuery) -> Result<Vec<Product>, Self::Error>;
}

Generated SQL

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

Filter Types

Exact Match (#[filter] or #[filter(eq)])

Filters where field equals the provided value.

#[filter]
pub status: String,

#[filter(eq)]  // Same as above
pub category_id: Uuid,

Generated:

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

SQL:

WHERE status = $1
  AND category_id = $2

Pattern Match (#[filter(like)])

Filters using case-insensitive pattern matching (ILIKE).

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

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

Generated:

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

SQL:

WHERE name ILIKE $1
  AND description ILIKE $2

Usage:

let query = ProductQuery {
    name: Some("%widget%".into()),  // Contains "widget"
    description: Some("premium%".into()),  // Starts with "premium"
    ..Default::default()
};

Range Filter (#[filter(range)])

Filters within a range (inclusive).

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

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

Generated:

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

Usage Examples

Basic Filtering

// Find products by category
let query = ProductQuery {
    category_id: Some(electronics_category_id),
    ..Default::default()
};
let products = repo.query(query).await?;

Pagination

// Get page 2 (20 items per page)
let query = ProductQuery {
    limit: Some(20),
    offset: Some(20),
    ..Default::default()
};
let products = repo.query(query).await?;

Combined Filters

// Search for affordable electronics
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?;

Date Range

// Get products created this month
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?;

API Endpoint Integration

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))
}

With Soft Delete

When soft_delete is enabled, the query automatically excludes deleted records:

#[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>>,
}

Generated SQL:

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

Additional method for including deleted:

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

Custom Query Extensions

For complex queries, use sql = "trait" and implement custom filtering:

#[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())
    }
}

Best Practices

  1. Default pagination β€” Always apply sensible limits to prevent large result sets
  2. Validate patterns β€” Sanitize LIKE patterns to prevent SQL issues
  3. Index filtered columns β€” Create database indexes for frequently filtered fields
  4. Use specific filters β€” Prefer exact match over pattern match when possible
  5. Combine with sorting β€” Consider adding sort fields to your query struct

See Also

  • [[Attributes]] β€” Complete attribute reference
  • [[Custom SQL]] β€” Complex custom queries
  • [[Relations]] β€” Filtering with relationships

Sorting & Keyset Pagination

Mark sortable columns with #[sort]: the Query struct gains a whitelisted {Entity}SortField selector (one Asc/Desc variant per column, snake_case JSON), so user input can never inject SQL. Every repository also gets list_after keyset pagination β€” with default UUIDv7 ids the id-ordered walk is chronologically stable and stays fast on deep pages, unlike 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?;

#[filter(search)] on a text column adds a fuzzy substring filter (col ILIKE '%' || $n || '%', the term is bound). With migrations, the matching gin_trgm_ops index lands in MIGRATION_UP and pg_trgm is added to MIGRATION_EXTENSIONS automatically. Compile-time check: the field must be a String.

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

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

Entity Relations

Define relationships between entities using #[belongs_to] and #[has_many]. Relations generate type-safe navigation methods in repositories.

Quick Start

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

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

// Child entity
#[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,
}

Generated Code

For #[has_many(Post)] on User

#[async_trait]
impl UserRepository for PgPool {
    // ... standard CRUD methods

    /// Find all posts belonging to this user.
    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())
    }
}

For #[belongs_to(User)] on Post

#[async_trait]
impl PostRepository for PgPool {
    // ... standard CRUD methods

    /// Find the user this post belongs to.
    async fn find_user(&self, id: Uuid) -> Result<Option<User>, Self::Error> {
        // First get the post to find 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)
        }
    }
}

Relation Types

belongs_to (Many-to-One)

A child entity references a parent via foreign key.

#[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,
}

Generated methods:

  • find_post(comment_id) β†’ Option<Post>
  • find_author(comment_id) β†’ Option<User> (note: method name derived from field name without _id)

has_many (One-to-Many)

A parent entity has multiple children.

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

Generated methods:

  • find_posts(user_id) β†’ Vec<Post>
  • find_comments(user_id) β†’ Vec<Comment>

has_many through (Many-to-Many)

Declare the junction table with through and the repository gains a JOIN-backed lookup plus link management; migrations emits MIGRATION_JUNCTIONS (composite primary key over both foreign keys, ON DELETE CASCADE on each side).

#[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?;

Generated methods: find_users (INNER JOIN), add_user (idempotent, ON CONFLICT DO NOTHING), remove_user (false when not linked), has_user (SELECT EXISTS).

Usage Examples

// Get user with their 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)
    }
}

// Get post with author
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)
}

Building Response DTOs

#[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)
}

Nested Loading with API

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

#[derive(Serialize)]
pub struct UserProfile {
    pub user: UserResponse,
    pub posts: Vec<PostResponse>,
    pub post_count: usize,
}

async fn get_user_profile(
    Path(user_id): Path<Uuid>,
    pool: Extension<PgPool>,
) -> Result<Json<UserProfile>, AppError> {
    let user = pool.find_by_id(user_id).await?
        .ok_or(AppError::NotFound)?;

    let posts = pool.find_posts(user_id).await?;

    Ok(Json(UserProfile {
        user: UserResponse::from(&user),
        post_count: posts.len(),
        posts: posts.into_iter().map(PostResponse::from).collect(),
    }))
}

Multiple Relations

An entity can have multiple relations:

#[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,
}

Generated for Organization:

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

Generated for Project:

  • find_organization(project_id)
  • find_owner(project_id)

Custom Joins with sql = β€œtrait”

For complex queries with eager loading, use custom SQL:

#[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> {
        // Single query with 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())
    }
}

With Filtering

Combine relations with query filtering:

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

    #[field(create, response)]
    #[belongs_to(User)]
    #[filter]  // Enable filtering by 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>,
}

Usage:

// Get posts by specific user with title filter
let query = PostQuery {
    user_id: Some(user_id),
    title: Some("%rust%".into()),
    limit: Some(20),
    ..Default::default()
};
let posts = pool.query(query).await?;

Best Practices

  1. Avoid N+1 queries β€” Use joins for eager loading when fetching multiple related entities
  2. Use pagination β€” Always limit has_many results
  3. Consider data access patterns β€” Add indexes on foreign key columns
  4. Cache when appropriate β€” Cache frequently accessed related data
  5. Use projections β€” Fetch only needed fields for related entities

See Also

  • [[Filtering]] β€” Query filtering
  • [[Custom SQL]] β€” Complex joins and queries
  • [[Best Practices]] β€” Performance tips

Lifecycle Events

Generate domain events for entity lifecycle changes. Events enable audit logging, event sourcing, and integration with message queues.

Quick Start

#[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>,
}

Generated Code

The events attribute generates an event enum:

/// Generated by entity-derive
#[derive(Debug, Clone)]
pub enum OrderEvent {
    /// Entity was created.
    Created(Order),

    /// Entity was updated.
    Updated {
        id: Uuid,
        changes: UpdateOrderRequest,
    },

    /// Entity was deleted.
    Deleted(Uuid),
}

Usage Examples

Basic Event Publishing

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?;

    // Publish event after successful creation
    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)
}

Audit Logging

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();
    }
}

Message Queue Integration

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();
    }
}

Event Sourcing Pattern

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
    }
}

With Soft Delete

When soft_delete is enabled, additional events are generated:

#[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>>,
}

Generated:

pub enum DocumentEvent {
    Created(Document),
    Updated { id: Uuid, changes: UpdateDocumentRequest },
    Deleted(Uuid),       // Soft delete
    Restored(Uuid),      // Restored from soft delete
    HardDeleted(Uuid),   // Permanent delete
}

Best Practices

  1. Publish after commit β€” Only publish events after the database transaction succeeds
  2. Idempotent handlers β€” Event handlers should be idempotent for at-least-once delivery
  3. Include context β€” Consider adding metadata (user_id, timestamp, correlation_id)
  4. Async processing β€” Use background workers for heavy event processing
  5. Dead letter queue β€” Handle failed events gracefully

Combining with Hooks

Events and hooks work well together:

#[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> {
        // Publish event in hook
        self.bus.publish(OrderEvent::Created(entity.clone())).await;
        Ok(())
    }

    async fn after_update(&self, entity: &Order) -> Result<(), Self::Error> {
        // Events can be published here too
        Ok(())
    }

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

See Also

  • [[Hooks]] β€” Execute custom logic on lifecycle events
  • [[Commands]] β€” CQRS pattern with command events
  • [[Best Practices]] β€” Production tips

Real-Time Streams

Subscribe to entity changes in real-time using Postgres LISTEN/NOTIFY. Streams enable live dashboards, instant notifications, cache invalidation, and event-driven architectures.

Quick Start

#[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,
}

Requirements:

  • Entity must derive Serialize and Deserialize (for JSON payloads)
  • Both events and streams attributes are required
  • Enable the streams feature in Cargo.toml
[dependencies]
entity-derive = { version = "0.3", features = ["postgres", "streams"] }
serde = { version = "1", features = ["derive"] }

Generated Code

The streams attribute generates:

Channel Constant

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

Subscriber Struct

/// Subscriber for real-time Order changes.
pub struct OrderSubscriber {
    listener: PgListener,
}

impl OrderSubscriber {
    /// Connect and subscribe to the channel.
    pub async fn new(pool: &PgPool) -> Result<Self, sqlx::Error>;

    /// Wait for next event (blocking).
    pub async fn recv(&mut self) -> Result<OrderEvent, StreamError<sqlx::Error>>;

    /// Check for event without blocking.
    pub async fn try_recv(&mut self) -> Result<Option<OrderEvent>, StreamError<sqlx::Error>>;
}

Automatic Notifications

CRUD operations automatically emit events:

// In generated create() method:
async fn create(&self, dto: CreateOrderRequest) -> Result<Order, Self::Error> {
    let order = /* insert */;

    // Auto-generated notification
    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)
}

Usage Examples

Basic Subscription

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!("New order: {}", order.id);
                    }
                    OrderEvent::Updated { old, new } => {
                        println!("Order {} updated: {} -> {}", new.id, old.status, new.status);
                    }
                    OrderEvent::HardDeleted { id } => {
                        println!("Order {} deleted", id);
                    }
                    _ => {}
                }
            }
            Err(StreamError::Database(e)) => {
                eprintln!("Database error: {}", e);
                break;
            }
            Err(StreamError::Deserialize(e)) => {
                eprintln!("Invalid event payload: {}", e);
            }
        }
    }

    Ok(())
}

Real-Time Dashboard (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,
        }
    }
}

Cache Invalidation

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();
                }
                _ => {}
            }
        }
    }
}

Background Worker with 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!("Stream error: {:?}", e);
                        tokio::time::sleep(Duration::from_secs(1)).await;
                    }
                }
            }
            _ = shutdown.changed() => {
                println!("Shutting down notification worker");
                break;
            }
        }
    }
}

Error Handling

use entity_derive::StreamError;

match subscriber.recv().await {
    Ok(event) => { /* process */ }
    Err(StreamError::Database(sqlx_error)) => {
        // Connection lost, query failed, etc.
        // Subscriber will auto-reconnect on next recv()
    }
    Err(StreamError::Deserialize(message)) => {
        // Invalid JSON payload
        // Log and continue - don't crash the loop
    }
}

Architecture

CRUD Operation (create/update/delete)
         β”‚
         β–Ό
    pg_notify(channel, event_json)
         β”‚
         β–Ό
    Postgres NOTIFY
         β”‚
    β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
    β–Ό         β–Ό
Subscriber  Subscriber  (multiple listeners)
    β”‚         β”‚
    β–Ό         β–Ό
 WebSocket   Cache
 Dashboard   Invalidator

Best Practices

  1. Reconnection β€” PgListener auto-reconnects; design your loop to handle temporary failures
  2. Idempotency β€” Events may be delivered multiple times; handlers should be idempotent
  3. Payload size β€” Keep entities small; large payloads may hit Postgres limits
  4. Separate pools β€” Use dedicated connection pool for listeners to avoid blocking queries
  5. Monitoring β€” Log stream errors and track event processing latency
  6. Graceful shutdown β€” Use select! with shutdown signal to clean up resources

With Soft Delete

When soft_delete is enabled, additional events are available:

#[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>>,
}

// Events include:
// - DocumentEvent::SoftDeleted { id }
// - DocumentEvent::Restored { id }
// - DocumentEvent::HardDeleted { id }

See Also

  • [[Events]] β€” Event enum without real-time streaming
  • [[Hooks]] β€” Execute custom logic on lifecycle events
  • [[Best Practices]] β€” Production tips

Lifecycle Hooks

Execute custom logic before and after entity operations. Hooks enable validation, normalization, side effects, and authorization.

Quick Start

#[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>,
}

Generated Code

The hooks attribute generates an async trait:

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

    /// Called before creating a new entity.
    /// Modify the DTO or return an error to abort.
    async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;

    /// Called after entity creation.
    async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;

    /// Called before updating an entity.
    /// Modify the DTO or return an error to abort.
    async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>;

    /// Called after entity update.
    async fn after_update(&self, entity: &User) -> Result<(), Self::Error>;

    /// Called before deleting an entity.
    /// Return an error to abort.
    async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>;

    /// Called after entity deletion.
    async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}

Implementation Example

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> {
        // Normalize email
        dto.email = dto.email.trim().to_lowercase();

        // Validate email format
        if !dto.email.contains('@') {
            return Err(AppError::Validation("Invalid email format".into()));
        }

        // Check for duplicate email
        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 already registered".into()));
        }

        Ok(())
    }

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

        // Cache the new user
        self.cache.set(&format!("user:{}", entity.id), entity).await?;

        Ok(())
    }

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

            // Check for duplicate (excluding current user)
            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 already in use".into()));
            }
        }

        Ok(())
    }

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

        Ok(())
    }

    async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error> {
        // Check if user can be deleted
        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("Cannot delete user with pending orders".into()));
        }

        Ok(())
    }

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

        // Clean up related data
        sqlx::query("DELETE FROM user_sessions WHERE user_id = $1")
            .bind(id)
            .execute(&self.pool)
            .await?;

        Ok(())
    }
}

Use Cases

Validation

async fn before_create(&self, dto: &mut CreateProductRequest) -> Result<(), Self::Error> {
    // Price validation
    if dto.price_cents <= 0 {
        return Err(AppError::Validation("Price must be positive".into()));
    }

    // SKU format validation
    if !dto.sku.chars().all(|c| c.is_alphanumeric() || c == '-') {
        return Err(AppError::Validation("Invalid SKU format".into()));
    }

    Ok(())
}

Normalization

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

    // Normalize name
    dto.name = dto.name.trim().to_string();

    // Capitalize first letter of each word
    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(())
}

Authorization

async fn before_update(&self, id: &Uuid, _dto: &mut UpdatePostRequest) -> Result<(), Self::Error> {
    // Get current user from context (e.g., thread-local or passed via self)
    let current_user = self.current_user()?;

    // Check ownership
    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("Cannot edit other user's posts".into()));
    }

    Ok(())
}

Side Effects

async fn after_create(&self, entity: &Order) -> Result<(), Self::Error> {
    // Update inventory
    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?;
    }

    // Send notification
    self.notifications.send_order_confirmation(entity).await?;

    // Schedule fulfillment job
    self.job_queue.enqueue(FulfillOrderJob { order_id: entity.id }).await?;

    Ok(())
}

Audit Logging

async fn after_update(&self, entity: &User) -> Result<(), Self::Error> {
    sqlx::query(
        "INSERT INTO audit_log (entity_type, entity_id, action, performed_by, performed_at)
         VALUES ('user', $1, 'update', $2, NOW())"
    )
    .bind(entity.id)
    .bind(self.current_user_id())
    .execute(&self.pool)
    .await?;

    Ok(())
}

With Soft Delete

When soft_delete is enabled, additional hooks are generated:

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

Generated hooks:

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

    // Standard CRUD hooks...
    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>;  // Soft delete
    async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;

    // Soft delete specific hooks
    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>;
}

With Commands

When commands and hooks are both enabled, command hooks are generated:

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

Additional hooks:

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

    // Standard CRUD hooks...

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

Usage:

async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error> {
    match cmd {
        OrderCommand::Place(place) => {
            // Validate order can be placed
            if place.items.is_empty() {
                return Err(AppError::Validation("Order must have items".into()));
            }
        }
        OrderCommand::Cancel(cancel) => {
            // Check if order can be cancelled
            let order = self.find_order(cancel.id).await?;
            if order.status == "shipped" {
                return Err(AppError::Forbidden("Cannot cancel shipped order".into()));
            }
        }
    }
    Ok(())
}

async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error> {
    match (cmd, result) {
        (OrderCommand::Place(_), OrderCommandResult::Place(order)) => {
            self.send_order_confirmation(order).await?;
        }
        (OrderCommand::Cancel(_), OrderCommandResult::Cancel) => {
            // Refund logic
        }
    }
    Ok(())
}

Best Practices

  1. Keep hooks fast β€” Long-running operations should be async jobs
  2. Use transactions β€” Wrap hook + repository call in a transaction
  3. Handle errors gracefully β€” Return meaningful error types
  4. Don’t duplicate logic β€” Use hooks for cross-cutting concerns
  5. Test hooks independently β€” Unit test hook implementations

Error Handling Pattern

#[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 required".into()));
        }
        Ok(())
    }
}

See Also

  • [[Events]] β€” Lifecycle events for audit logging
  • [[Commands]] β€” CQRS pattern with command hooks
  • [[Best Practices]] β€” Production tips

CQRS Commands

Define business-oriented commands instead of generic CRUD. Commands bring domain language to your API and enable the Command Query Responsibility Segregation (CQRS) pattern.

Quick Start

#[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,
}

Generated Code

Command Structs

/// Command payload for Register operation on User.
#[derive(Debug, Clone)]
pub struct RegisterUser {
    pub email: String,
    pub name: String,
}

/// Command payload for UpdateEmail operation on User.
#[derive(Debug, Clone)]
pub struct UpdateEmailUser {
    pub id: Uuid,
    pub email: String,
}

/// Command payload for Deactivate operation on User.
#[derive(Debug, Clone)]
pub struct DeactivateUser {
    pub id: Uuid,
}

Command Enum

/// Command enum for User entity.
#[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",
        }
    }
}

Result Enum

/// Result enum for User command execution.
#[derive(Debug, Clone)]
pub enum UserCommandResult {
    Register(User),
    UpdateEmail(User),
    Deactivate,
}

Handler Trait

/// Async trait for handling User commands.
#[async_trait]
pub trait UserCommandHandler: Send + Sync {
    type Error: std::error::Error + Send + Sync;
    type Context: Send + Sync;

    /// Dispatch command to appropriate handler.
    async fn handle(&self, cmd: UserCommand, ctx: &Self::Context)
        -> Result<UserCommandResult, Self::Error>;

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

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

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

Command Syntax Reference

Basic Command

Uses all #[field(create)] fields:

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

Specific Fields

Uses only specified fields (adds requires_id automatically):

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

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

ID-Only Command

Adds only the ID field:

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

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

Custom Payload

Uses an external struct:

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

#[command(Transfer, payload = "TransferPayload")]
// Uses TransferPayload directly

Custom Result

Uses a custom result type:

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

#[command(Transfer, payload = "TransferPayload", result = "TransferResult")]
// Returns TransferResult instead of entity

Source Options

Control which fields are used:

#[command(Create, source = "create")]     // Uses #[field(create)] fields (default)
#[command(Modify, source = "update")]     // Uses #[field(update)] fields (optional)
#[command(Ping, source = "none")]         // No payload fields

Kind Hints

Affect result type inference:

#[command(Create, kind = "create")]   // Returns entity (default)
#[command(Update, kind = "update")]   // Returns entity
#[command(Remove, kind = "delete")]   // Returns ()
#[command(Process, kind = "custom")]  // Inferred from source

Implementation Example

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>
    {
        // Validate
        if cmd.email.is_empty() {
            return Err(AppError::Validation("Email required".into()));
        }

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

        // Persist
        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?;

        // Side effects
        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>
    {
        // Authorization check
        if ctx.user_id != Some(cmd.id) {
            return Err(AppError::Forbidden("Cannot update other user's email".into()));
        }

        // Update
        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?;

        // Send verification
        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(())
    }
}

Using Commands

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!(),
    }
}

// Or call specific handler directly
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
}

EntityCommand Trait

All command enums implement the EntityCommand trait:

use entity_derive::{EntityCommand, CommandKind};

let cmd = UserCommand::Register(register_data);

// Get command metadata
assert_eq!(cmd.name(), "Register");
assert!(matches!(cmd.kind(), CommandKind::Create));

// Pattern matching
match cmd.kind() {
    CommandKind::Create => println!("Creating entity"),
    CommandKind::Update => println!("Updating entity"),
    CommandKind::Delete => println!("Deleting entity"),
    CommandKind::Custom => println!("Custom operation"),
}

Command Hooks

When commands and hooks are both enabled:

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

Generated hooks:

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

    // Standard CRUD hooks...

    // Command-specific hooks
    async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error>;
    async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error>;
}

Usage:

async fn before_command(&self, cmd: &OrderCommand) -> Result<(), Self::Error> {
    // Log command
    tracing::info!(command = cmd.name(), "Processing command");

    // Authorize
    match cmd {
        OrderCommand::Cancel(c) => {
            let order = self.find_order(c.id).await?;
            if order.status == "shipped" {
                return Err(AppError::Forbidden("Cannot cancel shipped order".into()));
            }
        }
        _ => {}
    }

    Ok(())
}

async fn after_command(&self, cmd: &OrderCommand, result: &OrderCommandResult) -> Result<(), Self::Error> {
    // Audit log
    match (cmd, result) {
        (OrderCommand::Place(c), OrderCommandResult::Place(order)) => {
            self.audit_log("order_placed", order.id).await?;
        }
        (OrderCommand::Cancel(c), OrderCommandResult::Cancel) => {
            self.audit_log("order_cancelled", c.id).await?;
        }
    }

    Ok(())
}

Best Practices

  1. Domain language β€” Use business terms: RegisterUser not CreateUser
  2. Single responsibility β€” One command = one business operation
  3. Explicit intent β€” Command names should describe the action
  4. Validation in handlers β€” Keep validation logic in command handlers
  5. Idempotent when possible β€” Design commands to be safely retried
  6. Use context β€” Pass request metadata (user, correlation ID) via context

CQRS Pattern

Commands are one half of CQRS. Combine with projections for the query side:

#[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 { /* ... */ }

// Commands (write side)
let result = handler.handle(OrderCommand::Place(place_order), &ctx).await?;

// Queries (read side)
let summary = repo.find_by_id_summary(order_id).await?;
let details = repo.find_by_id_details(order_id).await?;

See Also

  • [[Hooks]] β€” Lifecycle hooks including command hooks
  • [[Events]] β€” Event generation for audit logging
  • [[Attributes]] β€” Complete attribute reference

Transactions

Type-safe multi-entity transactions with automatic commit/rollback.

What are Transactions?

A database transaction is a way to group multiple database operations into a single atomic unit. This means:

  • All or nothing: Either ALL operations succeed, or NONE of them are applied
  • Automatic rollback: If any operation fails, all previous changes are automatically undone
  • Data consistency: Your database never ends up in an inconsistent state

Why do you need transactions?

Imagine you’re building a banking app and need to transfer money between accounts:

1. Subtract $100 from Account A
2. Add $100 to Account B

Without transactions, if step 1 succeeds but step 2 fails (network error, database crash, etc.), you’ve just lost $100! The money was subtracted from A but never added to B.

With transactions, if step 2 fails, step 1 is automatically rolled back. The money stays in Account A as if nothing happened.

Enabling Transactions

Add the transactions attribute to your entity:

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

#[derive(Entity)]
#[entity(table = "accounts", transactions)]  // ← Add this
pub struct Account {
    #[id]
    pub id: Uuid,

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

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

What Gets Generated?

For an entity Account with #[entity(transactions)], the macro generates:

1. Transaction Repository Adapter

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

This is like your regular repository, but all operations happen inside the transaction.

2. Builder Extension Trait

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

This adds the with_accounts() method to the transaction builder.

Available Methods

Inside a transaction, you have access to these methods:

MethodSignatureDescription
createcreate(dto) -> Result<Entity, Error>Insert a new record
find_by_idfind_by_id(id) -> Result<Option<Entity>, Error>Find by primary key
find_by_id_for_updatefind_by_id_for_update(id) -> Result<Option<Entity>, Error>Find by primary key and lock the row (SELECT ... FOR UPDATE) until the transaction ends β€” guards read-validate-write state transitions
updateupdate(id, dto) -> Result<Entity, Error>Update existing record
deletedelete(id) -> Result<bool, Error>Delete record (or soft-delete)
listlist(limit, offset) -> Result<Vec<Entity>, Error>Paginated list

Error above is the entity’s configured error = "..." type (default sqlx::Error) β€” the same type the pool-backed repository returns. With typed_constraints, the adapter’s write methods (create, upsert, update, delete) resolve violated constraint names to entity_core::ConstraintError exactly like the pool implementation, so an operation keeps its typed error behaviour when it moves inside a transaction.

State-machine transitions

#[transition(...)] declarations generate locking transition methods on the adapter:

#[derive(Entity)]
#[entity(table = "parcels", transactions, error = "AppError")]
#[transition(created -> accepted, sets(courier_id, ticket_id))]
#[transition(accepted -> in_transit)]
#[transition(created | accepted -> cancelled)]
pub struct Parcel {
    #[id] pub id: Uuid,
    #[field(update)] pub status: ParcelStatus,
    #[field(update)] pub courier_id: Option<Uuid>,
    #[field(update)] pub ticket_id: Option<Uuid>,
}

let mut tx = pool.begin().await?;
let parcel = ParcelTransactionRepo::new(&mut tx)
    .transition_to_accepted(id, courier_id, ticket_id)
    .await?
    .ok_or(NotFound)?;
tx.commit().await?;

Each transition_to_{target}(id, sets...) locks the row with SELECT ... FOR UPDATE, verifies the current status is one of the declared sources, patches status plus the sets(...) columns in one UPDATE and returns the persisted row. Ok(None) means the row does not exist; a disallowed transition returns a typed entity_core::TransitionError (your error type must implement From<TransitionError> β€” map it to HTTP 409). sets parameters take the unwrapped inner type of Option columns. Requires transactions, a status field marked #[field(update)] and a custom error type β€” all checked at compile time.

Basic Example

use entity_core::prelude::*;

async fn create_account(pool: &PgPool, user_id: Uuid) -> Result<Account, AppError> {
    Transaction::new(pool)           // 1. Start building transaction
        .with_accounts()              // 2. Add Account repository
        .run(|mut ctx| async move {   // 3. Execute operations
            let account = ctx.accounts().create(CreateAccountRequest {
                user_id,
                balance: 0,
            }).await?;

            Ok(account)               // 4. Return result (auto-commits)
        })
        .await
}

Step by Step:

  1. Transaction::new(pool) β€” Creates a new transaction builder with your database pool
  2. .with_accounts() β€” Adds the Account repository to the transaction context
  3. .run(|mut ctx| async move { ... }) β€” Executes your operations inside the transaction
  4. Ok(account) β€” Returning Ok commits the transaction. Returning Err rolls it back.

Complete Example: Money Transfer

This example shows the full power of transactions:

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())
    }
}

/// Transfer money between two accounts atomically.
///
/// If ANY step fails, all changes are rolled back automatically.
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 {
            // Step 1: Get source account
            let from = ctx.accounts()
                .find_by_id(from_id)
                .await?
                .ok_or(TransferError::AccountNotFound(from_id))?;

            // Step 2: Check if source has enough money
            if from.balance < amount {
                return Err(TransferError::InsufficientFunds {
                    available: from.balance,
                    requested: amount,
                });
            }

            // Step 3: Get destination account
            let to = ctx.accounts()
                .find_by_id(to_id)
                .await?
                .ok_or(TransferError::AccountNotFound(to_id))?;

            // Step 4: Subtract from source
            // If this succeeds but step 5 fails, this will be ROLLED BACK
            ctx.accounts().update(from_id, UpdateAccountRequest {
                balance: Some(from.balance - amount),
                user_id: None,  // Don't change user_id
            }).await?;

            // Step 5: Add to destination
            ctx.accounts().update(to_id, UpdateAccountRequest {
                balance: Some(to.balance + amount),
                user_id: None,
            }).await?;

            // All operations succeeded - transaction will COMMIT
            Ok(())
        })
        .await
}

What happens in different scenarios:

ScenarioResult
Both updates succeedTransaction commits, money transferred
Source account not foundTransaction rolls back (no changes)
Insufficient fundsTransaction rolls back (no changes)
First update succeeds, second failsTransaction rolls back (first update undone!)
Network error mid-transactionTransaction rolls back (no partial changes)

Multiple Entities in One Transaction

You can operate on multiple entities atomically:

#[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()      // Add Account repo
        .with_transfer_logs() // Add TransferLog repo
        .run(|mut ctx| async move {
            // Update balances
            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?;

            // Create log entry - all in same transaction!
            let log = ctx.transfer_logs().create(CreateTransferLogRequest {
                from_account_id: from_id,
                to_account_id: to_id,
                amount,
            }).await?;

            Ok(log)
        })
        .await
}

If the log creation fails, both account updates are rolled back!

Error Handling

Automatic Rollback

Any error returned from the closure triggers a rollback:

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

        // Some validation fails
        if amount < 0 {
            return Err(AppError::InvalidAmount);  // ← Triggers rollback!
        }

        // This never executes, and the update above is undone
        ctx.accounts().update(other_id, other_dto).await?;

        Ok(())
    })
    .await

Transaction Error Types

The TransactionError enum tells you what went wrong:

use entity_core::transaction::TransactionError;

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

match result {
    Ok(value) => {
        println!("Success: {:?}", value);
    }
    Err(e) => {
        // Check what kind of error
        if e.is_begin() {
            println!("Failed to start transaction");
        } else if e.is_operation() {
            println!("Operation failed: {}", e);
        } else if e.is_commit() {
            println!("Failed to commit");
        } else if e.is_rollback() {
            println!("Failed to rollback");
        }

        // Get the inner database error
        let db_error: sqlx::Error = e.into_inner();
    }
}

With Soft Delete

Transactions respect the soft_delete attribute:

#[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>>,  // Required for soft_delete
}

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

Best Practices

1. Keep Transactions Short

❌ Bad: Long-running transactions

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

        // DON'T: Call external APIs inside transactions
        let rate = external_api.get_exchange_rate().await?;  // ← SLOW!

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

βœ… Good: Do slow operations outside

// Fetch external data BEFORE starting transaction
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. Don’t Use Transactions for Single Operations

❌ Unnecessary:

Transaction::new(pool)
    .with_users()
    .run(|mut ctx| async move {
        ctx.users().find_by_id(id).await  // Just one operation!
    })
    .await

βœ… Better: Use regular repository

pool.find_by_id(id).await  // No transaction needed

3. Handle All Errors Properly

Always make sure errors are propagated with ?:

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

        // DON'T: Swallow errors
        if let Err(e) = result {
            log::error!("Update failed: {}", e);
            // Transaction won't rollback properly!
        }

        // DO: Propagate errors
        ctx.accounts().update(id, dto).await?;  // ← Use ?

        Ok(())
    })
    .await

Common Patterns

Check-Then-Update

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
Transaction::new(pool)
    .with_orders()
    .with_order_items()
    .run(|mut ctx| async move {
        // Create parent
        let order = ctx.orders().create(CreateOrderRequest {
            customer_id,
            status: "pending".to_string(),
        }).await?;

        // Create children
        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

See Also

  • [[Attributes-en|Attributes Reference]] β€” Complete attribute documentation
  • [[Hooks-en|Lifecycle Hooks]] β€” Run code before/after operations
  • [[Commands-en|Commands]] β€” CQRS command pattern
  • [[Events-en|Events]] β€” Track entity changes

Custom SQL Queries

When the auto-generated SQL isn’t enough, use sql = "trait" for full control.

When to Use Custom SQL

  • Joins β€” Related entities in single query
  • CTEs β€” Complex recursive or multi-step queries
  • Full-text search β€” PostgreSQL tsvector/tsquery
  • Aggregations β€” GROUP BY, HAVING, window functions
  • Partitioned tables β€” Time-based or range partitions
  • Bulk operations β€” Batch inserts/updates
  • Soft deletes β€” Custom delete logic
  • Optimistic locking β€” Version-based concurrency

Basic Setup

#[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>,
}

This generates:

  • All DTOs (CreatePostRequest, UpdatePostRequest, PostResponse)
  • PostRow and InsertablePost
  • PostRepository trait
  • All From implementations

But not the impl PostRepository for PgPool.

Implementing the 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> {
        // Your custom update logic
        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())
    }
}

Example: Posts with Author Join

// Extended response with author data
pub struct PostWithAuthor {
    pub post: Post,
    pub author: User,
}

// Custom repository extension
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> {
        // Similar join query with pagination
        todo!()
    }
}
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())
    }
}

Example: Soft Deletes

#[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 {
    // ... other methods

    async fn delete(&self, id: Uuid) -> Result<bool, Self::Error> {
        // Soft delete instead of hard delete
        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> {
        // Exclude soft-deleted
        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())
    }
}

// Additional method for admins
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>;
}

Example: Optimistic Locking

#[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> {
        // Requires current version for optimistic locking
        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)
    }

    // ... other methods
}

Best Practices for Custom SQL

  1. Use query_as with Row structs β€” Type-safe mapping
  2. Bind all parameters β€” Never interpolate strings
  3. Return Row, convert to Entity β€” Use generated From impls
  4. Extend, don’t replace β€” Add custom traits alongside Repository
  5. Test with real database β€” Integration tests are essential

Web Framework Integration

How to use entity-derive with popular Rust web frameworks.

The easiest way to create REST APIs is to use the api(handlers) attribute which automatically generates all CRUD handlers, router, and OpenAPI documentation.

Full CRUD API

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

#[derive(Debug, Clone, Entity)]
#[entity(
    table = "users",
    schema = "public",
    api(
        tag = "Users",
        security = "cookie",     // or "bearer", "api_key"
        handlers,                // generates all 5 CRUD handlers
        title = "User Service API",
        description = "RESTful API for user management",
        api_version = "1.0.0"
    )
)]
pub struct User {
    #[id]
    pub id: Uuid,

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

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

    #[field(create, skip)]  // create-only, not in response
    pub password_hash: String,

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

// This generates:
// - create_user() - POST /users
// - get_user() - GET /users/{id}
// - update_user() - PATCH /users/{id}
// - delete_user() - DELETE /users/{id}
// - list_user() - GET /users
// - user_router() - axum Router with all routes
// - UserApi - OpenAPI documentation struct

Usage with Axum

use std::sync::Arc;
use axum::Router;
use sqlx::PgPool;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;

fn app(pool: Arc<PgPool>) -> Router {
    Router::new()
        .merge(user_router::<PgPool>())
        .merge(SwaggerUi::new("/swagger-ui")
            .url("/api-docs/openapi.json", UserApi::openapi()))
        .with_state(pool)
}

Selective Handlers

Generate only specific handlers using handlers(...):

// Read-only API (no create, update, delete)
#[entity(api(tag = "Products", handlers(get, list)))]

// Create-only API
#[entity(api(tag = "Orders", handlers(create)))]

// No delete allowed
#[entity(api(tag = "Users", handlers(create, get, update, list)))]
SyntaxGenerated HandlersOpenAPI Schemas
handlerscreate, get, update, delete, listResponse, Create, Update
handlers(get, list)get, listResponse
handlers(create, get)create, getResponse, Create

OpenAPI Info Configuration

#[entity(api(
    tag = "Users",
    handlers,
    // OpenAPI Info
    title = "My API",
    description = "API description with **markdown** support",
    api_version = "1.0.0",
    // License
    license = "MIT",
    license_url = "https://opensource.org/licenses/MIT",
    // Contact
    contact_name = "API Support",
    contact_email = "support@example.com",
    contact_url = "https://example.com/support"
))]

Security Options

// Cookie-based auth (JWT in httpOnly cookie)
#[entity(api(tag = "Users", security = "cookie", handlers))]

// Bearer token auth
#[entity(api(tag = "Users", security = "bearer", handlers))]

// API key in header
#[entity(api(tag = "Users", security = "api_key", handlers))]

// No authentication
#[entity(api(tag = "Public", handlers))]

Manual Handlers

For more control, you can write handlers manually using the generated DTOs and repository trait.

Axum

Project Structure

src/
β”œβ”€β”€ main.rs
β”œβ”€β”€ entities/
β”‚   β”œβ”€β”€ mod.rs
β”‚   └── user.rs
β”œβ”€β”€ handlers/
β”‚   β”œβ”€β”€ mod.rs
β”‚   └── users.rs
└── routes.rs

Entity Definition

// 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))
}

Routes

// 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(),
    }
}

Routes

// 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
}

Error Handling

Better error handling with custom error types:

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, "Resource not found"),
            AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error"),
            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)
    }
}

// Usage in 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)))
}

Validation

Add validation with the validator crate:

use validator::Validate;

// Manually add Validate derive to the generated struct
// or validate before calling repository methods

pub async fn create_user(
    State(pool): State<PgPool>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserResponse>), AppError> {
    // Manual validation
    if payload.username.len() < 3 {
        return Err(AppError::Validation("Username too short".into()));
    }

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

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

Pagination

Example pagination helper:

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))
}

Best Practices

Guidelines for using entity-derive effectively in production.

Entity Design

Keep Entities Focused

One entity per database table. Don’t try to model complex relationships in a single entity.

// Good: Separate entities
#[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,  // Reference, not embed
    #[field(create, update, response)]
    pub title: String,
}

// Bad: Trying to embed relationships
pub struct User {
    pub id: Uuid,
    pub posts: Vec<Post>,  // Don't do this
}

Use Meaningful Field Attributes

Be explicit about each field’s purpose:

// Good: Clear intent
#[field(create, response)]      // Set once, always visible
pub email: String,

#[field(update, response)]      // Can change, always visible
pub display_name: Option<String>,

#[field(response)]              // Read-only, computed/managed elsewhere
pub post_count: i64,

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

// Bad: Everything everywhere
#[field(create, update, response)]  // Is this really needed for all?
pub internal_id: String,

Prefer Option for Nullable Fields

Match your database schema:

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

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

Security

Always Use #[field(skip)] for Sensitive Data

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

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

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

// PII that shouldn't be in responses
#[field(skip)]
pub ssn: String,

// Internal audit data
#[field(skip)]
pub created_by_ip: String,

Separate Internal and External Entities

For admin-only data, consider separate entities:

// Public entity
#[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>,
}

// Admin-only entity (same table, different view)
#[derive(Entity)]
#[entity(table = "users", sql = "trait")]
pub struct AdminUser {
    #[id]
    pub id: Uuid,
    #[field(response)]
    pub name: String,
    #[field(update, response)]  // Now visible and editable
    pub admin_notes: Option<String>,
    #[field(response)]
    pub last_login_ip: Option<String>,
}

Performance

Use sql = "trait" for Complex Queries

Don’t fight the generated SQL. If you need joins or complex logic, implement it yourself:

// Simple CRUD - use full generation
#[entity(table = "categories", sql = "full")]

// Complex queries needed - implement yourself
#[entity(table = "posts", sql = "trait")]

Batch Operations

For bulk inserts, implement custom methods:

#[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);
            // Insert within transaction
        }

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

Avoid N+1 Queries

Use joins instead of loading related entities one by one:

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

// Good: Single query with join
let posts_with_authors = pool.list_with_authors(100, 0).await?;  // 1 query

Testing

Use Separate Test Database

#[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();

        // Run migrations
        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");
    }
}

Test DTOs Separately

#[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 is not in UserResponse
    assert_eq!(response.username, "test");
    // No way to access password_hash through response
}

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

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

Schema Assertion

Generated SQL is correct by construction, but the table can drift (missed migration, manual ALTER, renamed column) β€” that surfaces as runtime decode errors. Every Postgres entity gets {Entity}::SCHEMA (declared columns: name, DDL type, nullability) and {Entity}::assert_schema(pool), which compares the declaration against information_schema.columns and reports all drifts at once. Run one integration test per entity:

#[tokio::test]
async fn entities_match_database_schema() {
    let pool = test_pool().await;
    User::assert_schema(&pool).await.expect("users drifted");
    Order::assert_schema(&pool).await.expect("orders drifted");
}

Type comparison is family-based (TEXT/VARCHAR/CHAR fold together, arrays compare as arrays); Postgres enums (USER-DEFINED) skip the type check but keep presence + nullability guarantees.

Project Organization

src/
β”œβ”€β”€ entities/           # Entity definitions
β”‚   β”œβ”€β”€ mod.rs
β”‚   β”œβ”€β”€ user.rs
β”‚   β”œβ”€β”€ post.rs
β”‚   └── comment.rs
β”œβ”€β”€ repositories/       # Custom repository extensions
β”‚   β”œβ”€β”€ mod.rs
β”‚   └── post_search.rs
β”œβ”€β”€ handlers/           # HTTP handlers
β”‚   β”œβ”€β”€ mod.rs
β”‚   β”œβ”€β”€ users.rs
β”‚   └── posts.rs
β”œβ”€β”€ services/           # Business logic
β”‚   β”œβ”€β”€ mod.rs
β”‚   └── auth.rs
└── main.rs

Re-export Generated Types

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

pub use user::*;
pub use post::*;
// src/entities/auth/mod.rs
mod user;
mod session;
mod api_key;

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

Common Mistakes

1. Forgetting #[field(skip)] on Sensitive Fields

// Wrong: password_hash will be in Response!
pub struct User {
    pub password_hash: String,
}

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

2. Using sql = "full" When You Need Joins

If you need related data, use sql = "trait" and implement yourself.

3. Not Handling Optional Updates

Remember: UpdateRequest fields are Option<T>. Check before applying:

// Generated UpdateUserRequest has Option<String> for name
// Your update logic should handle None (no change) vs Some (change)

4. Duplicating Business Logic

Put validation and business rules in a service layer, not in handlers:

// Good: Service layer
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)
    }
}

// Bad: Logic scattered in handlers
pub async fn create_user(pool: State<PgPool>, request: Json<CreateUserRequest>) -> ... {
    // Validation here
    // Business rules here
    // Repository call here
    // All mixed together
}

Checklist

Before deploying:

  • All sensitive fields have #[field(skip)]
  • DTOs match API contract expectations
  • Complex queries use sql = "trait"
  • Integration tests cover repository methods
  • Error handling is consistent
  • Pagination is implemented for list endpoints
  • Database indexes exist for query patterns