Что это?
entity-derive — процедурный макрос Rust, который генерирует полноценный доменный слой из одного определения сущности. Не просто CRUD — архитектурный фреймворк с событиями, хуками, командами и типобезопасной фильтрацией.
Проблема
Типичный Rust backend на ~10 сущностей — это:
| Компонент | Строк кода | Проблемы |
|---|---|---|
| DTO (Create, Update, Response) | ~60 на сущность | Ручная синхронизация, забытые поля |
| Repository trait + impl | ~150 на сущность | SQL-ошибки в runtime, copy-paste |
| Маппинг Entity ↔ DTO | ~40 на сущность | Утечки данных (password_hash в Response) |
| Валидация и хуки | Разбросаны по сервисам | Дублирование, нет единого места |
| События/аудит | Отсутствуют или adhoc | Нет истории изменений |
Итого: ~2500 строк boilerplate для 10 сущностей. И каждое изменение схемы — ручная правка в 5+ местах.
Решение
#[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)] // Никогда не утечёт в API
pub password_hash: String,
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,
}
15 строк → полный доменный слой:
CreateUserRequest,UpdateUserRequest,UserResponseUserRepositoryс типобезопасным SQLUserEvent::Created,Updated,DeletedUserHooksдля бизнес-логикиRegisterUser,DeactivateUserкомандыUserQueryдля фильтрации
Просто для начинающих
Минимальный пример — 10 строк:
#[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,
}
Готово. У тебя есть:
CreatePostRequest,UpdatePostRequest,PostResponsePostRepositoryсcreate(),find_by_id(),update(),delete(),list()- Типобезопасный SQL для PostgreSQL
- Всё работает из коробки
Никакой магии. Открой cargo expand — увидишь ровно тот код, который написал бы сам. Только без ошибок и за секунды.
Почему события?
Проблема: В CRUD-приложениях нет истории. Кто изменил запись? Когда? Что было до этого? Аудит требует отдельной инфраструктуры и дисциплины.
Решение: #[entity(events)] генерирует типизированные события:
pub enum UserEvent {
Created(User),
Updated { id: Uuid, changes: UpdateUserRequest },
Deleted(Uuid),
}
Что это даёт:
- Аудит из коробки — подпишись на события, сохраняй в лог
- Event Sourcing — можно восстановить состояние из истории событий
- Интеграции — Kafka, WebSocket уведомления, инвалидация кэша
- Отладка — полная история изменений каждой сущности
Почему хуки?
Проблема: Бизнес-логика размазана. Валидация email — в контроллере. Хэширование пароля — в сервисе. Отправка письма — в отдельном воркере. Где искать логику создания пользователя?
Решение: #[entity(hooks)] централизует lifecycle:
impl UserHooks for MyHooks {
async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Error> {
dto.email = dto.email.to_lowercase(); // Нормализация
validate_email(&dto.email)?; // Валидация
Ok(())
}
async fn after_create(&self, user: &User) -> Result<(), Error> {
self.mailer.send_welcome(user).await?; // Бизнес-действие
Ok(())
}
}
Что это даёт:
- Единое место — вся логика сущности рядом с определением
- Предсказуемость — ясно когда что выполняется
- Тестируемость — хуки можно мокать и тестировать изолированно
- Композиция — разные реализации для разных контекстов
Почему команды?
Проблема: REST API скрывает намерение. POST /users — это регистрация? Создание админом? Импорт из CSV? PATCH /users/123 — деактивация? Смена email? Бан?
Решение: #[command(...)] выражает бизнес-домен:
#[command(Register)] // Самостоятельная регистрация
#[command(Invite)] // Приглашение админом
#[command(Deactivate, requires_id)] // Деактивация аккаунта
#[command(Ban, requires_id)] // Бан за нарушения
Сравни:
// CRUD (что происходит?)
pool.update(user_id, UpdateUserRequest { active: Some(false), ..default() }).await?;
// Команды (ясное намерение)
handler.handle(DeactivateUser { id: user_id }).await?;
Что это даёт:
- Самодокументируемый API — названия команд = бизнес-словарь
- Разная логика —
DeactivateиBanмогут иметь разные side-effects - CQRS готовность — команды легко маршрутизировать, логировать, ретраить
- Типобезопасность — компилятор проверяет что команда существует
Почему типобезопасная фильтрация?
Проблема: Строковые query params — источник runtime-ошибок:
GET /users?stauts=active // Опечатка — молча игнорируется
GET /users?created_at=tomorrow // Невалидная дата — panic в runtime
Решение: #[filter] генерирует типизированную структуру:
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?;
Что это даёт:
- Compile-time проверка — опечатка в имени поля = ошибка компиляции
- Типобезопасность —
DateTimeнельзя сравнить соString - Автодополнение — IDE подсказывает доступные фильтры
- SQL-injection защита — параметры биндятся, не конкатенируются
Прозрачность
Макрос не прячет логику. Всё что генерируется — это обычный Rust-код который ты можешь:
- Прочитать —
cargo expandпоказывает весь сгенерированный код - Понять — никаких runtime-рефлексий, только структуры и трейты
- Переопределить —
sql = "trait"и пиши свой SQL - Отладить — ошибки компилятора указывают на твой код, не на внутренности макроса
// Хочешь понять что генерируется?
cargo expand --lib | grep -A 50 "impl UserRepository"
Zero magic. Если макрос сломается — ты всегда можешь написать код руками. Он не создаёт lock-in.
Вся мощь Rust
Compile-time гарантии
#[field(skip)]
pub password_hash: String,
Это не runtime-проверка “не сериализуй это поле”. Это физическое отсутствие поля в структуре UserResponse. Невозможно случайно вернуть — поля просто нет.
Zero-cost abstractions
Сгенерированный код — это:
- Обычные
structбез Box/dyn - Прямые вызовы sqlx без промежуточных слоёв
#[inline]на горячих путях- Никаких аллокаций сверх необходимого
Benchmark: сгенерированный репозиторий работает с той же скоростью, что и написанный вручную. Потому что это и есть тот же код.
Async из коробки
// Всё async, всё Send + Sync
let user = pool.find_by_id(id).await?;
let users = pool.list(100, 0).await?;
Полная совместимость с tokio, async-std, любым async runtime.
Строгая типизация
// Ошибка компиляции: нет такого поля
let query = UserQuery { naem: "test".into(), ..default() };
^^^^ unknown field
// Ошибка компиляции: неверный тип
let query = UserQuery { created_at_min: "yesterday".into(), ..default() };
^^^^^^^^^^^^ expected DateTime<Utc>
Если код скомпилировался — он работает правильно.
Профессиональная архитектура
Clean Architecture готовность
Domain Layer (entity-derive)
├── Entities — #[derive(Entity)]
├── DTOs — CreateRequest, UpdateRequest, Response
├── Repository Trait — абстракция хранилища
├── Events — доменные события
├── Commands — бизнес-операции
└── Hooks — lifecycle логика
Infrastructure Layer (твой код)
├── Repository Impl — PgPool автоматически или свой
├── Event Handlers — подписка на события
├── Command Handlers — реализация бизнес-логики
└── External Services — интеграции
Чистое разделение. Domain не знает про HTTP, базу данных, Kafka. Всё это — детали реализации.
CQRS/Event Sourcing готовность
// 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?,
_ => {}
}
Хочешь простой CRUD? Получи. Хочешь полноценный CQRS? Включи commands и events. Архитектура растёт вместе с проектом.
Расширяемость
Уровень 1: Базовый CRUD
#[entity(table = "users")]
Уровень 2: + Фильтрация
#[entity(table = "users")]
// + #[filter] на полях
Уровень 3: + События и хуки
#[entity(table = "users", events, hooks)]
Уровень 4: + CQRS команды
#[entity(table = "users", events, hooks, commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]
Уровень 5: Полный контроль
#[entity(table = "users", sql = "trait", events, hooks, commands)]
// Свой SQL, своя логика, но с сохранением всех DTO и типов
Начни просто. Добавляй фичи по мере роста. Не переписывай — расширяй.
Безопасность
#[field(skip)]
pub password_hash: String,
skip означает: это поле никогда не появится в:
CreateUserRequest(нельзя передать извне)UpdateUserRequest(нельзя изменить через API)UserResponse(нельзя случайно вернуть клиенту)
Единственный способ работать с password_hash — напрямую через entity в коде. Утечка невозможна по дизайну.
Почему это круто
| Аспект | Что получаешь |
|---|---|
| Скорость разработки | 10 сущностей за час вместо дня |
| Надёжность | Compile-time проверка всего |
| Безопасность | Невозможно случайно утечь данные |
| Производительность | Zero-cost, как написанный вручную код |
| Понятность | Прозрачная генерация, никакой магии |
| Гибкость | От простого CRUD до CQRS за один атрибут |
| Масштабируемость | Архитектура растёт с проектом |
| Поддерживаемость | Один источник истины, меньше багов |
Документация
| Тема | Описание |
|---|---|
| [[Атрибуты|Атрибуты]] | Полный справочник атрибутов |
| [[Фильтрация|Фильтрация]] | Типобезопасная фильтрация запросов |
| [[Связи|Связи]] | belongs_to и has_many |
| [[События|События]] | События жизненного цикла |
| [[Хуки|Хуки]] | Before/after хуки |
| [[Команды|Команды]] | CQRS-паттерн |
| [[Кастомный SQL|Кастомный-SQL]] | Сложные запросы |
| [[Примеры|Примеры]] | Реальные сценарии использования |
| [[Веб-фреймворки|Веб-фреймворки]] | Интеграция с Axum, Actix |
| [[Лучшие практики|Лучшие-практики]] | Рекомендации для продакшена |
Это не фреймворк который диктует как жить. Это инструмент который убирает рутину и даёт строить правильно.