Skip to content

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 synchronous SaveRequestHandler is 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 IUnitOfWork argument 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 of SaveResponse. ASP.NET Core awaits the returned task; ServiceEndpoint still wraps the eventual result as JSON. The injected CancellationToken is 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.

Optimistic Concurrency

Serenity supports optimistic concurrency via a version / rowversion field, modeled after the tracked concurrency tokens in frameworks like EF Core and NHibernate. A row implements IConcurrencyVersionRow and exposes its version field:

public sealed class ProductRow : Row<ProductRow.RowFields>, IIdRow, IConcurrencyVersionRow
{
    // returned to the client, but not editable (no form field)
    [DisplayText("Row Version"), Insertable(false), Updatable(false)]
    public int? RowVersion { get => fields.RowVersion[this]; set => fields.RowVersion[this] = value; }

    Field IConcurrencyVersionRow.ConcurrencyVersionField => fields.RowVersion;
}

On save, the handler appends the version to the update's WHERE clause and raises a localized ConcurrencyConflict validation error when no row is affected. This detects both a stale read (the value sent by the client is used when present) and a concurrent write between the load and the update. When the client does not send a version, the value loaded from the database (Old) is used, so a concurrent write in the load → update window is still detected.

Databases that maintain the version themselves (e.g. SQL Server rowversion) need no extra work; for application managed numeric versions, the AddConcurrencyVersionTrigger / DropConcurrencyVersionTrigger migration helpers generate the trigger SQL for SQL Server, PostgreSQL, MySQL, Oracle, Firebird and SQLite:

// migration
Alter.Table("Products")
    .AddColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(0)
    .AddConcurrencyVersionTrigger(this, idField: "ProductID");

An IgnoreConcurrencyVersion flag on SaveRequest lets internal services (localization, master/detail and updatable extension behaviors) update rows regardless of their version. It is a server side only flag — client JSON cannot set it.

Client-side, the server typings emit a concurrencyVersionProperty on the generated row class, and EntityDialog sends the loaded value back on save even though the read-only version field has no form field. See EntityDialog and CRUD Workflows. The Northwind demo's ProductRow uses this feature as a reference.

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:

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, IDefaultHandlerFactory wraps 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 like InsertUserId, 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.
  • IDefaultHandlerFactory returns 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.

See Also