-
-
Notifications
You must be signed in to change notification settings - Fork 0
Главная
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.
#[field(skip)] pub password_hash: String,
Это не runtime-проверка "не сериализуй это поле". Это физическое отсутствие поля в структуре UserResponse. Невозможно случайно вернуть — поля просто нет.
Сгенерированный код — это:
- Обычные
structбез Box/dyn - Прямые вызовы sqlx без промежуточных слоёв
-
#[inline]на горячих путях - Никаких аллокаций сверх необходимого
Benchmark: сгенерированный репозиторий работает с той же скоростью, что и написанный вручную. Потому что это и есть тот же код.
// Всё 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>
Если код скомпилировался — он работает правильно.
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. Всё это — детали реализации.
// 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 | Сложные запросы |
| Примеры | Реальные сценарии использования |
| Веб-фреймворки | Интеграция с Axum, Actix |
| Лучшие практики | Рекомендации для продакшена |
Это не фреймворк который диктует как жить. Это инструмент который убирает рутину и даёт строить правильно.
🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어 | 🇪🇸 Español | 🇨🇳 中文
Getting Started
Features
Advanced
Начало работы
Возможности
Продвинутое
시작하기
기능
고급
Comenzando
Características
Avanzado
入门
功能
高级