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.