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

Lifecycle Hooks

Configure operation hooks directly through attributes for simple cases.

Overview

Lifecycle hooks provide attribute-based customization without implementing CRUDOperations:

#[derive(EntityToModels)]
#[crudcrate(
    generate_router,
    create::one::pre = validate_article,
    create::one::post = notify_subscribers,
    update::one::pre = check_permissions,
    delete::one::pre = archive_instead_of_delete,
)]
pub struct Model {
    // ...
}

Hook Types

Pre Hooks (::pre)

Execute before the database operation:

#[crudcrate(create::one::pre = validate_article)]

// Hook function signature
async fn validate_article<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: &mut ArticleCreate,
) -> Result<(), ApiError> {
    if data.title.is_empty() {
        return Err(ApiError::ValidationFailed(vec![
            ValidationError::new("title", "Title is required")
        ]));
    }
    Ok(())
}

Post Hooks (::post)

Execute after the database operation succeeds:

#[crudcrate(create::one::post = notify_subscribers)]

// Hook function signature
async fn notify_subscribers<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    created: &Article,
) -> Result<(), ApiError> {
    send_notifications(created.author_id).await;
    Ok(())
}

Body Replacement (::body)

Replace the entire operation logic:

#[crudcrate(delete::one::body = soft_delete)]

// Completely replaces the delete handler
async fn soft_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    // Instead of deleting, mark as deleted
    let mut article: ActiveModel = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(ApiError::NotFound)?
        .into();

    article.deleted_at = Set(Some(chrono::Utc::now()));
    article.update(db).await?;

    Ok(())
}

Transform Hooks (::transform)

Modify the result after the operation (or body replacement) but before the post hook:

#[crudcrate(read::one::transform = enrich_article)]

// Hook function signature
async fn enrich_article<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    article: Article,  // Takes ownership
) -> Result<Article, ApiError> {
    // Enrich, decorate, or transform the result
    Ok(article)
}

Transform hooks take ownership of the result and return a modified version. Supported for all operations (create, read, update, delete) in both one and many cardinality.

For many operations, the transform receives and returns Vec<T>:

async fn transform_articles<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    articles: Vec<Article>,
) -> Result<Vec<Article>, ApiError> {
    // Transform the batch
    Ok(articles)
}

Available Operations

OperationDescription
create::oneCreate single item (POST /items)
create::manyBatch create (POST /items/batch)
update::oneUpdate single item (PUT /items/:id)
update::manyBatch update (PATCH /items/batch)
delete::oneDelete single item (DELETE /items/:id)
delete::manyBulk delete (DELETE /items with body)
read::oneGet single item (GET /items/:id)
read::allList items (GET /items)

Function Signatures

Create Hooks

// Pre hook - can modify the input
async fn create_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: &mut ArticleCreate,
) -> Result<(), ApiError>;

// Post hook - receives the created entity
async fn create_post<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    created: &Article,
) -> Result<(), ApiError>;

// Body replacement - full control
async fn create_body<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    data: ArticleCreate,
) -> Result<Article, ApiError>;

// Transform - modify the result
async fn create_transform<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    created: Article,
) -> Result<Article, ApiError>;

Update Hooks

// Pre hook
async fn update_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
    data: &mut ArticleUpdate,
) -> Result<(), ApiError>;

// Post hook
async fn update_post<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    updated: &Article,
) -> Result<(), ApiError>;

// Body replacement
async fn update_body<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
    data: ArticleUpdate,
) -> Result<Article, ApiError>;

// Transform - modify the result
async fn update_transform<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    updated: Article,
) -> Result<Article, ApiError>;

Delete Hooks

// Pre hook
async fn delete_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError>;

// Post hook
async fn delete_post<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError>;

// Body replacement
async fn delete_body<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError>;

Get Hooks

// Pre hook for get_one
async fn get_one_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError>;

// Pre hook for get_all - can modify the condition
async fn get_all_pre<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    condition: &mut Condition,
) -> Result<(), ApiError>;

Examples

Input Validation

#[crudcrate(create::one::pre = validate_user)]

async fn validate_user<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    data: &mut UserCreate,
) -> Result<(), ApiError> {
    let mut errors = Vec::new();

    // Email validation
    if !data.email.contains('@') {
        errors.push(ValidationError::new("email", "Invalid email format"));
    }

    // Password strength
    if data.password.len() < 8 {
        errors.push(ValidationError::new("password", "Must be at least 8 characters"));
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(ApiError::ValidationFailed(errors))
    }
}

Password Hashing

#[crudcrate(create::one::pre = hash_password)]

async fn hash_password<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    data: &mut UserCreate,
) -> Result<(), ApiError> {
    // Hash the password before storing
    data.password_hash = bcrypt::hash(&data.password, bcrypt::DEFAULT_COST)
        .map_err(|_| ApiError::Internal("Failed to hash password".into()))?;

    // Clear the plain password
    data.password.clear();

    Ok(())
}

Soft Delete

#[crudcrate(delete::one::body = soft_delete_article)]

async fn soft_delete_article<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    let article = Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(ApiError::NotFound)?;

    let mut active: ActiveModel = article.into();
    active.deleted_at = Set(Some(chrono::Utc::now()));
    active.update(db).await?;

    Ok(())
}

Row-Level Security

#[crudcrate(get::all::pre = filter_by_tenant)]

async fn filter_by_tenant<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    condition: &mut Condition,
) -> Result<(), ApiError> {
    let tenant_id = get_current_tenant_id();

    *condition = condition.clone().add(Column::TenantId.eq(tenant_id));

    Ok(())
}

Audit Trail

#[crudcrate(
    create::one::post = log_create,
    update::one::post = log_update,
    delete::one::post = log_delete,
)]

async fn log_create<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    created: &Article,
) -> Result<(), ApiError> {
    save_audit_log(db, "CREATE", "Article", &created.id.to_string()).await
}

async fn log_update<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    updated: &Article,
) -> Result<(), ApiError> {
    save_audit_log(db, "UPDATE", "Article", &updated.id.to_string()).await
}

async fn log_delete<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    save_audit_log(db, "DELETE", "Article", &id.to_string()).await
}

Cascade Updates

#[crudcrate(update::one::post = update_search_index)]

async fn update_search_index<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    _db: &C,
    updated: &Article,
) -> Result<(), ApiError> {
    // Update search engine
    search_client::update_document("articles", updated.id, updated).await;
    Ok(())
}

Authorization Check

#[crudcrate(
    update::one::pre = check_edit_permission,
    delete::one::pre = check_delete_permission,
)]

async fn check_edit_permission<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
    _data: &mut ArticleUpdate,
) -> Result<(), ApiError> {
    let article = Entity::find_by_id(id).one(db).await?.ok_or(ApiError::NotFound)?;
    let user = get_current_user();

    if article.author_id != user.id && !user.is_admin {
        return Err(ApiError::Forbidden);
    }

    Ok(())
}

async fn check_delete_permission<C: sea_orm::ConnectionTrait + sea_orm::TransactionTrait>(
    db: &C,
    id: Uuid,
) -> Result<(), ApiError> {
    let article = Entity::find_by_id(id).one(db).await?.ok_or(ApiError::NotFound)?;
    let user = get_current_user();

    if !user.is_admin {
        return Err(ApiError::Forbidden);
    }

    Ok(())
}

Hooks vs CRUDOperations

FeatureHooksCRUDOperations
ConfigurationAttributeTrait impl
ScopePer-operationAll operations
StateStateless functionsCan hold state
TestingFunction mockingTrait mocking
Use whenSimple casesComplex logic

When to Use Hooks

  • Simple validation
  • Data transformation
  • Logging/notifications
  • Single operation customization

When to Use CRUDOperations

  • Complex authorization logic
  • Shared state across operations
  • Database-dependent validation
  • Multiple operations need same logic

Combining Both

You can use both hooks and CRUDOperations:

#[derive(EntityToModels)]
#[crudcrate(
    generate_router,
    operations = ArticleOperations,
    create::one::pre = validate_article,  // Runs before CRUDOperations::before_create
)]
pub struct Model { }

Execution order:

  1. Attribute hook (create::one::pre)
  2. CRUDOperations::before_create
  3. Database operation (or create::one::body replacement)
  4. Attribute hook (create::one::transform)
  5. CRUDOperations::after_create
  6. Attribute hook (create::one::post)

Hooks and Partial Success Mode

When using ?partial=true on batch endpoints, items are processed individually using single-item hooks (create::one::*, update::one::*, delete::one::*), not batch hooks (create::many::*, etc.). Each item commits independently with no shared transaction.

This means:

  • create::one::pre runs once per item, not create::many::pre once for the batch
  • A failing item does not roll back previously succeeded items
  • Transform hooks run per-item, receiving a single entity (not a Vec)

See Security for more details on partial success behavior.

Next Steps