Expand description
entity-derive
One macro to rule them all
Generate DTOs, repositories, mappers, and SQL from a single entity definition
§The Problem
Building a typical CRUD application requires writing the same boilerplate over and over: entity struct, create DTO, update DTO, response DTO, row struct, repository trait, SQL implementation, and 6+ From implementations.
That’s 200+ lines of boilerplate for a single entity.
§The Solution
#[derive(Entity)]
#[entity(table = "users")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
#[field(create, update, response)]
pub email: String,
#[field(skip)]
pub password_hash: String,
#[field(response)]
#[auto]
pub created_at: DateTime<Utc>,
}Done. The macro generates everything else.
§Installation
[dependencies]
entity-derive = { version = "0.13", features = ["postgres", "api"] }§Feature flags
| Feature | Default | What it does |
|---|---|---|
postgres | ✓ | Generate sqlx::PgPool-backed repository implementations |
events | ✓ | Generate {Entity}Event enum (Created / Updated / Deleted variants) |
commands | ✓ | CQRS command pattern: command structs + dispatcher (#[entity(commands)], #[command(...)]) |
hooks | ✓ | {Entity}Hooks trait with before/after lifecycle methods |
transactions | ✓ | {Entity}TransactionRepo adapter + transaction builder helpers (#[entity(transactions)]) |
aggregate_root | ✓ | New{Entity} constructor type and transactional save() (#[entity(aggregate_root)]) |
migrations | ✓ | Compile-time MIGRATION_UP / MIGRATION_DOWN SQL constants (#[entity(migrations)]) |
projections | ✓ | Projection structs and find_by_id_<projection> lookups (#[projection(...)]) |
clickhouse | Generate ClickHouse-backed repositories (planned) | |
mongodb | Generate MongoDB-backed repositories (planned) | |
streams | {Entity}Subscriber using Postgres LISTEN/NOTIFY (pulls in events) | |
outbox | Transactional-outbox enqueue in generated writes + OutboxDrainer runtime (pulls in events) | |
api | Generate HTTP handlers (axum) and utoipa OpenAPI schemas | |
validate | Wire up validator::Validate on generated DTOs | |
tracing | Wrap every generated async method in #[tracing::instrument] carrying entity + op span fields |
Default features cover the full entity-attribute surface so existing projects work without changes. For lean builds, opt out of what you don’t need:
[dependencies]
# Just repositories — no events, hooks, commands, etc.
entity-derive = { version = "0.13", default-features = false, features = ["postgres"] }If you use an entity attribute whose feature is disabled (e.g. #[entity(commands)] without features = ["commands"]), the macro emits a compile_error! at the attribute pointing to the missing feature.
Enable extras alongside the defaults:
[dependencies]
entity-derive = { version = "0.13", features = ["postgres", "api", "tracing", "streams"] }
tracing = "0.1"
tracing-subscriber = "0.3"§Features
| Feature | Description |
|---|---|
| Zero Runtime Cost | All code generation at compile time |
| Type Safe | Change a field once, everything updates |
| Auto HTTP Handlers | api(handlers) generates CRUD endpoints + router |
OpenAPI Docs | Auto-generated Swagger/OpenAPI documentation |
| Query Filtering | Type-safe #[filter], #[filter(like)], #[filter(range)] |
| Relations | #[belongs_to] and #[has_many] |
| Ownership Scoping | #[owner] generates find_by_id_scoped / list_by_owner / update_scoped / delete_scoped |
| Upsert | upsert(conflict = "…") generates INSERT ... ON CONFLICT DO UPDATE / DO NOTHING |
| Aggregate Roots | #[entity(aggregate_root)] with New{T} DTOs and transactional save |
| Transactions | Multi-entity atomic operations |
| Lifecycle Events | Created, Updated, Deleted events |
| Real-Time Streams | Postgres LISTEN/NOTIFY integration |
| Transactional Outbox | events(outbox) — durable at-least-once event delivery with retry/backoff |
| Lifecycle Hook Traits | {Entity}Hooks trait emitted with before_create / after_update / etc.; invocation is currently manual at your service layer (tracking auto-invocation: #127) |
| CQRS Commands | Business-oriented command pattern |
| Soft Delete | deleted_at timestamp support |
| Structured Logging | Opt-in tracing feature wraps every generated async method in #[tracing::instrument] with entity + op fields |
§Documentation
| Topic | Languages |
|---|---|
| Getting Started | |
| Attributes | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Examples | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Features | |
| Filtering | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Relations | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Events | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Streams | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Hooks | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Commands | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Advanced | |
| Custom SQL | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Web Frameworks | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
| Best Practices | 🇬🇧 🇷🇺 🇰🇷 🇪🇸 🇨🇳 |
§Quick Reference
§Entity Attributes
#[entity(
table = "users", // Required: table name
schema = "public", // Optional: schema (default: omitted)
dialect = "postgres", // Optional: database dialect
aggregate_root, // Optional: New{T} DTOs + transactional save
soft_delete, // Optional: use deleted_at instead of DELETE
upsert( // Optional: INSERT ... ON CONFLICT method
conflict = "email", // #[column(unique)] field(s) or unique_index columns
action = "update", // "update" (default) or "nothing"
),
events, // Optional: generate lifecycle events
events(outbox), // Optional: + durable transactional outbox
streams, // Optional: real-time Postgres NOTIFY
hooks, // Optional: before/after lifecycle hooks
commands, // Optional: CQRS command pattern
transactions, // Optional: multi-entity transaction support
api( // Optional: generate HTTP handlers + OpenAPI
tag = "Users",
handlers, // All CRUD, or handlers(get, list, create)
security = "bearer", // cookie, bearer, api_key, or none
guard = "RequireAuth", // FromRequestParts extractor enforced in handlers
guard(list = "none"), // per-op override: create/get/update/delete/list/commands
title = "My API",
api_version = "1.0.0",
),
)]§Field Attributes
#[id] // Primary key (auto-generated UUID)
#[auto] // Auto-generated (timestamps)
#[owner] // Ownership column: adds *_scoped methods
#[field(create)] // Include in CreateRequest
#[field(update)] // Include in UpdateRequest
#[field(response)] // Include in Response
#[field(skip)] // Exclude from all DTOs
#[filter] // Exact match filter
#[filter(like)] // ILIKE pattern filter
#[filter(range)] // Range filter (from/to)
#[belongs_to(Entity)] // Foreign key relation
#[has_many(Entity)] // One-to-many relation
#[projection(Name: fields)] // Partial view§Transactional Outbox
LISTEN/NOTIFY (streams) is fire-and-forget: events are lost if no
subscriber is listening. events(outbox) makes delivery durable — every
generated write inserts the serialized event into the entity_outbox
table in the same transaction as the DML, and a drainer delivers rows
with retry and exponential backoff:
#[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;Rows are claimed with FOR UPDATE SKIP LOCKED (multiple drainers
cooperate), retried with exponential backoff and parked after
max_attempts for manual inspection. Delivery is at-least-once —
handlers must be idempotent. Composes with streams: NOTIFY wakes
subscribers instantly, the outbox guarantees nothing is lost. Requires
the outbox feature and serde_json in your crate.
§Ownership Scoping
Mark the column carrying the owning principal’s id with #[owner] and the
repository gains row-level scoped methods — “only this user’s rows” without
hand-written predicates:
#[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: Vec<Order> = pool.list_by_owner(user_id, 20, 0).await?;
let order = pool.find_by_id_scoped(id, user_id).await?; // None if not theirs
let updated = pool.update_scoped(id, user_id, patch).await?; // None if not theirs
let removed = pool.delete_scoped(id, user_id).await?; // false if not theirsScoped reads and writes never reveal whether a row exists for another
owner, and all of them respect soft_delete.
§Handler Guards
security = "..." only documents authentication in the OpenAPI spec. To
actually enforce it, pass a guard — any type implementing axum’s
FromRequestParts. It is injected as a leading argument of every generated
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 accept create, get, update, delete, list
and commands; the literal "none" disables the guard for that operation.
Commands listed in public = [...] never receive a guard.
§Postgres Enums
Derive ValueObject on a status-style enum and reference it from entities
with #[column(pg_enum = "...")]:
#[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?;ValueObjectgeneratesPG_TYPEand idempotentPG_CREATE_TYPEconstants; the opt-insqlxflag additionally emitssqlx::Type/Encode/Decodeimpls so the enum binds and decodes without hand-written glue (omit it if you already derivesqlx::Typeyourself).#[column(pg_enum = "...")]sets the DDL column type and registers the enum’s DDL in{Entity}::MIGRATION_TYPES— run those beforeMIGRATION_UP.- The declared name is checked against the enum’s
pg_typeat compile time; a typo fails the build.
§Upsert
Declare a conflict target with a uniqueness guarantee (#[id],
#[column(unique)] or unique_index(...)) and the repository gains an
upsert 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,
}
let user = pool.upsert(CreateUserRequest { email, name }).await?;action = "update" (default) overwrites all non-conflict columns with the
incoming values (DO UPDATE SET col = EXCLUDED.col) and returns the
persisted row. action = "nothing" keeps the existing row (DO NOTHING)
and returns Option<Entity> — None when a conflicting row already
existed. Requires returning = "full" (the default). With streams
enabled, upsert publishes a Created notification for every row it
returns.
§Transactions
Mark each participating entity with #[entity(table = "…", transactions)] and
drive a multi-entity transaction through Transaction::run. The closure
receives &mut TransactionContext; run commits on Ok and rolls back on
Err (or any panic) automatically:
use entity_core::transaction::Transaction;
Transaction::new(&pool)
.run(async |ctx| {
let user = ctx.users().create(create_user).await?;
ctx.orders().create(order_for(user.id)).await?;
Ok::<_, sqlx::Error>(user)
})
.await?;Need conditional commit/rollback inside the closure? Use
run_with_commit
— it takes TransactionContext by value so the closure can call
ctx.commit().await (or ctx.rollback().await) itself.
§Tracing
Opt-in with the tracing feature. Every generated async method
(create, find_by_id, update, delete, list, find_by_<field>,
projections, transaction adapters, stream subscribers) is wrapped in
#[tracing::instrument(skip_all, fields(entity, op), err(Debug))].
entity-derive = { version = "0.13", features = ["postgres", "tracing"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }With a subscriber initialized, a failed User::create surfaces as:
ERROR entity.User.create: error=database error: duplicate key value violates unique constraint
in entity.User.create with entity="User" op="create"When the feature is off, generated code is byte-for-byte identical to a build without the attribute — zero runtime cost.
§Code Coverage
§entity-derive
One crate, all features. Re-exports:
Entityderive macro fromentity-derive-impl- All types from
entity-core(Pagination,SortDirection,Repository)
§Quick Start
use entity_derive::{Entity, Pagination};
#[derive(Entity)]
#[entity(table = "users")]
pub struct User {
#[id]
pub id: Uuid,
#[field(create, update, response)]
pub name: String,
}
// Use pagination
let page = Pagination::page(0, 25);Modules§
- outbox
outbox - Transactional-outbox drainer runtime.
- policy
- Policy/authorization types for entity-derive.
- prelude
- Convenient re-exports for common usage.
- stream
streams - Streaming types for real-time entity updates.
- transaction
- Transaction support for entity-derive.
Structs§
- Pagination
- Pagination parameters for list operations.
Enums§
- Command
Kind - Kind of business command.
- Event
Kind - Kind of lifecycle event.
- Sort
Direction - Sort direction for ordered queries.
Traits§
- Entity
Command - Base trait for entity commands.
- Entity
Event - Base trait for entity lifecycle events.
- Repository
- Base repository trait.
Functions§
- const_
str_ eq - Compare two strings in const context.
Attribute Macros§
Derive Macros§
- Entity
- Derive macro for generating complete domain boilerplate from a single entity definition.
- Value
Object - Derive macro for generating
PostgreSQLenum boilerplate.