Entity Lifecycle Hooks¶
Hooks are the primary extension point in Head.Net. They run at defined points in the CRUD pipeline and give you a clean place to put domain logic — timestamps, notifications, audit trails, derived state — without touching HTTP request or response objects.
Available hooks¶
| Hook | When it runs | Return type | Can abort? |
|---|---|---|---|
BeforeCreate |
Before the entity is persisted | ValueTask<HeadHookResult<TEntity>?> |
✅ Yes — return Invalid(...) |
AfterCreate |
After the entity is persisted | ValueTask |
No |
BeforeUpdate |
Before the entity is updated | ValueTask<HeadHookResult<TEntity>?> |
✅ Yes — return Invalid(...) |
AfterUpdate |
After the entity is updated | ValueTask |
No |
BeforeDelete |
Before the entity is removed | ValueTask |
No |
AfterDelete |
After the entity is removed | ValueTask |
No |
BeforeCreate and BeforeUpdate return HeadHookResult<TEntity>?. Return null to proceed normally, or HeadHookResult<TEntity>.Invalid(validation) to abort with a 400 Validation Failed response. All other hooks are fire-and-forget ValueTask.
BeforeCreate¶
Use BeforeCreate to set derived fields, defaults, or computed state before the entity reaches the database. Return null to proceed, or a validation result to abort with 400.
.BeforeCreate((invoice, ct) =>
{
invoice.CreatedAt = DateTimeOffset.UtcNow;
invoice.Status = "draft";
invoice.ReferenceNumber = GenerateReference();
return new ValueTask<HeadHookResult<Invoice>?>((HeadHookResult<Invoice>?)null); // null = proceed
})
The entity object passed to BeforeCreate is the same instance that will be persisted. Mutations here are reflected in the database and in the 201 Created response.
AfterCreate¶
AfterCreate runs after the entity has been saved. The entity has an assigned Id at this point.
.AfterCreate(async (invoice, ct) =>
{
await emailService.SendConfirmationAsync(invoice.CustomerEmail, invoice.Id, ct);
await auditLog.RecordAsync("Invoice created", invoice.Id, ct);
})
:::info
If AfterCreate mutates the entity (for example, setting a computed field), SaveChangesAsync is called automatically after the hook runs. Mutations are safe here.
:::
BeforeUpdate¶
BeforeUpdate receives the entity ID and the incoming replacement data. Use it to validate the incoming data, reject illegal state transitions, or stamp an audit timestamp. Return null to proceed, or a validation result to abort with 400.
.BeforeUpdate((id, invoice, ct) =>
{
invoice.UpdatedAt = DateTimeOffset.UtcNow;
return new ValueTask<HeadHookResult<Invoice>?>((HeadHookResult<Invoice>?)null); // null = proceed
})
Note that BeforeUpdate receives the incoming entity, not the existing one. If you need the existing record, fetch it from the store or inject a service.
AfterUpdate¶
AfterUpdate runs after the update has been persisted. It receives the ID and the updated entity.
.AfterUpdate(async (id, invoice, ct) =>
{
if (invoice.Status == "paid")
{
await billing.RecordPaymentAsync(invoice.Id, invoice.Total, ct);
}
})
BeforeDelete¶
BeforeDelete receives only the entity ID. Use it for pre-deletion validation, archiving related data, or audit logging.
.BeforeDelete(async (id, ct) =>
{
var hasRelatedOrders = await orders.ExistsForInvoiceAsync(id, ct);
if (hasRelatedOrders)
{
// See Validation section below for how to block this
}
await auditLog.RecordAsync("Invoice delete requested", id, ct);
})
AfterDelete¶
AfterDelete receives the deleted entity. The entity is no longer in the database at this point.
.AfterDelete(async (invoice, ct) =>
{
await searchIndex.RemoveAsync(invoice.Id, ct);
await cache.InvalidateAsync($"invoice:{invoice.Id}", ct);
})
Async hooks¶
All hooks accept async lambdas. Use async/await freely:
.AfterCreate(async (invoice, ct) =>
{
await Task.WhenAll(
notifications.PushAsync(invoice.Id, ct),
analytics.TrackCreateAsync(invoice, ct)
);
})
Multiple hooks on the same operation¶
Each hook slot holds a single delegate. If you need multiple operations in one hook, compose them inline or delegate to a service:
.AfterCreate(async (invoice, ct) =>
{
await invoiceService.HandleCreatedAsync(invoice, ct);
// invoiceService.HandleCreatedAsync internally does notifications + audit
})
Or use a setup class (see Setup Classes) and inject an InvoiceEventService through the constructor.
Validation and short-circuiting¶
BeforeCreate and BeforeUpdate support clean short-circuit validation. Return HeadHookResult<TEntity>.Invalid(validation) to abort the operation and respond with 400 Validation Failed (RFC 7807 Problem Details). No exception throwing required.
.BeforeCreate((invoice, ct) =>
{
if (invoice.Total <= 0)
{
var validation = HeadValidationResult.Failure("Total must be greater than zero");
return new ValueTask<HeadHookResult<Invoice>?>(HeadHookResult<Invoice>.Invalid(validation));
}
invoice.CreatedAt = DateTimeOffset.UtcNow;
return new ValueTask<HeadHookResult<Invoice>?>((HeadHookResult<Invoice>?)null); // proceed
})
Multiple errors can be returned at once:
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(invoice.CustomerName)) errors.Add("CustomerName is required");
if (invoice.Total <= 0) errors.Add("Total must be positive");
if (errors.Count > 0)
{
var validation = HeadValidationResult.Failure(errors.ToArray());
return new ValueTask<HeadHookResult<Invoice>?>(HeadHookResult<Invoice>.Invalid(validation));
}
The resulting error response:
{
"type": "https://head.net/errors/validation-failed",
"title": "Validation Failed",
"detail": "CustomerName is required; Total must be positive.",
"status": 400
}
Organizing hooks¶
For entities with many hooks, inline lambdas in Program.cs get noisy quickly. The recommended pattern is a Setup Class:
// Program.cs — clean
app.MapEntity<Invoice>()
.WithCrud()
.Setup<InvoiceSetup>()
.Build();
// InvoiceSetup.cs — all hooks in one place
public sealed class InvoiceSetup : IHeadEntitySetup<Invoice>
{
private readonly IInvoiceService _invoiceService;
public InvoiceSetup(IInvoiceService invoiceService)
=> _invoiceService = invoiceService;
public void Configure(HeadEntityEndpointBuilder<Invoice> builder)
{
builder
.BeforeCreate(OnBeforeCreate)
.AfterCreate(OnAfterCreate)
.BeforeDelete(OnBeforeDelete);
}
private ValueTask OnBeforeCreate(Invoice invoice, CancellationToken ct)
{
invoice.CreatedAt = DateTimeOffset.UtcNow;
invoice.Status = "draft";
return ValueTask.CompletedTask;
}
private async ValueTask OnAfterCreate(Invoice invoice, CancellationToken ct)
=> await _invoiceService.NotifyCreatedAsync(invoice, ct);
private async ValueTask OnBeforeDelete(int id, CancellationToken ct)
=> await _invoiceService.ValidateDeletableAsync(id, ct);
}
The setup class receives IInvoiceService from DI automatically — no registration of InvoiceSetup itself required.