These release notes detail the significant changes made in Serenity and StartSharp from version 10.4.0 to 10.5.0. For a complete list of changes, please refer to the Serenity Change Log.
Async Request Handlers: The Big Theme
Prior to this release, all request handlers (SaveRequestHandler, RetrieveRequestHandler, ListRequestHandler, DeleteRequestHandler, UndeleteRequestHandler) executed their lifecycle synchronously:
// 10.4.0 and earlier -- the whole handler pipeline ran on the calling thread
public class MyListHandler(IRequestContext context) :
ListRequestHandler<MyRow>(context), IMyListHandler
{
}
public interface IMyListHandler : IListHandler<MyRow> { }
The MVC endpoint action called the sync List/Retrieve/Create/Update/Delete method directly, and any database I/O inside the handler blocked a thread pool thread for the duration of the request.
In 10.5.0 every built-in request handler now has an async twin. The sync classes remain available for backward compatibility, but new code (and the sergen templates, Serene, StartSharp samples and all feature modules) is generated/converted to use the async variants:
// 10.5.0 -- async handler, non-blocking I/O throughout the pipeline
public class MyListHandler(IRequestContext context) :
ListRequestHandlerAsync<MyRow>(context), IMyListHandler
{
}
public interface IMyListHandler : IListHandlerAsync<MyRow> { }
Each handler type gained a parallel set of interfaces and base classes:
| Operation | Sync (still available) | Async (new) |
|---|---|---|
| Save (Create/Update) | SaveRequestHandler, ISaveHandler, ICreateHandler, IUpdateHandler |
SaveRequestHandlerAsync, ISaveHandlerAsync, ICreateHandlerAsync, IUpdateHandlerAsync |
| Retrieve | RetrieveRequestHandler, IRetrieveHandler |
RetrieveRequestHandlerAsync, IRetrieveHandlerAsync |
| List | ListRequestHandler, IListHandler |
ListRequestHandlerAsync, IListHandlerAsync |
| Delete | DeleteRequestHandler, IDeleteHandler |
DeleteRequestHandlerAsync, IDeleteHandlerAsync |
| Undelete | UndeleteRequestHandler, IUndeleteHandler |
UndeleteRequestHandlerAsync, IUndeleteHandlerAsync |
The async methods mirror their sync counterparts and accept a trailing CancellationToken:
// Sync (10.4.0)
SaveResponse Create(IUnitOfWork uow, SaveRequest<MyRow> request);
ListResponse<MyRow> List(IDbConnection connection, ListRequest request);
DeleteResponse Delete(IUnitOfWork uow, DeleteRequest request);
RetrieveResponse<MyRow> Retrieve(IDbConnection connection, RetrieveRequest request);
UndeleteResponse Undelete(IUnitOfWork uow, UndeleteRequest request);
// Async (10.5.0)
Task<SaveResponse> CreateAsync(IUnitOfWork uow, SaveRequest<MyRow> request, CancellationToken ct = default);
Task<ListResponse<MyRow>> ListAsync(IDbConnection connection, ListRequest request, CancellationToken ct = default);
Task<DeleteResponse> DeleteAsync(IUnitOfWork uow, DeleteRequest request, CancellationToken ct = default);
Task<RetrieveResponse<MyRow>> RetrieveAsync(IDbConnection connection, RetrieveRequest request, CancellationToken ct = default);
Task<UndeleteResponse> UndeleteAsync(IUnitOfWork uow, UndeleteRequest request, CancellationToken ct = default);
Internally, the shared state and mode-neutral logic (validation, field selection, editable-field handling, display order helpers, etc.) was extracted into SaveRequestHandlerBase, ListRequestHandlerBase, RetrieveRequestHandlerBase, DeleteRequestHandlerBase and UndeleteRequestHandlerBase, and each sync/async pair derives from its base.
Async Endpoint Actions
Endpoint actions (hand-written or generated by sergen) are now async-ready: they return Task<T> and pass the CancellationToken down to the handler. MVC's CancellationToken model binding automatically links it to the HttpContext.RequestAborted token, so a client disconnect cancels the database operation.
// 10.4.0
public class CategoryEndpoint : ServiceEndpoint
{
[HttpPost, AuthorizeCreate(typeof(MyRow))]
public SaveResponse Create(IUnitOfWork uow, SaveRequest<MyRow> request,
[FromServices] ICategorySaveHandler handler)
{
return handler.Create(uow, request);
}
public ListResponse<MyRow> List(IDbConnection connection, ListRequest request,
[FromServices] ICategoryListHandler handler)
{
return handler.List(connection, request);
}
}
// 10.5.0
public class CategoryEndpoint : ServiceEndpoint
{
[HttpPost, AuthorizeCreate(typeof(MyRow))]
public Task<SaveResponse> Create(IUnitOfWork uow, SaveRequest<MyRow> request,
[FromServices] ICategorySaveHandler handler, CancellationToken cancellationToken = default)
{
return handler.CreateAsync(uow, request, cancellationToken);
}
public Task<ListResponse<MyRow>> List(IDbConnection connection, ListRequest request,
[FromServices] ICategoryListHandler handler, CancellationToken cancellationToken = default)
{
return handler.ListAsync(connection, request, cancellationToken);
}
[HttpPost, AuthorizeList(typeof(MyRow))]
public async Task<FileContentResult> ListExcel(IDbConnection connection, ListRequest request,
[FromServices] ICategoryListHandler handler,
[FromServices] IExcelExporter exporter, CancellationToken cancellationToken = default)
{
var data = (await List(connection, request, handler, cancellationToken)).Entities;
var bytes = exporter.Export(data, typeof(Columns.CategoryColumns), request.ExportColumns);
return ExcelContentResult.Create(bytes, "CategoryList_" +
DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture) + ".xlsx");
}
}
Converting a Custom Handler
A typical hand-written handler that overrides lifecycle methods just changes its base class and adds Async to each overridden method signature, awaits the base call, and forwards the CancellationToken:
// 10.4.0 -- synchronous override
public class MyOrderSaveHandler(IRequestContext context) :
SaveRequestHandler<MyRow>(context), IMyOrderSaveHandler
{
protected override void ValidateRequest()
{
base.ValidateRequest();
if (Row.CustomerId != null && !Connection.Exists<CustomerRow>(
new Criteria(CustomerRow.Fields.CustomerId) == Row.CustomerId.Value))
throw DataValidation.RequiredError("CustomerId", Localizer);
}
protected override void OnReturn()
{
base.OnReturn();
Cache.InvalidateOnCommit(UnitOfWork, MyRow.Fields);
}
}
// 10.5.0 -- asynchronous override
public class MyOrderSaveHandler(IRequestContext context) :
SaveRequestHandlerAsync<MyRow>(context), IMyOrderSaveHandler
{
protected override async Task ValidateRequestAsync(CancellationToken cancellationToken = default)
{
await base.ValidateRequestAsync(cancellationToken);
if (Row.CustomerId != null && !await Connection.ExistsAsync<CustomerRow>(
new Criteria(CustomerRow.Fields.CustomerId) == Row.CustomerId.Value,
cancellationToken).ConfigureAwait(false))
throw DataValidation.RequiredError("CustomerId", Localizer);
}
protected override async Task OnReturnAsync(CancellationToken cancellationToken = default)
{
await base.OnReturnAsync(cancellationToken);
Cache.InvalidateOnCommit(UnitOfWork, MyRow.Fields);
}
}
Your existing synchronous handlers keep working unchanged if you leave them as they are — the framework auto-detects a sync custom handler and runs it (through a compatibility wrapper) even when an async handler interface is requested, and vice versa. See Sync/Async Compatibility below.
Behavior Interfaces Split into Sync and Async (Breaking Change)
This is the most important breaking change in 10.5.0.
In 10.4.0 a behavior implemented the single lifecycle interface directly, e.g.:
public interface ISaveBehavior
{
void OnPrepareQuery(ISaveRequestHandler handler, SqlQuery query);
void OnValidateRequest(ISaveRequestHandler handler);
void OnBeforeSave(ISaveRequestHandler handler);
void OnAfterSave(ISaveRequestHandler handler);
// ...etc
}
In 10.5.0 the original ISaveBehavior / IRetrieveBehavior / IListBehavior / IDeleteBehavior / IUndeleteBehavior interfaces no longer declare the lifecycle methods — they are now empty marker interfaces so a behavior can be recognized by the behavior provider regardless of which mode it implements. The actual hooks moved to a pair of new interfaces per operation:
ISaveBehaviorSync/ISaveBehaviorAsyncIRetrieveBehaviorSync/IRetrieveBehaviorAsyncIListBehaviorSync/IListBehaviorAsyncIDeleteBehaviorSync/IDeleteBehaviorAsyncIUndeleteBehaviorSync/IUndeleteBehaviorAsync
The sync interfaces keep the old method signatures (void OnBeforeSave(ISaveRequestHandler handler)), while the async interfaces expose Task-returning methods with a CancellationToken:
public interface ISaveBehaviorAsync : ISaveBehavior
{
Task OnPrepareQueryAsync(ISaveRequestHandler handler, SqlQuery query,
CancellationToken cancellationToken = default) => Task.CompletedTask;
Task OnValidateRequestAsync(ISaveRequestHandler handler,
CancellationToken cancellationToken = default) => Task.CompletedTask;
Task OnBeforeSaveAsync(ISaveRequestHandler handler,
CancellationToken cancellationToken = default) => Task.CompletedTask;
Task OnAfterSaveAsync(ISaveRequestHandler handler,
CancellationToken cancellationToken = default) => Task.CompletedTask;
// ...
}
Note that the async interface methods have default implementations returning Task.CompletedTask, so a behavior only needs to override the hooks it cares about. The base classes provide the same convenience with virtual methods:
BaseSaveBehavior/BaseSaveBehaviorAsync,BaseSaveDeleteBehavior/BaseSaveDeleteBehaviorAsyncBaseRetrieveBehavior/BaseRetrieveBehaviorAsyncBaseListBehavior/BaseListBehaviorAsyncBaseDeleteBehavior/BaseDeleteBehaviorAsyncBaseUndeleteBehavior/BaseUndeleteBehaviorAsync
Converting a Custom Behavior
Because the base classes (BaseSaveBehavior, etc.) still exist and now implement the ...Sync interface, a behavior that did nothing async can migrate simply by switching which base it derives from:
// 10.4.0
public class MyAuditBehavior : BaseSaveBehavior
{
public override void OnAfterSave(ISaveRequestHandler handler)
{
// sync work
}
}
// 10.5.0 -- async version (only override the hooks you need)
public class MyAuditBehavior : BaseSaveBehaviorAsync
{
public override async Task OnAfterSaveAsync(ISaveRequestHandler handler,
CancellationToken cancellationToken = default)
{
// async work, e.g. await handler.UnitOfWork.Connection.InsertAsync(...)
}
}
Behaviors that need to run in both sync and async handlers can implement both interfaces. The built-in integrated behaviors were updated to do exactly this, so e.g. CaptureLogBehavior works whether the surrounding handler is sync or async:
public class CaptureLogBehavior : BaseSaveDeleteBehaviorAsync,
ISaveBehaviorSync, IDeleteBehaviorSync,
IUndeleteBehaviorAsync, IUndeleteBehaviorSync, IImplicitBehavior
{
// sync hook for sync handlers:
public virtual void OnAudit(IDeleteRequestHandler handler) { ... }
// async hook for async handlers:
public override Task OnAuditAsync(IDeleteRequestHandler handler,
CancellationToken cancellationToken = default) { ... }
}
The following integrated behaviors were converted to implement both modes so they are fully compatible with async handlers: CaptureLog, InsertUpdateLog, LinkingSetRelation, Localization, MasterDetailRelation, UniqueConstraintSave, UniqueFieldSave, UpdatableExtension and ValidateParent.
Exception Behaviors
The exception-preview hooks (OnException) live in separate extension interfaces — ISaveExceptionBehavior, IDeleteExceptionBehavior, IRetrieveExceptionBehavior, IListExceptionBehavior and IUndeleteExceptionBehavior. The async Base...BehaviorAsync classes now implement these interfaces as well (they were previously only on the sync base classes), so async behaviors can hook OnException. The async handlers also invoke the exception behavior through a wrapped-behavior check, so an OnException on the inner behavior is called even when the behavior was wrapped to change its sync/async mode.
Sync/Async Compatibility
The framework is designed so you are not forced to convert everything at once. Both modes can coexist:
- Sync handlers + async behaviors and async handlers + sync behaviors work through internal
SyncToAsync.../AsyncToSync...wrappers that the request handlers build automatically from the registered behaviors (AutoWrapBehaviors). - Custom handlers registered for a row are resolved by
DefaultHandlerFactoryeven when only the "other mode" interface is requested: a sync custom save handler is found when an async save handler is requested (and wrapped), and an async custom handler is found when a sync handler is requested. - The DI proxy registrations (
CreateHandlerProxyAsync,ListHandlerProxyAsync, etc.) were added alongside the existing sync proxies, so resolvingIListHandlerAsync<MyRow>from an endpoint gives you the correct handler for the row. - Because a behavior now routes through the inner wrapped behavior for additional interface checks, behavior features such as
OnException, field-expression mapping (IListMapFieldExpressionBehavior), etc. keep working when the behavior was auto-wrapped between modes.
This means an incremental migration is possible: convert handlers/endpoints first, leave behaviors sync (they will be auto-wrapped), then convert behaviors at your own pace.
Async Data Access Layer
The handler work is backed by a full async data-access layer. Every commonly used data-access method now has an Async twin, most with a CancellationToken parameter.
SqlHelper
// 10.4.0 (sync)
SqlHelper.ExecuteNonQuery(connection, sql);
insert.ExecuteAndGetID(connection);
update.Execute(connection);
new SqlInsert(...).ExecuteUpsert(connection, keyFields);
SqlHelper.ExecuteReader(connection, query);
SqlHelper.ExecuteScalar(connection, sql);
query.Exists(connection);
// 10.5.0 (async)
await SqlHelper.ExecuteNonQueryAsync(connection, sql, cancellationToken: ct);
await insert.ExecuteAndGetIDAsync(connection, cancellationToken: ct);
await update.ExecuteAsync(connection, cancellationToken: ct);
await new SqlInsert(...).ExecuteUpsertAsync(connection, keyFields, cancellationToken: ct);
await SqlHelper.ExecuteReaderAsync(connection, query, cancellationToken: ct);
await SqlHelper.ExecuteScalarAsync(connection, sql, cancellationToken: ct);
await query.ExistsAsync(connection, cancellationToken: ct);
The full set of new methods: ExecuteNonQueryAsync, ExecuteAndGetIDAsync, ExecuteAsync (for SqlInsert/SqlUpdate/SqlDelete), ExecuteUpsertAsync, ExecuteReaderAsync, ExecuteScalarAsync, ExistsAsync.
EntityConnectionExtensions
Every CRUD/query extension now has an async variant:
// Sync (10.4.0) // Async (10.5.0)
connection.ById<MyRow>(id) await connection.ByIdAsync<MyRow>(id, ct)
connection.TryById<MyRow>(id) await connection.TryByIdAsync<MyRow>(id, ct)
connection.First<MyRow>(where) await connection.FirstAsync<MyRow>(where, ct)
connection.TryFirst<MyRow>(where) await connection.TryFirstAsync<MyRow>(where, ct)
connection.Single<MyRow>(where) await connection.SingleAsync<MyRow>(where, ct)
connection.TrySingle<MyRow>(where) await connection.TrySingleAsync<MyRow>(where, ct)
connection.List<MyRow>(where) await connection.ListAsync<MyRow>(where, ct)
connection.Count<MyRow>(where) await connection.CountAsync<MyRow>(where, ct)
connection.Exists<MyRow>(where) await connection.ExistsAsync<MyRow>(where, ct)
connection.ExistsById<MyRow>(id) await connection.ExistsByIdAsync<MyRow>(id, ct)
connection.Insert(row) await connection.InsertAsync(row, ct)
connection.InsertAndGetID(row) await connection.InsertAndGetIDAsync(row, ct)
connection.UpdateById(row) await connection.UpdateByIdAsync(row, ct)
connection.DeleteById(id) await connection.DeleteByIdAsync(id, ct)
EntitySqlHelper and Dapper
SqlQuery extensions GetFirstAsync, GetSingleAsync, ListAsync and ForEachAsync were added, and DapperCore now provides async ExecuteAsync and QueryAsync overloads (for both raw SQL strings and ISqlQuery) that honor Serenity's SQL translation and open the connection asynchronously.
Connection Support
To support these methods without thread-pool blocking:
WrappedConnectionnow extendsDbConnection(instead of merely implementingIDbConnection), so it can overrideOpenAsync/CloseAsyncand be used by async ADO.NET APIs.ConnectionExtensions.EnsureOpenAsyncopens the connection asynchronously (throughWrappedConnection.OpenAsyncor the underlyingDbConnection.OpenAsync) and is used by the async helpers.DataReaderExtensions.ReadAsync/NextResultAsynccall the nativeDbDataReader.ReadAsync/NextResultAsyncwhen available and fall back to the synchronous methods otherwise. Likewise, the internal async command helpers inSqlHelper/EntitySqlHelperuse the underlyingDbCommand/DbDataReaderasync methods and fall back to the sync call on plainIDbCommandimplementations — no moreTask.Runto "fake" async on a connection that only supports sync.
Interceptors Get Async Variants
ISqlOperationInterceptor and IRowOperationInterceptor (used mainly by mock connections in tests) now declare async methods — ExecuteNonQueryAsync, ExecuteReaderAsync, ExecuteScalarAsync and FindRowAsync, ListRowsAsync, ManipulateRowAsync — whose default implementations delegate to the existing sync methods. Existing custom interceptors therefore keep compiling and working without changes, while test mocks can opt into real async behavior.
Mock classes in Serenity.Net.Tests were extended accordingly (MockDbConnection gained ExecuteScalar/async interception support, plus MockSaveHandlerAsync, MockDeleteHandlerAsync, MockListHandlerAsync etc.), and the tests for the integrated behaviors were expanded to cover async handling.
Sample & Feature Module Conversions
All request handlers in the sample applications and feature modules now use the async handler base classes and async endpoints:
- Serene (
serene/in the Serenity repository): Administration Language/Role/User handlers. - StartSharp.Web: Administration Language/Role/User handlers,
UserHelper. - Common features (Northwind): Category, Customer, Order, OrderDetail, Product, Region, Territory, Shipper, Supplier and the CategoryLang/ProductLang localizations, plus the note handlers.
- Pro features: meeting module (BusinessUnit, Contact, Meeting, MeetingAgenda(+Relevant/Type), MeetingAttendee, MeetingDecision(+Relevant), MeetingLocation, MeetingType), demo.advancedsamples (DragDropInTreeGrid, ProductExcelImport), pro.extensions (DataAuditLog, EmailQueue), worklog (Currency, Customer, Employee, EmployeePricing, Invoice, Project, WorkLog and its custom action handlers).
- Tests for all converted modules were updated to call the async handler methods.
A nice example of a custom (non-CRUD) handler that was converted is WorkLogStartTaskHandler, which loads a row, computes a value, and delegates to the save handler — all asynchronously now:
[GenerateInterface]
public class WorkLogStartTaskHandler(IWorkLogSaveHandler handler) : IWorkLogStartTaskHandler
{
public async Task<SaveResponse> StartTaskAsync(IUnitOfWork uow,
WorkLogStartTaskRequest request, CancellationToken cancellationToken = default)
{
var fld = WorkLogRow.Fields;
var row = await uow.Connection.ByIdAsync<WorkLogRow>(request.EntityId, q =>
q.Select(fld.ProjectId)
.Select(fld.Tasks)
.Select(fld.Description), cancellationToken).ConfigureAwait(false);
var startDate = WorkLogRequestHandlerHelpers.RoundDate(DateTime.UtcNow, floor: true);
return await handler.CreateAsync(uow, new()
{
Entity = new()
{
StartDate = startDate,
EndDate = startDate,
TimeSpent = 0,
ProjectId = row.ProjectId,
Tasks = row.Tasks,
Description = row.Description,
}
}, cancellationToken).ConfigureAwait(false);
}
}
Another good reference is the NotesBehavior in the Northwind demo, which now derives from BaseSaveDeleteBehaviorAsync, implements IRetrieveBehaviorAsync, and calls ListAsync / CreateAsync / UpdateAsync / DeleteAsync on the note handlers from inside its OnReturnAsync, OnAfterSaveAsync and OnBeforeDeleteAsync hooks.
Sergen Now Generates Async Code
The sergen scriban templates (SaveHandler.scriban, ListHandler.scriban, RetrieveHandler.scriban, DeleteHandler.scriban, Endpoint.scriban) were updated so newly generated code is async by default:
// Generated handler (10.5.0):
public interface ICategorySaveHandler : ISaveHandlerAsync<MyRow> { }
public class CategorySaveHandler(IRequestContext context) :
SaveRequestHandlerAsync<MyRow>(context), ICategorySaveHandler
{
}
// Generated endpoint action (10.5.0):
public Task<SaveResponse> Create(IUnitOfWork uow, SaveRequest<MyRow> request,
[FromServices] ICategorySaveHandler handler, CancellationToken cancellationToken = default)
{
return handler.CreateAsync(uow, request, cancellationToken);
}
The source generator used by StartSharp's pro.coder (InterfaceSourceGenerator) also learned to recognize the ...Async handler base classes, so it generates the correct async handler interface when it sees SaveRequestHandlerAsync etc.
Bugfixes
- Fix
SqlHelper.ExecuteScalarto call the interceptor'sExecuteScalar(notExecuteReader), and makeMockDbConnection.interceptExecuteScalartake anobjectargument to match. - Fix
getNewIdbeing passed astruefor interceptor methods that do not generate a new ID (e.g. plainExecuteNonQuery/Executecalls onSqlInsert/SqlUpdate). - Make
SqlHelper.LogCommandpublic again for compatibility (it was temporarily made private during the async refactor) and add a null check for the command argument. - Remove unused usings and add
System.Threading/System.Threading.Taskswhere needed.
Upgrading to 10.5.0
Update NuGet packages to 10.5.0.
Behaviors — the main breaking change. If you implement
ISaveBehavior,IRetrieveBehavior,IListBehavior,IDeleteBehaviororIUndeleteBehaviordirectly (not via aBase...Behaviorclass), your type will no longer compile because those interfaces are now marker interfaces:- Change it to implement the matching
...Syncinterface (keep your existing method signatures), or - Change it to implement the
...Asyncinterface and make the overridden hooksasync Taskmethods, or - Derive from
Base...Behavior(sync) /Base...BehaviorAsync(async). - If the behavior must work with both sync and async handlers, you may implement both interfaces but not required due to auto-wrapping (the async side can call into the sync side).
- Change it to implement the matching
Handlers are optional to convert. Custom handlers that keep deriving from
SaveRequestHandleretc. continue to work, even in apps whose generated code is async, thanks to the auto-wrap compatibility layer. To opt into async, switch the base class toSaveRequestHandlerAsyncand change overriddenvoidlifecycle methods toasync Taskmethods with aCancellationToken.Custom endpoints. If you keep a hand-written endpoint calling
handler.Create(uow, request)and the injected handler interface is nowISaveHandlerAsync, callawait handler.CreateAsync(uow, request, ct)and make the action returnTask<SaveResponse>. If you keep the sync handler interface (ISaveHandler<MyRow>), the sync call continues to work — both interface families remain registered.Data access. Prefer the new
...Asyncconnection/query methods in async code paths (e.g. inside async behaviors and async handler overrides) so the whole pipeline is truly non-blocking. If you implementISqlOperationInterceptororIRowOperationInterceptoryourself, no change is required (the async methods default to your sync implementations).