Save Request Handler
The SaveRequestHandlerAsync is the base class that handles Create (insert) and Update service requests. Sergen generates a small subclass for every entity; the base class contains all the default logic, so an empty subclass already produces a working save service.
From Serenity 10.5.0, Sergen generates asynchronous handlers (
SaveRequestHandlerAsync) and endpoint actions by default. The synchronousSaveRequestHandleris kept for backward compatibility and is marked obsolete — it will be deprecated in a future version. New code — and code generated by Sergen — should use the async variants.
Generated Handler
For a Language entity, Sergen generates:
using MyRow = MyProject.Administration.LanguageRow;
namespace MyProject.Administration;
public interface ILanguageSaveHandler : ISaveHandlerAsync<MyRow> { }
public class LanguageSaveHandler(IRequestContext context)
: SaveRequestHandlerAsync<MyRow>(context), ILanguageSaveHandler
{
}
The interface (ISaveHandlerAsync<MyRow>) and the class derive from the generic SaveRequestHandlerAsync<TRow> and ISaveRequestHandler. Request handlers are auto-registered via the type source, so no explicit registration is needed.
In StartSharp, the handler interface does not have to be written by hand — it can be generated from the handler class with the
[GenerateInterface]attribute. This works for all request handler types. See Generating Handler Interfaces. For how handlers are discovered and registered automatically, see Auto-Registration of Request Handlers.
The Service Endpoint
The endpoint exposes two actions that delegate to the handler. Because the handler is asynchronous, the actions are also asynchronous and take a CancellationToken:
[Route("Services/Administration/Language/[action]")]
[ConnectionKey(typeof(MyRow)), ServiceAuthorize(typeof(MyRow))]
public class LanguageEndpoint : ServiceEndpoint
{
[HttpPost, AuthorizeCreate(typeof(MyRow))]
public Task<SaveResponse> Create(IUnitOfWork uow, SaveRequest<MyRow> request,
[FromServices] ILanguageSaveHandler handler, CancellationToken cancellationToken = default)
{
return handler.CreateAsync(uow, request, cancellationToken);
}
[HttpPost, AuthorizeUpdate(typeof(MyRow))]
public Task<SaveResponse> Update(IUnitOfWork uow, SaveRequest<MyRow> request,
[FromServices] ILanguageSaveHandler handler, CancellationToken cancellationToken = default)
{
return handler.UpdateAsync(uow, request, cancellationToken);
}
}
[AuthorizeCreate(typeof(MyRow))]/[AuthorizeUpdate(typeof(MyRow))]validate the create/update permission declared on the row.- The
IUnitOfWorkargument provides a transaction, so the save either commits or rolls back as a whole. [FromServices]injects the (auto-registered) handler.- The endpoint returns
Task<SaveResponse>instead ofSaveResponse. ASP.NET Core awaits the returned task;ServiceEndpointstill wraps the eventual result as JSON. The injectedCancellationTokenis forwarded to the handler so the operation can be cancelled when the client disconnects.
The Request and Response
The request is a SaveRequest<TEntity>:
public class SaveRequest<TEntity> : ServiceRequest, ISaveRequest
{
public object? EntityId { get; set; }
public TEntity? Entity { get; set; }
public Dictionary<string, TEntity>? Localizations { get; set; }
}
EntityId— set for Update (which record to update); ignored for Create.Entity— the fields to insert/update. Because rows track assignments, only the fields actually sent from the client are included in the SQL statement.Localizations— optional per-language entity values when localization is used.
The response is a SaveResponse:
public class SaveResponse : ServiceResponse
{
public object? EntityId { get; set; }
}
EntityId contains the ID of the created or updated record.
Create vs Update
Inside the handler, IsCreate is true for Create requests and IsUpdate for Update requests:
if (IsCreate)
// new record
else
// existing record
Row holds the entity being saved. For updates, Old holds the previously stored entity.
Handler Type Hierarchy
The generated handler derives from SaveRequestHandlerAsync<TRow>, which itself derives from the fully generic SaveRequestHandlerAsync<TRow, TSaveRequest, TSaveResponse>:
public class SaveRequestHandlerAsync<TRow>(IRequestContext context)
: SaveRequestHandlerAsync<TRow, SaveRequest<TRow>, SaveResponse>(context), ISaveHandlerAsync<TRow>
where TRow : class, IRow, IIdRow, new()
{
}
The three generic arguments let you customize the request and response types. If you need a save handler that works with a custom request/response (e.g. one with extra fields), derive from the three-argument version directly:
public class MySaveHandler(IRequestContext context)
: SaveRequestHandlerAsync<MyRow, MySaveRequest, MySaveResponse>(context), IMySaveHandler
{
}
Create and Update interfaces
The handler interface generated by Sergen (ILanguageSaveHandler) derives from ISaveHandlerAsync<MyRow>, which combines the create and update contracts. The underlying generic interfaces are:
ICreateHandlerAsync<TRow, TSaveRequest, TSaveResponse>— declaresTask<TSaveResponse> CreateAsync(IUnitOfWork uow, TSaveRequest request, CancellationToken cancellationToken = default).IUpdateHandlerAsync<TRow, TSaveRequest, TSaveResponse>— declaresTask<TSaveResponse> UpdateAsync(IUnitOfWork uow, TSaveRequest request, CancellationToken cancellationToken = default).
The synchronous equivalents are ICreateHandler and IUpdateHandler.
ISaveRequestProcessor and SaveRequestType
ISaveRequestProcessor is the abstraction used when a handler needs to process a save without knowing up front whether it's a create or an update. Its Process method takes a SaveRequestType:
public enum SaveRequestType
{
Create,
Update,
Auto // determine from request.EntityId
}
SaveRequestHandlerAsync<TRow> implements the async variant ISaveRequestProcessorAsync, so ProcessAsync(uow, request, SaveRequestType.Auto, cancellationToken) is how the create/update decision is made internally.
Create/Update proxies
CreateHandlerProxyAsync and UpdateHandlerProxyAsync are internal classes registered by AddProxyRequestHandlers(). They let the DI container resolve ICreateHandlerAsync<TRow> / IUpdateHandlerAsync<TRow> on demand: each proxy resolves the concrete handler through IDefaultHandlerFactory and forwards the call. You normally don't use them directly — they're what makes the generic handler interfaces resolvable (see Auto-Registration of Request Handlers).
The framework auto-registers both the async proxies (
ICreateHandlerAsync<TRow>,IUpdateHandlerAsync<TRow>, ...) and their sync counterparts. If only one mode of a custom handler exists,IDefaultHandlerFactorywraps it to the requested mode automatically, so sync and async code can coexist.
Lifecycle Methods
The main overridable methods on SaveRequestHandlerAsync are the async variants. Override them and call the base implementation first:
ValidateRequestAsync()— validate the request (permissions, required fields, etc.).SetInternalFieldsAsync()— set fields likeInsertUserId,InsertDate,UpdateUserId,UpdateDate.BeforeSaveAsync()— called right before the INSERT/UPDATE is executed.AfterSaveAsync()— called right after the INSERT/UPDATE succeeds.OnReturnAsync()— called just before the response is returned.
Every method accepts a CancellationToken cancellationToken = default and returns Task, so you can await async operations (e.g. an async connection call) inside them.
The BeforeSaveAsync/AfterSaveAsync methods also invoke any registered SaveBehavior.OnBeforeSaveAsync / OnAfterSaveAsync, so a behavior runs in the same phase.
Example: Calculating a Value Before Save
A common use is computing a value in BeforeSaveAsync. For example, the Student Information System tutorial computes a grade average before the record is saved:
public class GradesSaveHandler : SaveRequestHandlerAsync<MyRow>, IGradesSaveHandler
{
public GradesSaveHandler(IRequestContext context) : base(context)
{
}
protected override async Task BeforeSaveAsync(CancellationToken cancellationToken = default)
{
await base.BeforeSaveAsync(cancellationToken);
Row.CalculateAverage();
}
}
Using Synchronous Handlers
The sync base classes (SaveRequestHandler<TRow>, ISaveHandler<TRow>, ICreateHandler<TRow>, etc.) are kept for backward compatibility and are marked obsolete. When a sync handler is requested from async code (or vice versa), the framework bridges the two automatically:
- An async request handler automatically wraps synchronous behaviors so they run inside the async lifecycle.
- A synchronous request handler automatically wraps asynchronous behaviors by calling their async methods synchronously.
IDefaultHandlerFactoryreturns a mode-appropriate wrapper when a custom handler only exists for the other mode.
Behaviors
Behaviors implementing ISaveBehaviorAsync (or the sync ISaveBehaviorSync) run for every save handler. They are discovered through the type source and are the recommended way to add cross-cutting save logic (audit logging, multi-tenancy, master–detail saving, etc.) without touching individual handlers. See Service Behaviors.