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

Один макрос, чтобы править всеми

English Русский 한국어 Español 中文


Что это?

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, UserResponse
  • UserRepository с типобезопасным SQL
  • UserEvent::Created, Updated, Deleted
  • UserHooks для бизнес-логики
  • 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, PostResponse
  • PostRepository с 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
[[Лучшие практики|Лучшие-практики]]Рекомендации для продакшена

Это не фреймворк который диктует как жить. Это инструмент который убирает рутину и даёт строить правильно.