Service Behaviors
Serenity request handlers expose their lifecycle through virtual methods you can override in a generated handler. But when the same logic must apply to many entities — audit logging, multi-tenancy, unique constraints, master–detail saving, row localization — repeating it in every handler is impractical.
Behaviors solve this. A behavior is a class that plugs into a handler's lifecycle and is activated based on the row type (or a field), so it runs automatically for every matching handler. They are the recommended way to add cross-cutting concerns to the service layer.
The built-in features — capture log, insert/update log, localization, master–detail, linking set, unique constraints, and more — are all implemented as behaviors. See Built-in Service Behaviors.
Behavior Interfaces
Each handler type has a matching pair of behavior interfaces — a synchronous one (...Sync) and an asynchronous one (...Async). A single behavior class can implement one or more of these interfaces to participate in the corresponding handlers:
| Interface (Async) | Interface (Sync) | Handler it participates in |
|---|---|---|
| ISaveBehaviorAsync | ISaveBehaviorSync | Save (Create / Update) |
| IListBehaviorAsync | IListBehaviorSync | List |
| IDeleteBehaviorAsync | IDeleteBehaviorSync | Delete |
| IRetrieveBehaviorAsync | IRetrieveBehaviorSync | Retrieve |
| IUndeleteBehaviorAsync | IUndeleteBehaviorSync | Undelete |
The two interfaces in a pair both derive from a common marker interface (ISaveBehavior, IListBehavior, etc.) which is what the behavior provider resolves against.
Since handlers are now asynchronous by default, implement the
...Asyncinterface (or derive from aBase...BehaviorAsyncbase class) for new behaviors. The sync variants are still fully supported — async request handlers automatically wrap synchronous behaviors, and sync handlers wrap asynchronous behaviors — so a behavior implementing either variant works with both handler modes.
There are also a few special interfaces:
| Interface | Purpose |
|---|---|
| IListMapFieldExpressionBehavior | List — map a field to a custom SQL expression |
| IFieldBehavior | Any — targets a single field |
There are also optional exception interfaces for handling errors raised during the operation:
- ISaveExceptionBehavior
- IDeleteExceptionBehavior
- IRetrieveExceptionBehavior
- IListExceptionBehavior
- IUndeleteExceptionBehavior
These let a behavior inspect an exception raised by the database operation and, for example, translate a foreign-key or primary-key error into a friendlier validation message. Exception hooks are synchronous (OnException(...)) in both variants — they only inspect the exception and optionally throw a translated one, so they don't need to be async.
Base Classes
Instead of implementing every method of an interface, derive from the corresponding base class which provides empty virtual methods. There is a base for each mode:
- BaseSaveBehaviorAsync — async save behavior (implements
ISaveBehaviorAsyncandISaveExceptionBehavior). - BaseSaveDeleteBehaviorAsync — combines async save and delete, for behaviors that need to act on both (e.g. audit logs, master–detail).
- BaseListBehaviorAsync — async list behavior.
- BaseDeleteBehaviorAsync — async delete behavior (implements
IDeleteBehaviorAsyncandIDeleteExceptionBehavior). - BaseRetrieveBehaviorAsync — async retrieve behavior.
- BaseUndeleteBehaviorAsync — async undelete behavior (implements
IUndeleteBehaviorAsyncandIUndeleteExceptionBehavior).
And the corresponding synchronous bases — BaseSaveBehavior, BaseSaveDeleteBehavior, BaseListBehavior, BaseDeleteBehavior, BaseRetrieveBehavior, BaseUndeleteBehavior. These are kept for behaviors that predate the async migration and are marked obsolete.
You then override only the methods you need.
A single behavior class may implement both the sync and async variants of an interface (and derive from the async base, overriding the sync methods too). Some framework behaviors such as
MasterDetailRelationBehaviordo this so they run natively in both handler modes without being wrapped. Usually, implementing just one variant is enough — the framework wraps it for the other mode.
How Behaviors Are Attached
A behavior is attached to a row type in one of two ways: implicitly (the behavior decides which rows it applies to) or explicitly (the row or field declares it with an attribute).
Implicit Behaviors (IImplicitBehavior)
Most behaviors implement IImplicitBehavior, which has a single method:
public interface IImplicitBehavior
{
bool ActivateFor(IRow row);
}
ActivateFor returns true when the behavior should be used for the given row type. Because the behavior is a singleton that is cached and reused across requests, it is called once per handler type and row type, and is a good place to read attribute/interface metadata and store it in private fields.
For example, the framework's capture-log behavior only activates for rows that have an id and are decorated with [CaptureLog]:
public class CaptureLogBehavior : BaseSaveDeleteBehaviorAsync, IImplicitBehavior
{
private CaptureLogAttribute captureLogAttr;
public bool ActivateFor(IRow row)
{
if (row is not IIdRow)
return false;
captureLogAttr = row.GetType().GetCustomAttribute<CaptureLogAttribute>();
return captureLogAttr != null;
}
}
Because all IImplicitBehavior types are discovered through the type source, you never have to register them manually — AddServiceBehaviors() picks them up automatically.
Explicitly Attaching a Behavior ([AddBehavior])
The AddBehavior attribute attaches a specific behavior type to a row class or a field property:
[AddBehavior(typeof(MyCustomBehavior))]
public sealed class MyRow : Row<MyRow.RowFields>, IIdRow
{
}
This is useful when a behavior should not be first-class (e.g. it is specific to one row) or when its activation must be declared rather than inferred.
Field Behaviors (IFieldBehavior)
A behavior that implements IFieldBehavior is given a Target field before ActivateFor is called:
public interface IFieldBehavior
{
Field Target { get; set; }
}
This lets a single implicit behavior instance work on a per-field basis. For example, the framework's UniqueFieldSaveBehavior and the NotesBehavior in the Northwind demo both use this to operate on whichever field they are applied to. When resolving a field behavior, the provider creates one instance per field and sets Target before checking ActivateFor.
How Behaviors Are Resolved
Behaviors are resolved through IBehaviorProvider, which is available on the handler's Context.Behaviors:
public interface IRequestContext
{
IBehaviorProvider Behaviors { get; }
// ...
}
The default resolution logic (in DefaultBehaviorProvider) gathers, in order:
- Every implicit behavior whose
ActivateFor(row)returnstruefor the row type. - Every behavior explicitly attached to the row class via
[AddBehavior]. - Every field behavior attached to a field via
[AddBehavior].
Behaviors are instantiated by DefaultBehaviorFactory using the DI container, so you can inject dependencies (e.g. ITextLocalizer, ISqlConnections, IDefaultHandlerFactory) into a behavior's constructor.
IBehaviorProvider, IBehaviorFactory, and IImplicitBehaviorRegistry are registered as singletons by AddServiceBehaviors, which is called by AddServiceHandlers() during startup. See Auto-Registration of Request Handlers.
How sync and async behaviors interoperate
The handler resolves behaviors through the common marker interface (e.g. ISaveBehavior), then adapts them to its own mode. The handlers use BehaviorProviderExtensions.AutoWrapBehaviors internally:
- An async handler keeps behaviors that implement the
...Asyncinterface as-is and wraps behaviors that only implement the...Syncinterface in aSyncToAsync...wrapper that calls the sync methods and returnsTask.CompletedTask. - A sync handler does the reverse: behaviors that only implement
...Asyncare wrapped in anAsyncToSync...wrapper that invokes their async methods and blocks on the result.
A behavior that implements both variants is always used as-is. The wrappers expose the original behavior through the IWrappedBehavior interface so framework code can still check for optional interfaces (such as ISaveExceptionBehavior) on the underlying behavior.
Behavior Lifecycle
A behavior's methods are called at specific points in the handler lifecycle. The base classes already wire this up, so implement only the hooks you need.
The hook names below are the async variants used by the async handler bases (e.g. OnBeforeSaveAsync). The synchronous variants (OnBeforeSave, OnValidateRequest, ...) follow the same order and are used by the sync handler bases. Every async hook takes a CancellationToken cancellationToken = default and returns Task, so you can await inside it.
Save (Create / Update)
For a save request, the async handler:
- Loads the old entity (only for updates) — calls
OnPrepareQueryAsyncon each save behavior. - Validates the request — calls
OnValidateRequestAsync. - Sets internal fields — calls
OnSetInternalFieldsAsync. - Runs before the insert/update — calls
OnBeforeSaveAsync. - Executes the SQL statement. On failure, calls
OnExceptionon save behaviors that implementISaveExceptionBehavior. - Runs after the insert/update — calls
OnAfterSaveAsync. - Performs auditing — calls
OnAuditAsync. - Returns the response — calls
OnReturnAsync.
sequenceDiagram
participant H as SaveRequestHandlerAsync
participant B as SaveBehaviorAsync
H->>B: OnPrepareQueryAsync (updates only)
H->>B: OnValidateRequestAsync
H->>B: OnSetInternalFieldsAsync
H->>B: OnBeforeSaveAsync
H->>B: (OnException if SQL fails)
H->>B: OnAfterSaveAsync
H->>B: OnAuditAsync
H->>B: OnReturnAsync
List
For a list request, the async handler:
- Validates the request — calls
OnValidateRequestAsync. - Builds the query — calls
OnPrepareQueryAsync. - Applies filters — calls
OnApplyFiltersAsync. - Runs before the query is executed — calls
OnBeforeExecuteQueryAsync. - Executes the query. On failure, calls
OnExceptionon behaviors implementingIListExceptionBehavior. - Runs after the query is executed — calls
OnAfterExecuteQueryAsync. - Returns the response — calls
OnReturnAsync.
Delete
For a delete request, the async handler:
- Loads the entity to delete — calls
OnPrepareQueryAsync(viaLoadEntityAsync). - Validates the request — calls
OnValidateRequestAsync. - Runs before the delete — calls
OnBeforeDeleteAsync. - Executes the delete. On failure, calls
OnExceptionon behaviors implementingIDeleteExceptionBehavior. - Runs after the delete — calls
OnAfterDeleteAsync. - Audits — calls
OnAuditAsync. - Returns the response — calls
OnReturnAsync.
Retrieve
For a retrieve request, the async handler:
- Validates the request — calls
OnValidateRequestAsync. - Builds the query — calls
OnPrepareQueryAsync. - Runs before the query — calls
OnBeforeExecuteQueryAsync. - Executes the query. On failure, calls
OnException. - Runs after the query — calls
OnAfterExecuteQueryAsync. - Returns the response — calls
OnReturnAsync.
Undelete
For an undelete request, the async handler:
- Loads the entity — calls
OnPrepareQueryAsync. - Validates the request — calls
OnValidateRequestAsync. - Runs before the undelete — calls
OnBeforeUndeleteAsync. - Executes the undelete. On failure, calls
OnException. - Runs after the undelete — calls
OnAfterUndeleteAsync. - Audits — calls
OnAuditAsync. - Returns the response — calls
OnReturnAsync.
Writing a Behavior
The simplest behavior only needs to implement one hook and be activated implicitly.
Example: Humanizing SQL Exceptions
StartSharp ships a HumanizeSqlExceptionBehavior in StartSharp.Common that translates raw SQL errors into friendly validation messages for save and delete:
using Microsoft.Data.SqlClient;
namespace StartSharp.Common;
public class HumanizeSqlExceptionBehavior : BaseSaveDeleteBehaviorAsync, IImplicitBehavior
{
public bool ActivateFor(IRow row)
{
return true;
}
public override void OnException(ISaveRequestHandler handler, Exception exception)
{
if (exception is SqlException)
SqlExceptionHelper.HandleSavePrimaryKeyException(exception, handler.Context?.Localizer,
handler.Row?.IdField?.GetTitle(handler.Context?.Localizer));
}
public override void OnException(IDeleteRequestHandler handler, Exception exception)
{
if (exception is SqlException)
SqlExceptionHelper.HandleDeleteForeignKeyException(exception, handler.Context?.Localizer);
}
}
Because it derives from BaseSaveDeleteBehaviorAsync, it can override the (synchronous) OnException method for both save and delete — exception hooks are sync in both the async and sync behavior bases, since they only translate an exception. Since it implements IImplicitBehavior and ActivateFor always returns true, it applies to every row type, which is exactly what you want for a global exception handler.
Example: Multi-Tenant Behavior
A common cross-cutting behavior is row-level multi-tenancy. The Multi-Tenancy tutorial builds one that intercepts Retrieve, List, Save, and Delete for any row implementing an IMultiTenantRow interface:
public interface IMultiTenantRow
{
Int32Field TenantIdField { get; }
}
public class MultiTenantBehavior : IImplicitBehavior,
IRetrieveBehaviorAsync, IListBehaviorAsync,
ISaveBehaviorAsync, IDeleteBehaviorAsync
{
private Int32Field tenantIdField;
public bool ActivateFor(IRow row)
{
if (row is not IMultiTenantRow mtRow)
return false;
tenantIdField = mtRow.TenantIdField;
return true;
}
public Task OnPrepareQueryAsync(IRetrieveRequestHandler handler,
SqlQuery query, CancellationToken cancellationToken = default)
{
if (!handler.Context.Permissions.HasPermission(PermissionKeys.Tenants))
query.Where(tenantIdField == handler.Context.User.GetTenantId());
return Task.CompletedTask;
}
public Task OnPrepareQueryAsync(IListRequestHandler handler,
SqlQuery query, CancellationToken cancellationToken = default)
{
if (!handler.Context.Permissions.HasPermission(PermissionKeys.Tenants))
query.Where(tenantIdField == handler.Context.User.GetTenantId());
return Task.CompletedTask;
}
public Task OnSetInternalFieldsAsync(ISaveRequestHandler handler,
CancellationToken cancellationToken = default)
{
if (handler.IsCreate)
tenantIdField[handler.Row] = handler.Context.User.GetTenantId();
return Task.CompletedTask;
}
public Task OnValidateRequestAsync(ISaveRequestHandler handler,
CancellationToken cancellationToken = default)
{
if (handler.IsUpdate && tenantIdField[handler.Old] != tenantIdField[handler.Row])
handler.Context.Permissions.ValidatePermission(PermissionKeys.Tenants, handler.Context.Localizer);
return Task.CompletedTask;
}
public Task OnValidateRequestAsync(IDeleteRequestHandler handler,
CancellationToken cancellationToken = default)
{
if (tenantIdField[handler.Row] != handler.Context.User.GetTenantId())
handler.Context.Permissions.ValidatePermission(PermissionKeys.Tenants, handler.Context.Localizer);
return Task.CompletedTask;
}
// the remaining hooks are optional — the async behavior interfaces
// provide default no-op implementations for every member
}
The async behavior interfaces provide default implementations for their methods, so you only override the hooks you need — no more empty method stubs for every interface member. (The sync
...Syncinterfaces work the same way.) If a hook does no I/O, returningTask.CompletedTaskis fine; if it does, mark the methodasyncandawaitit.
This lets the behavior replace the manual RoleRepository plumbing from the earlier part of the tutorial, and apply the same plan automatically to every row type that implements IMultiTenantRow.
Important Notes
- Behaviors are cached and reused across requests. A single behavior instance is shared for all requests targeting the same row and handler type. Do not store per-request state in private fields — use the handler's
StateBag(handler.StateBag) instead. All methods must be thread-safe. ActivateForruns once per handler type and row type. It is a good place to read row/field metadata and cache it in private fields.- Constructor injection works. Behaviors are created through the DI container, so you can inject services like
ITextLocalizer,ISqlConnections,IDefaultHandlerFactory, orIServiceResolver<T>. - A behavior can implement multiple interfaces. For example,
BaseSaveDeleteBehaviorAsynccovers both save and delete, andCaptureLogBehavioradditionally implementsIUndeleteBehaviorAsync. - Implement one mode or both. Implementing just the async interfaces is enough for new behaviors — sync handlers wrap async behaviors automatically, and async handlers wrap sync behaviors. Implementing both variants lets a behavior run natively in both modes without wrapping.
- Behaviors run for every handler of the matching type. They are found through the type source, so you can add cross-cutting logic once instead of overriding methods in each handler.