Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

entity-derive

One macro to rule them all

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


What is it?

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


The Problem

A typical Rust backend with ~10 entities means:

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

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


The Solution

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

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

    #[field(skip)]  // Never leaks to API
    pub password_hash: String,

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

15 lines β†’ complete domain layer:

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

Simple for Beginners

Minimal example β€” 10 lines:

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

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

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

Done. You have:

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

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


Why Events?

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

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

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

Benefits:

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

Why Hooks?

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

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

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

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

Benefits:

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

Why Commands?

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

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

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

Compare:

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

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

Benefits:

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

Why Type-Safe Filtering?

Problem: String query params are runtime error sources:

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

Solution: #[filter] generates typed struct:

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

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

Benefits:

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

Transparency

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

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

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


Full Power of Rust

Compile-time Guarantees

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

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

Zero-cost Abstractions

Generated code is:

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

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

Async Out of the Box

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

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

Strict Typing

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

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

If code compiles β€” it works correctly.


Professional Architecture

Clean Architecture Ready

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

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

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

CQRS/Event Sourcing Ready

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

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

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

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

Extensibility

Level 1: Basic CRUD

#[entity(table = "users")]

Level 2: + Filtering

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

Level 3: + Events and Hooks

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

Level 4: + CQRS Commands

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

Level 5: Full Control

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

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


Security

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

skip means: this field will never appear in:

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

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


Why This is Awesome

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

Documentation

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

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