BeforeSave / AfterSave Hooks

Hooks let you execute arbitrary logic before or after an entity is saved to the database. Multiple hooks can be chained on the same entity — all run in registration order. Both BeforeSave and AfterSave are invoked for POST, PUT, PATCH, and DELETE operations.

Hook signature

Signature
(DbContext context, T newEntity, T? originalEntity, OtterApiCrudOperation operation)
ParameterDescription
contextThe current DbContext instance
newEntityIncoming data for BeforeSave; saved/patched data for AfterSave
originalEntityDatabase state before the change. null for POST. For DELETE, equals newEntity.
operationOtterApiCrudOperation.Post, .Put, .Patch, or .Delete

Lambda approach

Best for concise inline logic. Both synchronous and asynchronous overloads are supported.

BeforeSave

Synchronous BeforeSave
options.Entity<Product>("products")
    .BeforeSave((DbContext ctx, Product p, Product? original, OtterApiCrudOperation op) =>
    {
        if (op == OtterApiCrudOperation.Post)
            p.CreatedAt = DateTime.UtcNow;

        if (op == OtterApiCrudOperation.Put && original != null)
        {
            if (original.Price != p.Price)
                Console.WriteLine($"Price changed: {original.Price} → {p.Price}");
        }
    });
Async BeforeSave — validation with OtterApiException
options.Entity<Product>("products")
    .BeforeSave(async (DbContext ctx, Product p, Product? _, OtterApiCrudOperation op) =>
    {
        if (op == OtterApiCrudOperation.Post)
        {
            var exists = await ctx.Set<Product>().AnyAsync(x => x.Name == p.Name);
            if (exists)
                throw new OtterApiException("DUPLICATE_NAME", "Name already taken.", 409);
        }
    });

AfterSave

Async AfterSave — post-save side effect
options.Entity<Product>("products")
    .AfterSave(async (DbContext ctx, Product saved, Product? _, OtterApiCrudOperation op) =>
    {
        if (op == OtterApiCrudOperation.Delete)
            await NotificationService.SendAsync($"Product '{saved.Name}' was deleted.");
    });
💡
BeforeSave is called before dbContext.SaveChangesAsync(). AfterSave is called after.

Handler approach (interface)

Best for complex logic that uses DI dependencies, or when you want to separate concerns into dedicated classes.

Interfaces

IOtterApiBeforeSaveHandler<T>
public interface IOtterApiBeforeSaveHandler<T> where T : class
{
    Task BeforeSaveAsync(DbContext context, T newEntity, T? originalEntity, OtterApiCrudOperation operation);
}

public interface IOtterApiAfterSaveHandler<T> where T : class
{
    Task AfterSaveAsync(DbContext context, T newEntity, T? originalEntity, OtterApiCrudOperation operation);
}

Implementation example

ProductBeforeSaveHandler.cs
public class ProductBeforeSaveHandler : IOtterApiBeforeSaveHandler<Product>
{
    private readonly ILogger<ProductBeforeSaveHandler> _logger;

    public ProductBeforeSaveHandler(ILogger<ProductBeforeSaveHandler> logger)
        => _logger = logger;

    public async Task BeforeSaveAsync(
        DbContext context, Product newProduct, Product? original, OtterApiCrudOperation operation)
    {
        if (operation == OtterApiCrudOperation.Post)
        {
            newProduct.CreatedAt = DateTime.UtcNow;
            _logger.LogInformation("Creating product: {Name}", newProduct.Name);

            var duplicate = await context.Set<Product>().AnyAsync(p => p.Name == newProduct.Name);
            if (duplicate)
                throw new OtterApiException("DUPLICATE", "A product with this name already exists.", 409);
        }
    }
}

Registering handler instances

Program.cs — registering handlers
options.Entity<Product>("products")
    .BeforeSave(new ProductBeforeSaveHandler(loggerFactory.CreateLogger<ProductBeforeSaveHandler>()))
    .AfterSave(new ProductAfterSaveHandler(eventBus));
⚠️
Hooks are registered once at application startup. If your handler requires scoped dependencies (e.g. another DbContext, HTTP client), use the lambda approach and resolve dependencies through the provided DbContext or a scope factory.

Chaining multiple hooks

Call .BeforeSave() or .AfterSave() multiple times to chain hooks. All run in registration order. Each subsequent BeforeSave handler sees the entity in the state left by the previous handler.

Chaining lambdas and handler classes
options.Entity<Order>("orders")
    .BeforeSave((ctx, order, _, op) =>
    {
        if (op == OtterApiCrudOperation.Post)
            order.CreatedAt = DateTime.UtcNow;
    })
    .BeforeSave(new OrderValidationHandler())     // runs second
    .AfterSave(new OrderAuditHandler())           // runs first after save
    .AfterSave((_, order, _, op) =>
    {
        if (op == OtterApiCrudOperation.Post)
            Console.WriteLine($"[Audit] New order #{order.Id}");
    });

Execution order

Order of execution per mutating request
BeforeSave[0] → BeforeSave[1] → ... → SaveChangesAsync → AfterSave[0] → AfterSave[1] → ...
Pre-save and post-save handlers are not wrapped in a database transaction. If an AfterSave hook throws (e.g. while publishing to a message bus), the database changes are already committed and will not be rolled back. For atomic side-effects, manage the transaction manually inside a BeforeSave handler using DbContext.Database.BeginTransactionAsync(), or implement the Outbox pattern.