-
-
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());
}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 | 🇨🇳 中文
🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어 | 🇪🇸 Español | 🇨🇳 中文
Getting Started
Features
Advanced
Начало работы
Возможности
Продвинутое
시작하기
기능
고급
Comenzando
Características
Avanzado
入门
功能
高级