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)
| Parameter | Description |
|---|---|
context | The current DbContext instance |
newEntity | Incoming data for BeforeSave; saved/patched data for AfterSave |
originalEntity | Database state before the change. null for POST. For DELETE, equals newEntity. |
operation | OtterApiCrudOperation.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.