Custom Actions¶
CRUD covers the structural operations on an entity: create it, read it, update it, delete it. But real systems have domain operations that do not map cleanly to these five verbs. An invoice gets paid. A document gets archived. An order gets fulfilled.
Custom actions let you declare these operations as first-class members of the entity surface.
Declaring a custom action¶
app.MapEntity<Invoice>()
.WithCrud()
.CustomAction("pay", (invoice, ct) =>
{
invoice.Status = "paid";
invoice.PaidAt = DateTimeOffset.UtcNow;
return Task.CompletedTask;
})
.Build();
This registers POST /invoices/{id}/pay.
What happens at runtime¶
- The request arrives at
POST /invoices/{id}/pay - Head.Net fetches the entity with the given
{id}— returns404if not found - Your handler runs with the loaded entity and the cancellation token
SaveChangesAsyncis called automatically to persist any mutations- The updated entity is returned with
200 OK
You never write the fetch, the save, or the 404 check.
Signature¶
.CustomAction(
string name,
Func<TEntity, CancellationToken, Task> handler,
string httpMethod = "POST"
)
name— used as the route segment and the OpenAPI operation name (Invoice_pay)handler— receives the loaded entity; mutations are persisted automaticallyhttpMethod— defaults toPOST; pass"PUT"or"PATCH"if semantics require it
Multiple actions on one entity¶
.CustomAction("pay", (invoice, ct) =>
{
invoice.Status = "paid";
invoice.PaidAt = DateTimeOffset.UtcNow;
return Task.CompletedTask;
})
.CustomAction("archive", (invoice, ct) =>
{
invoice.Status = "archived";
return Task.CompletedTask;
})
.CustomAction("void", (invoice, ct) =>
{
invoice.Status = "void";
invoice.VoidedAt = DateTimeOffset.UtcNow;
return Task.CompletedTask;
})
Each action becomes a separate route:
- POST /invoices/{id}/pay
- POST /invoices/{id}/archive
- POST /invoices/{id}/void
Async actions with external services¶
Handlers are Func<TEntity, CancellationToken, Task>, so you can await freely. The standard pattern for service dependencies is to capture them in a setup class constructor:
public sealed class InvoiceSetup : IHeadEntitySetup<Invoice>
{
private readonly IBillingService _billing;
private readonly INotificationService _notifications;
public InvoiceSetup(IBillingService billing, INotificationService notifications)
{
_billing = billing;
_notifications = notifications;
}
public void Configure(HeadEntityEndpointBuilder<Invoice> builder)
{
builder
.CustomAction("pay", async (invoice, ct) =>
{
await _billing.ChargeAsync(invoice.Id, invoice.Total, ct);
invoice.Status = "paid";
invoice.PaidAt = DateTimeOffset.UtcNow;
await _notifications.SendReceiptAsync(invoice.CustomerEmail, ct);
})
.CustomAction("refund", async (invoice, ct) =>
{
await _billing.RefundAsync(invoice.Id, ct);
invoice.Status = "refunded";
invoice.RefundedAt = DateTimeOffset.UtcNow;
});
}
}
IBillingService and INotificationService are injected from DI into InvoiceSetup at startup. The action lambdas capture them by closure.
OpenAPI¶
Custom actions appear in OpenAPI automatically. For an Invoice entity with a pay action:
- Operation ID:
Invoice_pay - Path:
/invoices/{id}/pay - Method:
POST(or whatever method you specified) - Responses:
200with the updated entity,404if not found
No additional attributes or annotations needed.
HTTP method conventions¶
| Action type | Recommended method |
|---|---|
| State transition (pay, approve, publish) | POST |
| Reversible toggle (archive/unarchive) | POST (with separate actions) |
| Partial update with semantic meaning | PATCH |
| Replace-and-confirm semantics | PUT |
.CustomAction("publish", PublishHandler, "POST")
.CustomAction("unpublish", UnpublishHandler, "POST")
.CustomAction("feature", FeatureHandler, "PATCH")
Differences from BeforeUpdate / AfterUpdate¶
| Custom Action | Update hooks | |
|---|---|---|
| Triggered by | POST /entity/{id}/name |
PUT /entity/{id} |
| Receives request body | No (entity only) | Yes (incoming entity) |
| HTTP semantics | Domain operation | Replace |
| Use case | pay, archive, approve |
General field updates |
Custom actions are for named domain transitions. Update hooks are for processing general field updates. They serve different purposes and compose well together.