Skip to main content

Crate entity_derive

Crate entity_derive 

Source
Expand description

entity-derive logo

entity-derive

One macro to rule them all

Generate DTOs, repositories, mappers, and SQL from a single entity definition

Crates.io Documentation CI Status

Coverage License: MIT REUSE Compliant Wiki


§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.17", features = ["postgres", "api"] }

§Feature flags

FeatureDefaultWhat it does
postgresGenerate sqlx::PgPool-backed repository implementations
eventsGenerate {Entity}Event enum (Created / Updated / Deleted variants)
commandsCQRS 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_rootNew{Entity} constructor type and transactional save() (#[entity(aggregate_root)])
migrationsCompile-time MIGRATION_UP / MIGRATION_DOWN SQL constants (#[entity(migrations)])
projectionsProjection structs and find_by_id_<projection> lookups (#[projection(...)])
clickhouseGenerate ClickHouse-backed repositories (planned)
mongodbGenerate MongoDB-backed repositories (planned)
streams{Entity}Subscriber using Postgres LISTEN/NOTIFY (pulls in events)
outboxTransactional-outbox enqueue in generated writes + OutboxDrainer runtime (pulls in events)
apiGenerate HTTP handlers (axum) and utoipa OpenAPI schemas
validateWire up validator::Validate on generated DTOs
tracingWrap 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.17", 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.17", features = ["postgres", "api", "tracing", "streams"] }
tracing = "0.1"
tracing-subscriber = "0.3"

§Features

FeatureDescription
Zero Runtime CostAll code generation at compile time
Type SafeChange a field once, everything updates
Auto HTTP Handlersapi(handlers) generates CRUD endpoints + router
OpenAPI DocsAuto-generated Swagger/OpenAPI documentation
Query FilteringType-safe #[filter], #[filter(like)], #[filter(range)]
Sorting & Keyset#[sort] whitelisted ORDER BY + list_after cursor pagination
Bulk Operationsfind_by_ids, atomic create_many, soft-delete-aware delete_many
PATCH SemanticsDynamic UPDATE SET; double-Option distinguishes “leave” from “set NULL”
Relations#[belongs_to], #[has_many] and many-to-many via through = "junction"
Ownership Scoping#[owner] generates find_by_id_scoped / list_by_owner / update_scoped / delete_scoped
Upsertupsert(conflict = "…") generates INSERT ... ON CONFLICT DO UPDATE / DO NOTHING
Aggregate Roots#[entity(aggregate_root)] with New{T} DTOs and transactional save
TransactionsMulti-entity atomic operations
Lifecycle EventsCreated, Updated, Deleted events
Real-Time StreamsPostgres LISTEN/NOTIFY integration
Transactional Outboxevents(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 CommandsBusiness-oriented command pattern
Soft Deletedeleted_at timestamp support
Structured LoggingOpt-in tracing feature wraps every generated async method in #[tracing::instrument] with entity + op fields

§Documentation


§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)
#[sort]                        // Whitelisted dynamic ORDER BY
#[belongs_to(Entity)]          // Foreign key relation
#[has_many(Entity)]            // One-to-many relation
#[has_many(E, through = "t")]  // Many-to-many via junction table
#[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 theirs

Scoped 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.

§PATCH Semantics

Update DTOs are true partial patches. Fields absent from the payload are left untouched — the generated UPDATE only includes columns actually present. Nullable columns use double-Option, so “leave unchanged” and “set NULL” are finally distinguishable:

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

In Rust code: None = leave, Some(None) = SET NULL, Some(Some(v)) = SET v.

§Bulk Operations

Every repository ships batch primitives — one round-trip reads and atomic writes:

let posts: Vec<Post> = pool.find_by_ids(ids).await?;          // WHERE id = ANY($1)
let created: Vec<Post> = pool.create_many(dtos).await?;       // one transaction
let removed: u64 = pool.delete_many(stale_ids).await?;        // soft-delete aware

create_many rolls the whole batch back if any row fails; delete_many returns the number of rows actually affected. With events delivery (streams or outbox) per-row events are emitted inside the same transaction.

§Sorting & Keyset Pagination

Mark sortable columns with #[sort] — the Query struct gains a whitelisted sort selector ({Entity}SortField, one Asc/Desc variant per column, JSON-friendly), so user input can never inject SQL. Every repository also gets list_after keyset pagination that stays fast on deep pages, unlike OFFSET:

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

With the default UUIDv7 ids, the id-ordered keyset walk is chronologically stable.

§Many-to-Many Relations

Declare the junction table with through and the repository gains a JOIN-backed lookup plus link management, while migrations emits the junction DDL (composite primary key, cascading foreign keys):

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

§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?;
  • ValueObject generates PG_TYPE and idempotent PG_CREATE_TYPE constants; the opt-in sqlx flag additionally emits sqlx::Type / Encode / Decode impls so the enum binds and decodes without hand-written glue (omit it if you already derive sqlx::Type yourself).
  • #[column(pg_enum = "...")] sets the DDL column type and registers the enum’s DDL in {Entity}::MIGRATION_TYPES — run those before MIGRATION_UP.
  • The declared name is checked against the enum’s pg_type at 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.17", 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

Coverage Sunburst

§entity-derive

One crate, all features. Re-exports:

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

outboxoutbox
Transactional-outbox drainer runtime.
policy
Policy/authorization types for entity-derive.
prelude
Convenient re-exports for common usage.
serde_helpersserde
Serde helpers for generated DTOs.
streamstreams
Streaming types for real-time entity updates.
transaction
Transaction support for entity-derive.

Structs§

Pagination
Pagination parameters for list operations.

Enums§

CommandKind
Kind of business command.
EventKind
Kind of lifecycle event.
SortDirection
Sort direction for ordered queries.

Traits§

EntityCommand
Base trait for entity commands.
EntityEvent
Base trait for entity lifecycle events.
Repository
Base repository trait.

Functions§

const_str_eq
Compare two strings in const context.

Attribute Macros§

async_trait

Derive Macros§

Entity
Derive macro for generating complete domain boilerplate from a single entity definition.
ValueObject
Derive macro for generating PostgreSQL enum boilerplate.