Skip to content

Entity functions: validation, mutation, and triggers

Entity functions are C# classes in the workspace Net Operation Project that attach to one record type, typed against the project’s generated entity classes — current.Title is a compile-checked property, not a string key lookup. ValidationFunction<TEntity> blocks invalid saves; MutationFunction<TEntity> changes data, inside the save or after it, depending on the binding.

For everyday field checks, prefer declarative validation rules; they run before any C# logic. Reach for entity functions when a check or change needs injected services, related records, or orchestration.

A validation function overrides OnValidateAsync(previous, current, ct) and returns a ValidationResult. previous is a detached snapshot of the record before the operation and is null only on create.

using Moltaro.Operational.Generated.Entities;
using Moltaro.Package.NET.Functions;
[MoltaroFunction(
"acme.serviceTicketValidation",
Name = "Require service ticket title",
Description = "Blocks service ticket saves when the title is missing.")]
[EntitySaveBinding(FunctionOperation.Create, Id = "create", SortOrder = 10)]
[EntitySaveBinding(FunctionOperation.Update, Id = "update", SortOrder = 10)]
public sealed class ServiceTicketValidationFunction
: ValidationFunction<ServiceTicket>
{
public override Task<ValidationResult> OnValidateAsync(
ServiceTicket? previous,
ServiceTicket current,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(current.Title))
{
return Task.FromResult(ValidationResult.Invalid(new ValidationIssue(
"Ticket title is required.",
"Title",
"ticket.title.required")));
}
return Task.FromResult(ValidationResult.Valid);
}
}

Return ValidationResult.Valid to allow the save, or ValidationResult.Invalid(...) with one or more ValidationIssue values to block it. Each issue carries a user-facing message, an optional FieldKey (null for record-level issues), and an optional stable machine-readable Code for tests and client behavior — not a localization key. [MoltaroFunction] declares the function’s durable id plus the display name and description shown to administrators and configurators.

Validation runs in a read-only phase: it inspects previous and current and returns issues, but it must not change data. Writes made through an injected DbContext or MoltaroDbContext during validation are not persisted, and the Boards and Entitlement application-automation facades are unavailable here (they return moltaro.automation.executionPhase.unsupported). Record mutation and side effects belong in a mutation, trigger, action, or job function.

A mutation function overrides OnMutateAsync(previous, current, ct) and edits the working record or related data. Bound with EntitySaveBinding, it runs before the record is written, so changes made on current persist as part of the same save — computed defaults and normalization belong here.

The save remains authorization-atomic around that function run. Moltaro captures the Entity Definition, field, table, Security configuration version, dependency versions, and active mutation-effect versions together with the caller’s roles, responsibility groups, and common responsibility assignments when it prepares the function input. When a mutation effect reads through a Reference path, the fingerprint also covers every referenced Entity Definition, field and Security Statement dependency used by that path. Immediately before validation and persistence, Moltaro compares the persisted versions for the root definition and that Reference-path contributor closure. It keeps the current snapshot when every version still matches and rebuilds the complete definition snapshot only when the cache is cold or drift is detected, then verifies the same fingerprint. The final transaction also prevents those authorization and automation inputs from changing until the record commit completes. A current operation or access check that runs before the fingerprint comparison can return its specific disabled, read-only, or access-denied result. Otherwise, a changed prepared authorization fingerprint is rejected as a definition conflict. In either case the caller must reload and retry; Moltaro never combines newly restricted caller input or an obsolete mutation-effect catalog with trusted function output computed from the older draft.

Both binding attributes name the entity operation and can be applied multiple times per class:

  • [EntitySaveBinding(operation)] runs the function inside the save operation; the caller waits for it. Validation functions may bind to Create, Update, Archive, Restore, and Delete; mutation functions may bind to Create and Update before-save.
  • [EntityTriggerBinding(operation)] binds a mutation function to a lifecycle trigger. The function runs after the original transaction commits, asynchronously on the worker. Delete triggers receive detached snapshots because the deleted record may no longer exist in the database.

Both attributes share three properties: Id (a stable binding identifier — changing it creates a different binding), SortOrder (lower values run earlier within the same operation), and EnabledByDefault.

Agents can verify this distinction after publication without interpreting the source: GET /api/workspace/admin/function-catalog/binding-summaries exposes Source = EntitySave for request-blocking bindings and Source = EntityTrigger for queued after-commit bindings. See Asynchronous operations and polling.

The lifecycle event is captured inside the original write transaction and delivered only after that transaction commits; nothing fires when the save rolls back. Trigger runs are non-recursive by default:

interactive API write -> trigger eligible
function-originated write -> trigger suppressed

Writes performed by a function do not re-fire triggers, so an update trigger can safely write back to the same record type without creating a loop.

A trigger also supports an optional statement filter — the function runs only for records that match it — and an optional deduplication key template that collapses repeated events for the same key into one queued run. Every run carries the correlation id of the originating operation, and update events include the changed field keys.

using System.Text.Json.Nodes;
using Moltaro.Operational.Generated.Entities;
using Moltaro.Package.NET;
using Moltaro.Package.NET.Functions;
[MoltaroFunction(
"acme.serviceTicketUpdatedTrigger",
Name = "Log service ticket update",
Description = "Writes DevLog diagnostics after a service ticket update.")]
[EntityTriggerBinding(FunctionOperation.Update, Id = "update", SortOrder = 10)]
public sealed class ServiceTicketUpdatedTriggerFunction(IDevLog devLog)
: MutationFunction<ServiceTicket>
{
public override async Task OnMutateAsync(
ServiceTicket? previous,
ServiceTicket current,
CancellationToken cancellationToken)
{
await devLog.LogInformationAsync(
"Service ticket update trigger ran.",
new JsonObject { ["TicketId"] = current.Id },
cancellationToken: cancellationToken);
}
}

Functions are ordinary classes and receive project services through constructor injection, as IDevLog shows above.

Entity functions are authored as part of the Net Operation Project in the Constructor area, group Automation & logic, on the Development page (titled “Business Logic Development”; the NET Project mode covers the C# sources). The Function Catalog page in the same group lists the workspace’s functions. Trigger and queued runs are monitored in the Administration area, group Monitoring, on the Function operations page — see Operations.

Calls to external systems — notifying another service after a record changes, pushing data into a downstream API — are written as ordinary C# inside trigger functions or global functions. Moltaro has no separate outbound-webhook feature: an update trigger that sends an HTTP request to another system is the outbound webhook. For receiving calls from external systems, see HTTP endpoints.