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:
| Component | Lines of Code | Problems |
|---|---|---|
| DTOs (Create, Update, Response) | ~60 per entity | Manual sync, forgotten fields |
| Repository trait + impl | ~150 per entity | Runtime SQL errors, copy-paste |
| Entity β DTO mapping | ~40 per entity | Data leaks (password_hash in Response) |
| Validation and hooks | Scattered across services | Duplication, no single source |
| Events/audit | Missing or ad-hoc | No 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,UserResponseUserRepositorywith type-safe SQLUserEvent::Created,Updated,DeletedUserHooksfor business logicRegisterUser,DeactivateUsercommandsUserQueryfor 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,PostResponsePostRepositorywithcreate(),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 β
DeactivateandBancan 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
DateTimewithString - 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 expandshows 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
structwithout 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
| Aspect | What You Get |
|---|---|
| Development Speed | 10 entities in an hour instead of a day |
| Reliability | Compile-time verification of everything |
| Security | Impossible to accidentally leak data |
| Performance | Zero-cost, like hand-written code |
| Clarity | Transparent generation, no magic |
| Flexibility | From simple CRUD to CQRS with one attribute |
| Scalability | Architecture grows with project |
| Maintainability | Single source of truth, fewer bugs |
Documentation
| Topic | Description |
|---|---|
| [[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
| Attribute | Required | Default | Description |
|---|---|---|---|
table | Yes | β | Database table name |
schema | No | "public" | Database schema |
sql | No | "full" | SQL generation level |
dialect | No | "postgres" | Database dialect |
uuid | No | "v7" | UUID version for ID generation |
soft_delete | No | false | Enable soft delete |
returning | No | "full" | RETURNING clause mode |
upsert(...) | No | β | Generate INSERT ... ON CONFLICT upsert method |
api(guard = "...") | No | β | Enforced FromRequestParts guard in generated handlers |
error | No | sqlx::Error | Custom error type |
events | No | false | Generate lifecycle events |
hooks | No | false | Generate lifecycle hooks trait |
commands | No | false | Enable CQRS command pattern |
transactions | No | false | Generate 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".
| Value | Repository Trait | PgPool Implementation | Use Case |
|---|---|---|---|
"full" | Yes | Yes | Standard CRUD entities |
"trait" | Yes | No | Custom queries (joins, CTEs) |
"none" | No | No | DTOs 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".
| Dialect | Aliases | Client Type | Status |
|---|---|---|---|
"postgres" | "pg", "postgresql" | sqlx::PgPool | Stable |
"clickhouse" | "ch" | clickhouse::Client | Planned |
"mongodb" | "mongo" | mongodb::Client | Planned |
uuid (optional)
UUID version for auto-generated primary keys. Default: "v7".
| Version | Method | Properties |
|---|---|---|
"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()β Setsdeleted_at = NOW()instead of DELETEhard_delete()β Permanently removes the recordrestore()β Setsdeleted_at = NULLfind_by_id()/list()β Automatically filter deleted recordsfind_by_id_with_deleted()/list_with_deleted()β Include deleted records
returning (optional)
Control what data is fetched back after INSERT/UPDATE. Default: "full".
| Mode | SQL Clause | Use Case |
|---|---|---|
"full" | RETURNING * | Get all fields including DB-generated (default) |
"id" | RETURNING id | Confirm insert, return pre-built entity |
"none" | (no RETURNING) | Fire-and-forget, fastest option |
"col1, col2" | RETURNING col1, col2 | Return 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,
}
| Option | Required | Default | Description |
|---|---|---|---|
conflict | Yes | β | Comma-separated conflict target columns |
action | No | "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 conflictaction = "nothing"βasync fn upsert(&self, dto: CreateUserRequest) -> Result<Option<User>, Error>β keeps the existing row untouched;Nonemeans a conflicting row already existed
Compile-time validation:
- conflict columns must exist and carry a uniqueness guarantee (
#[id],#[column(unique)]or a matchingunique_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 contextTransactionWithAccounttrait withwith_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 transactionfind_by_id(id)β Find entity by IDupdate(id, dto)β Update entitydelete(id)β Delete entity (respectssoft_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
uuidattribute) - Always included in
ResponseDTO - Excluded from
CreateRequestandUpdateRequest
#[id]
pub id: Uuid,
#[auto]
Marks auto-generated fields (timestamps, sequences).
Behavior:
- Gets
Default::default()inFrom<CreateRequest> - Excluded from
CreateRequestandUpdateRequest - Can be included in
Responsewith#[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
Rowstruct (for database reads)Insertablestruct (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_TYPEDDL in{Entity}::MIGRATION_TYPESβ run those beforeMIGRATION_UP - The declared name is checked against the enumβs
PG_TYPEconstant at compile time; a mismatch fails the build - The
ValueObjectβs opt-insqlxflag emitssqlx::Type/Encode/Decodeimpls; omit it if you already derivesqlx::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 + ciemitsCREATE UNIQUE INDEX {table}_{column}_lower_key ON {table} (LOWER({column}))instead of an inlineUNIQUEconstraint - 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; derivessqlx::FromRowandserde::SerializeTicketView::SELECTβ the canonicalSELECT ... 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)andTicketView::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>implementationsfind_by_id_public(),find_by_id_admin()methods
Command Attributes
Applied at entity level with #[command(...)].
Quick Reference
| Syntax | Effect |
|---|---|
#[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 UUID | uuid = "v4" on entity |
| Use time-ordered UUID | uuid = "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 queries | sql = "trait" |
| DTOs only, no DB | sql = "none" |
| Soft delete records | soft_delete on entity |
| Custom error type | error = "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 changes | events on entity |
| Run code on lifecycle | hooks on entity |
| Use domain commands | commands on entity + #[command(...)] |
| Use multi-entity transactions | transactions 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>andexists_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:
CreateWebhookPayloadRequestWebhookPayloadResponseFromimplementations
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
- Default pagination β Always apply sensible limits to prevent large result sets
- Validate patterns β Sanitize LIKE patterns to prevent SQL issues
- Index filtered columns β Create database indexes for frequently filtered fields
- Use specific filters β Prefer exact match over pattern match when possible
- 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?;
Trigram Search
#[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
Loading Related Data
// 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
- Avoid N+1 queries β Use joins for eager loading when fetching multiple related entities
- Use pagination β Always limit
has_manyresults - Consider data access patterns β Add indexes on foreign key columns
- Cache when appropriate β Cache frequently accessed related data
- 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
- Publish after commit β Only publish events after the database transaction succeeds
- Idempotent handlers β Event handlers should be idempotent for at-least-once delivery
- Include context β Consider adding metadata (user_id, timestamp, correlation_id)
- Async processing β Use background workers for heavy event processing
- 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
SerializeandDeserialize(for JSON payloads) - Both
eventsandstreamsattributes are required - Enable the
streamsfeature 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
- Reconnection β PgListener auto-reconnects; design your loop to handle temporary failures
- Idempotency β Events may be delivered multiple times; handlers should be idempotent
- Payload size β Keep entities small; large payloads may hit Postgres limits
- Separate pools β Use dedicated connection pool for listeners to avoid blocking queries
- Monitoring β Log stream errors and track event processing latency
- 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
- Keep hooks fast β Long-running operations should be async jobs
- Use transactions β Wrap hook + repository call in a transaction
- Handle errors gracefully β Return meaningful error types
- Donβt duplicate logic β Use hooks for cross-cutting concerns
- 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
- Domain language β Use business terms:
RegisterUsernotCreateUser - Single responsibility β One command = one business operation
- Explicit intent β Command names should describe the action
- Validation in handlers β Keep validation logic in command handlers
- Idempotent when possible β Design commands to be safely retried
- 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:
| Method | Signature | Description |
|---|---|---|
create | create(dto) -> Result<Entity, Error> | Insert a new record |
find_by_id | find_by_id(id) -> Result<Option<Entity>, Error> | Find by primary key |
find_by_id_for_update | find_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 |
update | update(id, dto) -> Result<Entity, Error> | Update existing record |
delete | delete(id) -> Result<bool, Error> | Delete record (or soft-delete) |
list | list(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:
Transaction::new(pool)β Creates a new transaction builder with your database pool.with_accounts()β Adds the Account repository to the transaction context.run(|mut ctx| async move { ... })β Executes your operations inside the transactionOk(account)β ReturningOkcommits the transaction. ReturningErrrolls 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:
| Scenario | Result |
|---|---|
| Both updates succeed | Transaction commits, money transferred |
| Source account not found | Transaction rolls back (no changes) |
| Insufficient funds | Transaction rolls back (no changes) |
| First update succeeds, second fails | Transaction rolls back (first update undone!) |
| Network error mid-transaction | Transaction 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
Create Multiple Related Records
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) PostRowandInsertablePostPostRepositorytrait- All
Fromimplementations
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!()
}
}
Example: Full-Text Search
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
- Use
query_aswith Row structs β Type-safe mapping - Bind all parameters β Never interpolate strings
- Return Row, convert to Entity β Use generated
Fromimpls - Extend, donβt replace β Add custom traits alongside
Repository - Test with real database β Integration tests are essential
Web Framework Integration
How to use entity-derive with popular Rust web frameworks.
Auto-Generated Handlers (Recommended)
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)))]
| Syntax | Generated Handlers | OpenAPI Schemas |
|---|---|---|
handlers | create, get, update, delete, list | Response, Create, Update |
handlers(get, list) | get, list | Response |
handlers(create, get) | create, get | Response, 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
Recommended Structure
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::*;
Group Related Entities
// 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