-
-
Notifications
You must be signed in to change notification settings - Fork 0
Best Practices en
Guidelines for using entity-derive effectively in production.
One entity per database table. Don't try to model complex relationships in a single entity.
// Good: Separate entities #[derive(Entity)] #[entity(table = "users")] pub struct User { #[id] pub id: Uuid, #[field(create, update, response)] pub name: String, } #[derive(Entity)] #[entity(table = "posts")] pub struct Post { #[id] pub id: Uuid, #[field(create, response)] pub author_id: Uuid, // Reference, not embed #[field(create, update, response)] pub title: String, } // Bad: Trying to embed relationships pub struct User { pub id: Uuid, pub posts: Vec<Post>, // Don't do this }
Be explicit about each field's purpose:
// Good: Clear intent #[field(create, response)] // Set once, always visible pub email: String, #[field(update, response)] // Can change, always visible pub display_name: Option<String>, #[field(response)] // Read-only, computed/managed elsewhere pub post_count: i64, #[field(skip)] // Never exposed pub password_hash: String, // Bad: Everything everywhere #[field(create, update, response)] // Is this really needed for all? pub internal_id: String,
Match your database schema:
// Database: email VARCHAR NOT NULL #[field(create, update, response)] pub email: String, // Database: bio TEXT NULL #[field(update, response)] pub bio: Option<String>,
// Passwords #[field(skip)] pub password_hash: String, // API keys #[field(skip)] pub api_key: String, // Internal tokens #[field(skip)] pub refresh_token: Option<String>, // PII that shouldn't be in responses #[field(skip)] pub ssn: String, // Internal audit data #[field(skip)] pub created_by_ip: String,
For admin-only data, consider separate entities:
// Public entity #[derive(Entity)] #[entity(table = "users")] pub struct User { #[id] pub id: Uuid, #[field(create, update, response)] pub name: String, #[field(skip)] pub admin_notes: Option<String>, } // Admin-only entity (same table, different view) #[derive(Entity)] #[entity(table = "users", sql = "trait")] pub struct AdminUser { #[id] pub id: Uuid, #[field(response)] pub name: String, #[field(update, response)] // Now visible and editable pub admin_notes: Option<String>, #[field(response)] pub last_login_ip: Option<String>, }
Don't fight the generated SQL. If you need joins or complex logic, implement it yourself:
// Simple CRUD - use full generation #[entity(table = "categories", sql = "full")] // Complex queries needed - implement yourself #[entity(table = "posts", sql = "trait")]
For bulk inserts, implement custom methods:
#[entity(table = "events", sql = "trait")] pub struct Event { /* ... */ } pub trait EventBatchRepository { async fn create_batch(&self, events: Vec<CreateEventRequest>) -> Result<(), sqlx::Error>; } #[async_trait] impl EventBatchRepository for PgPool { async fn create_batch(&self, events: Vec<CreateEventRequest>) -> Result<(), sqlx::Error> { let mut tx = self.begin().await?; for event in events { let entity = Event::from(event); let insertable = InsertableEvent::from(&entity); // Insert within transaction } tx.commit().await?; Ok(()) } }
Use joins instead of loading related entities one by one:
// Bad: N+1 queries let posts = pool.list(100, 0).await?; for post in &posts { let author = pool.find_user_by_id(post.author_id).await?; // N queries! } // Good: Single query with join let posts_with_authors = pool.list_with_authors(100, 0).await?; // 1 query
#[cfg(test)] mod tests { use sqlx::PgPool; async fn setup_test_db() -> PgPool { let url = std::env::var("TEST_DATABASE_URL") .expect("TEST_DATABASE_URL must be set"); let pool = PgPool::connect(&url).await.unwrap(); // Run migrations sqlx::migrate!("./migrations") .run(&pool) .await .unwrap(); pool } #[tokio::test] async fn test_create_user() { let pool = setup_test_db().await; let request = CreateUserRequest { username: "test_user".into(), email: "test@example.com".into(), }; let user = pool.create(request).await.unwrap(); assert_eq!(user.username, "test_user"); } }
#[test] fn test_user_response_excludes_password() { let user = User { id: Uuid::new_v4(), username: "test".into(), email: "test@example.com".into(), password_hash: "secret_hash".into(), created_at: Utc::now(), }; let response = UserResponse::from(&user); // password_hash is not in UserResponse assert_eq!(response.username, "test"); // No way to access password_hash through response } #[test] fn test_update_request_is_partial() { let update = UpdateUserRequest { username: Some("new_name".into()), email: None, // Not updating email }; assert!(update.username.is_some()); assert!(update.email.is_none()); }
Generated SQL is correct by construction, but the table can drift (missed migration, manual ALTER, renamed column) — that surfaces as runtime decode errors. Every Postgres entity gets {Entity}::SCHEMA (declared columns: name, DDL type, nullability) and {Entity}::assert_schema(pool), which compares the declaration against information_schema.columns and reports all drifts at once. Run one integration test per entity:
#[tokio::test] async fn entities_match_database_schema() { let pool = test_pool().await; User::assert_schema(&pool).await.expect("users drifted"); Order::assert_schema(&pool).await.expect("orders drifted"); }
Type comparison is family-based (TEXT/VARCHAR/CHAR fold together; the date/time families fold their aliases to the spelled-out information_schema names — TIMESTAMPTZ ↔ timestamp with time zone, TIME ↔ time without time zone, TIMETZ ↔ time with time zone; arrays compare as arrays); Postgres enums (USER-DEFINED) skip the type check but keep presence + nullability guarantees.
src/
├── entities/ # Entity definitions
│ ├── mod.rs
│ ├── user.rs
│ ├── post.rs
│ └── comment.rs
├── repositories/ # Custom repository extensions
│ ├── mod.rs
│ └── post_search.rs
├── handlers/ # HTTP handlers
│ ├── mod.rs
│ ├── users.rs
│ └── posts.rs
├── services/ # Business logic
│ ├── mod.rs
│ └── auth.rs
└── main.rs
// src/entities/mod.rs mod user; mod post; pub use user::*; pub use post::*;
// src/entities/auth/mod.rs mod user; mod session; mod api_key; pub use user::*; pub use session::*; pub use api_key::*;
// Wrong: password_hash will be in Response! pub struct User { pub password_hash: String, } // Right #[field(skip)] pub password_hash: String,
If you need related data, use sql = "trait" and implement yourself.
Remember: UpdateRequest fields are Option<T>. Check before applying:
// Generated UpdateUserRequest has Option<String> for name // Your update logic should handle None (no change) vs Some (change)
Put validation and business rules in a service layer, not in handlers:
// Good: Service layer impl UserService { pub async fn create_user(&self, request: CreateUserRequest) -> Result<User, AppError> { self.validate_email(&request.email)?; self.check_username_available(&request.username).await?; self.pool.create(request).await.map_err(Into::into) } } // Bad: Logic scattered in handlers pub async fn create_user(pool: State<PgPool>, request: Json<CreateUserRequest>) -> ... { // Validation here // Business rules here // Repository call here // All mixed together }
Before deploying:
- All sensitive fields have
#[field(skip)] - DTOs match API contract expectations
- Complex queries use
sql = "trait" - Integration tests cover repository methods
- Error handling is consistent
- Pagination is implemented for list endpoints
- Database indexes exist for query patterns
🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어 | 🇪🇸 Español | 🇨🇳 中文
Getting Started
Features
Advanced
Начало работы
Возможности
Продвинутое
시작하기
기능
고급
Comenzando
Características
Avanzado
入门
功能
高级