NetEvolve.Pulse.AspNetCore
0.84.106
Prefix Reserved
dotnet add package NetEvolve.Pulse.AspNetCore --version 0.84.106
NuGet\Install-Package NetEvolve.Pulse.AspNetCore -Version 0.84.106
<PackageReference Include="NetEvolve.Pulse.AspNetCore" Version="0.84.106" />
<PackageVersion Include="NetEvolve.Pulse.AspNetCore" Version="0.84.106" />
<PackageReference Include="NetEvolve.Pulse.AspNetCore" />
paket add NetEvolve.Pulse.AspNetCore --version 0.84.106
#r "nuget: NetEvolve.Pulse.AspNetCore, 0.84.106"
#:package NetEvolve.Pulse.AspNetCore@0.84.106
#addin nuget:?package=NetEvolve.Pulse.AspNetCore&version=0.84.106
#tool nuget:?package=NetEvolve.Pulse.AspNetCore&version=0.84.106
NetEvolve.Pulse.AspNetCore
NetEvolve.Pulse.AspNetCore provides IEndpointRouteBuilder extension methods that map Pulse mediator commands and queries directly to ASP.NET Core Minimal API HTTP endpoints. Eliminate the boilerplate of endpoint lambdas that only forward to IMediator.
Features
MapCommand<TCommand, TResponse>: Maps a command to an HTTP endpoint returning200 OKwith the response. Defaults toPOSTwhen no method is specified; accepts anyCommandHttpMethodvalue.MapCommand<TCommand>: Maps a void command to an HTTP endpoint returning204 No Content. Defaults toPOSTwhen no method is specified; accepts anyCommandHttpMethodvalue.MapQuery<TQuery, TResponse>: Maps a query to aGETendpoint returning200 OKwith the result.MapStreamQuery<TQuery, TResponse>: Maps a stream query to aGETendpoint that streams items as SSE or NDJSON, serialized with the HTTP JSON options. See HTTP Stream Queries.MapStreamQueryHub<TQuery, TResponse>: Maps aPulseStreamHubthat exposes a stream query as a native SignalR server-to-client stream (requiresAddSignalR()).CommandHttpMethodenum: Strongly-typed HTTP method selection —Post,Put,Patch,Delete.GETis excluded by design since commands are state-changing operations.- CancellationToken propagation: Automatically propagates the HTTP request cancellation token.
- OpenAPI compatible: Returns typed results (
TypedResults) soWithOpenApi()produces correct response schemas. - DI-based:
IMediatoris resolved from the request scope at runtime — no compile-time dependency onNetEvolve.Pulse. - Outbox Inspector (
MapOutboxInspector): Minimal API endpoints to inspect outbox messages and to replay or dismiss dead letters, backed byIOutboxManagement. See Outbox Inspector.
Installation
NuGet Package Manager
Install-Package NetEvolve.Pulse.AspNetCore
.NET CLI
dotnet add package NetEvolve.Pulse.AspNetCore
PackageReference
<PackageReference Include="NetEvolve.Pulse.AspNetCore" Version="x.x.x" />
Quick Start
using NetEvolve.Pulse;
var builder = WebApplication.CreateBuilder(args);
// Register Pulse and handlers
builder.Services.AddPulse();
builder.Services.AddScoped<ICommandHandler<CreateOrderCommand, OrderResult>, CreateOrderHandler>();
builder.Services.AddScoped<ICommandHandler<UpdateOrderCommand, OrderResult>, UpdateOrderHandler>();
builder.Services.AddScoped<ICommandHandler<DeleteOrderCommand, Void>, DeleteOrderHandler>();
builder.Services.AddScoped<IQueryHandler<GetOrderQuery, OrderDto>, GetOrderHandler>();
var app = builder.Build();
// Map commands and queries — no boilerplate lambdas needed
app.MapCommand<CreateOrderCommand, OrderResult>("/orders"); // POST /orders
app.MapCommand<UpdateOrderCommand, OrderResult>("/orders/{id}", CommandHttpMethod.Put); // PUT /orders/{id}
app.MapCommand<DeleteOrderCommand>("/orders/{id}", CommandHttpMethod.Delete); // DELETE /orders/{id}
app.MapQuery<GetOrderQuery, OrderDto>("/orders/{id}"); // GET /orders/{id}
app.Run();
Without this package you would write:
app.MapPost("/orders", async (CreateOrderCommand cmd, IMediator mediator, CancellationToken ct) =>
Results.Ok(await mediator.SendAsync<CreateOrderCommand, OrderResult>(cmd, ct)));
app.MapPut("/orders/{id}", async (Guid id, UpdateOrderCommand cmd, IMediator mediator, CancellationToken ct) =>
Results.Ok(await mediator.SendAsync<UpdateOrderCommand, OrderResult>(cmd with { Id = id }, ct)));
app.MapDelete("/orders/{id}", async ([AsParameters] DeleteOrderCommand cmd, IMediator mediator, CancellationToken ct) =>
{
await mediator.SendAsync<DeleteOrderCommand>(cmd, ct);
return Results.NoContent();
});
app.MapGet("/orders/{id}", async ([AsParameters] GetOrderQuery query, IMediator mediator, CancellationToken ct) =>
Results.Ok(await mediator.QueryAsync<GetOrderQuery, OrderDto>(query, ct)));
Usage
Commands with a Response
MapCommand<TCommand, TResponse> binds TCommand as described in Command Binding Sources, sends it via IMediator.SendAsync, and returns 200 OK with the result. The default HTTP method is POST; use the CommandHttpMethod parameter to choose a different method:
// POST /orders (default)
app.MapCommand<CreateOrderCommand, OrderResult>("/orders");
// PUT /orders/{id}
app.MapCommand<UpdateOrderCommand, OrderResult>("/orders/{id}", CommandHttpMethod.Put);
// PATCH /orders/{id}
app.MapCommand<PatchOrderCommand, OrderResult>("/orders/{id}", CommandHttpMethod.Patch);
public record CreateOrderCommand(string Sku, int Quantity) : ICommand<OrderResult>;
public record UpdateOrderCommand(Guid Id, string Sku, int Quantity) : ICommand<OrderResult>;
public record OrderResult(Guid OrderId, string Status);
Void Commands
MapCommand<TCommand> binds TCommand as described in Command Binding Sources, sends it via IMediator.SendAsync, and returns 204 No Content. The default HTTP method is POST:
// POST /orders/cancel (default)
app.MapCommand<CancelOrderCommand>("/orders/cancel");
// DELETE /orders/{id}
app.MapCommand<DeleteOrderCommand>("/orders/{id}", CommandHttpMethod.Delete);
public record CancelOrderCommand(Guid Id) : ICommand;
public record DeleteOrderCommand(Guid Id) : ICommand;
Command Binding Sources
The binding source of a command depends on the HTTP method:
| Method | Binding source |
|---|---|
POST, PUT, PATCH |
JSON request body. Route values whose key matches a command property overwrite the body value, so the route value wins. A key matches the JSON property name (after the naming policy and [JsonPropertyName]) or the CLR property name, case-insensitive. |
DELETE |
Route values and query string via [AsParameters], like MapQuery. A request body is not required and is ignored. |
For PUT /orders/{id} with body {"id":"B","sku":"X","quantity":1} sent to /orders/A, the handler receives Id = A. The command always targets the resource named in the URI. Route values are converted with the application's HTTP JSON options (ConfigureHttpJsonOptions); a route value that cannot be converted to the property type returns 400 Bad Request. Route values without a matching property, such as {tenant} in /tenants/{tenant}/orders/{id}, are ignored. Route parameter names must match the JSON or CLR name of the property, otherwise the body value is kept.
DELETE follows RFC 9110 §9.3.5: content in a DELETE request has no generally defined semantics. Every property of a DELETE command therefore needs a route or query string value that ASP.NET Core can bind (TryParse), or it must be optional.
Clients that sent the identifier of a DELETE command only in the request body must move it to the route or the query string.
Queries
MapQuery<TQuery, TResponse> registers a GET endpoint that binds route parameters and query string to TQuery using [AsParameters], executes the query via IMediator.QueryAsync, and returns 200 OK with the result:
app.MapQuery<GetOrderQuery, OrderDto>("/orders/{id}");
public record GetOrderQuery(Guid Id) : IQuery<OrderDto>;
public record OrderDto(Guid Id, string Sku, string Status);
HTTP Stream Queries
MapStreamQuery<TQuery, TResponse> registers a GET endpoint that streams the items of IMediator.StreamQueryAsync as Server-Sent Events (text/event-stream, the default) or as NDJSON (application/x-ndjson, when the Accept header weighs it higher):
app.MapStreamQuery<GetOrdersStreamQuery, OrderDto>("/orders/stream");
Every item is serialized with the application's HTTP JSON options (ConfigureHttpJsonOptions), the same contract as MapQuery and MapCommand, on every target framework. Indentation is always disabled, so each item is one single-line JSON text, and string items are quoted JSON strings:
# application/x-ndjson
{"id":"0b6f…","sku":"A-1","status":"Open"}
# text/event-stream
data: {"id":"0b6f…","sku":"A-1","status":"Open"}
Under NativeAOT, add a source-generated context for TResponse to the HTTP JSON options, for example builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default)).
Breaking change (#846): NDJSON items, and SSE items on .NET 8 and .NET 9, previously used
IPayloadSerializerand were PascalCase. They now follow the HTTP JSON options (camelCase by default). SSEstringitems on .NET 10 were previously written as raw text and are now quoted JSON strings. On .NET 10, anullSSE item is now written asdata: nullinstead of an emptydata:line, the same as NDJSON. A customIPayloadSerializerno longer affectsMapStreamQuery. See the decision record.
SignalR Stream Queries
MapStreamQueryHub<TQuery, TResponse> maps a PulseStreamHub<TQuery, TResponse> to a path. Clients call the StreamAsync hub method as a SignalR server-to-client stream. The hub runs IMediator.StreamQueryAsync and sends each item to the caller as a stream item. SignalR is part of the ASP.NET Core shared framework, so you don't need an extra package on the server. You must register SignalR with AddSignalR() first, otherwise MapStreamQueryHub throws InvalidOperationException:
builder.Services.AddSignalR();
// ...
app.MapStreamQueryHub<GetOrdersStreamQuery, OrderDto>("/hubs/orders");
public record GetOrdersStreamQuery(string CustomerId) : IStreamQuery<OrderDto>;
TQuery must be deserializable by the configured hub protocol (JSON by default). Each query type needs its own hub path, because SignalR does not support generic hub methods.
To cancel, the client unsubscribes from the stream. SignalR then cancels the hub method's CancellationToken, and the hub ends the stream without an error. A disconnect cancels the stream the same way.
JavaScript client (@microsoft/signalr):
const subscription = connection.stream("StreamAsync", { customerId: "42" }).subscribe({
next: (order) => console.log(order),
complete: () => console.log("done"),
error: (err) => console.error(err),
});
// Cancels the server-side stream.
subscription.dispose();
.NET client (Microsoft.AspNetCore.SignalR.Client):
using var cts = new CancellationTokenSource();
try
{
await foreach (var order in connection.StreamAsync<OrderDto>("StreamAsync", new GetOrdersStreamQuery("42"), cts.Token))
{
Console.WriteLine(order);
if (order.Status == "Shipped")
{
// Cancels the server-side stream.
cts.Cancel();
}
}
}
catch (OperationCanceledException)
{
// Raised on the client after cancelling; the server ends the stream without an error.
}
The hub accepts anonymous connections unless you apply authorization. Secure it like any other endpoint:
app.MapStreamQueryHub<GetOrdersStreamQuery, OrderDto>("/hubs/orders").RequireAuthorization();
The query payload comes from the client and is untrusted. Validate it in the handler or an interceptor and scope it to the calling user (for example, check that CustomerId belongs to the caller).
CommandHttpMethod Enum
The CommandHttpMethod enum controls the HTTP method registered for command endpoints. GET is intentionally excluded because commands are state-changing operations — use MapQuery for read-only operations instead:
| Value | HTTP Method | Typical use |
|---|---|---|
Post (default) |
POST |
Create a new resource |
Put |
PUT |
Replace an existing resource |
Patch |
PATCH |
Partially update a resource |
Delete |
DELETE |
Remove a resource |
Passing an undefined enum value throws ArgumentOutOfRangeException.
Chaining Endpoint Configuration
All methods return RouteHandlerBuilder, so you can chain Minimal API metadata:
app.MapCommand<CreateOrderCommand, OrderResult>("/orders")
.WithName("CreateOrder")
.WithTags("Orders")
.WithOpenApi()
.RequireAuthorization();
app.MapCommand<DeleteOrderCommand>("/orders/{id}", CommandHttpMethod.Delete)
.WithName("DeleteOrder")
.WithTags("Orders")
.WithOpenApi()
.RequireAuthorization();
app.MapQuery<GetOrderQuery, OrderDto>("/orders/{id}")
.WithName("GetOrder")
.WithTags("Orders")
.WithOpenApi()
.RequireAuthorization("ReadOrders");
Grouping Endpoints
Combine with MapGroup for shared prefixes and metadata:
var orders = app.MapGroup("/orders")
.WithTags("Orders")
.RequireAuthorization();
orders.MapCommand<CreateOrderCommand, OrderResult>("/");
orders.MapCommand<UpdateOrderCommand, OrderResult>("/{id}", CommandHttpMethod.Put);
orders.MapCommand<DeleteOrderCommand>("/{id}", CommandHttpMethod.Delete);
orders.MapQuery<GetOrderQuery, OrderDto>("/{id}");
Command Dead Letter Inspector
MapCommandDeadLetterInspector maps administrative endpoints over the registered ICommandDeadLetterManagement (provided by the SQL Server, PostgreSQL, SQLite, MySQL and Entity Framework Core dead letter stores):
| Method | Route | Result |
|---|---|---|
GET |
{BasePath}/stats |
200 with counts per status |
GET |
{BasePath}/entries?count=50&skip=0 |
200 with pending entries, oldest first; 400 if count is not between 1 and 1000 or skip < 0 |
GET |
{BasePath}/entries/{id:guid} |
200 with the entry, 404 if not found |
POST |
{BasePath}/entries/{id:guid}/replay |
204 after replaying the command, 404 if not found, 409 if the entry was dismissed. Resolved entries can be replayed again; a failed replay resets the entry to New, increments its AttemptCount and records the latest failure instead of adding a new entry |
POST |
{BasePath}/entries/{id:guid}/dismiss |
204 after dismissing the entry, 404 if not found |
CommandDeadLetterInspectorOptions configures BasePath (default /pulse/commands) and RouteGroupName (default Pulse Command Dead Letter Inspector).
No authorization is applied. Secure the returned route group yourself:
app.MapCommandDeadLetterInspector(options => options.BasePath = "/admin/commands")
.RequireAuthorization("Operations");
Audit Inspector
MapAuditInspector maps read-only endpoints over the registered IAuditManagement (any Pulse audit store provider). There are no replay, dismiss or other mutating operations.
| Endpoint | Description |
|---|---|
GET {BasePath}/stats |
Success and failure counts (AuditStatistics) |
GET {BasePath}/entries |
Filtered, paginated audit records, most recent first |
GET {BasePath}/entries/{id:guid} |
A single audit record, or 404 Not Found |
Query parameters for GET {BasePath}/entries (all optional, combined with AND):
| Parameter | Description |
|---|---|
commandType |
Exact request type name |
userId |
Exact user identifier |
from / to |
Inclusive OccurredAt bounds (ISO 8601); from must not be later than to |
result |
Success or Failure |
take |
Page size, between 1 and 1000 (default 50) |
skip |
Number of records to skip, 0 or more (default 0) |
Malformed or out-of-range values return 400 Bad Request.
AuditInspectorOptions sets BasePath (default /pulse/audit) and RouteGroupName (default Pulse Audit Inspector, applied via WithGroupName). No authorization is built in, so secure the returned route group yourself:
app.MapAuditInspector(options => options.BasePath = "/admin/audit")
.RequireAuthorization("AuditReaders");
Outbox Inspector
MapOutboxInspector maps a route group of administrative endpoints backed by IOutboxManagement. Use them to find stuck messages, replay dead letters and dismiss failures without direct database access. IOutboxManagement is registered by the outbox persistence provider, for example the Entity Framework, SQL Server, PostgreSQL, MySQL, SQLite, MongoDB or Cosmos DB package.
app.MapOutboxInspector(); // default base path "/pulse/outbox"
app.MapOutboxInspector(options =>
{
options.BasePath = "/admin/outbox";
options.RouteGroupName = "Admin Outbox Inspector";
})
.RequireAuthorization("OutboxAdmin"); // secure the whole group
| Method | Route | Description | Responses |
|---|---|---|---|
GET |
{BasePath}/stats |
Message counts per status | 200 |
GET |
{BasePath}/messages?pageSize=50&page=0&status= |
Messages in any status, newest update first; status is optional (Pending, Processing, Completed, Failed, DeadLetter or their numeric values). Read-only: messages are not locked or changed. |
200, 400 |
GET |
{BasePath}/messages/{id:guid} |
A single message in any status | 200, 404 |
POST |
{BasePath}/messages/{id:guid}/replay |
Alias of dead-letters/{id}/replay; only dead-letter messages can be replayed |
204, 404 |
GET |
{BasePath}/dead-letters?pageSize=50&page=0 |
Dead-letter messages, newest update first | 200, 400 |
GET |
{BasePath}/dead-letters/count |
Number of dead-letter messages | 200 |
GET |
{BasePath}/dead-letters/{id:guid} |
A single dead-letter message | 200, 404 |
POST |
{BasePath}/dead-letters/{id:guid}/replay |
Resets a dead-letter message to Pending |
204, 404 |
POST |
{BasePath}/dead-letters/{id:guid}/dismiss |
Permanently deletes a dead-letter message | 204, 404 |
POST |
{BasePath}/dead-letters/replay-all |
Resets all dead-letter messages to Pending; returns { "count": n } |
200 |
pageSize must be between 1 and 1000 and page must not be negative. Invalid values and undefined status values return 400 Bad Request with a validation problem body. Identifiers that are not GUIDs do not match any route and return 404 Not Found.
Options
| Property | Default | Description |
|---|---|---|
BasePath |
/pulse/outbox |
Route prefix of the endpoint group |
RouteGroupName |
Pulse Outbox Inspector |
Endpoint group name, for example for OpenAPI documents |
Authorization
MapOutboxInspector applies no authorization. These endpoints expose message payloads and can replay or delete messages, so always secure the returned group, for example with .RequireAuthorization(), and never expose it publicly without protection.
Note for SQL Server and PostgreSQL: the message listing, message lookup and dismiss operations use new stored procedures and functions. Re-run
Scripts/OutboxMessage.sqlof the provider package after upgrading. The script is idempotent.
NativeAOT and Trimming
MapCommand,MapQuery,MapStreamQueryand the inspector extensions (MapOutboxInspector,MapAuditInspector,MapCommandDeadLetterInspector) carry[RequiresUnreferencedCode]and[RequiresDynamicCode], because they build request delegates withRequestDelegateFactory. Trimmed and NativeAOT applications get a warning when they call them.- The inspector endpoints write their responses with the application's
HttpJsonOptions(ConfigureHttpJsonOptions), so naming policy, converters and other settings apply. Enums such asOutboxMessageStatus,AuditResultandCommandDeadLetterStatusare written as numbers unless the application registers a converter such asJsonStringEnumConverter<TEnum>. Pulse appends an internal source-generatedJsonSerializerContextto a copy of these options as fallback resolver, so the responses need no reflection and no application context under NativeAOT. The configured options instance is never modified. MapStreamQueryHubandPulseStreamHub<TQuery, TResponse>build without trim or AOT warnings, but SignalR itself is not supported under NativeAOT on .NET 8 and only partially supported on .NET 9 and later. Under NativeAOT, register a source-generatedJsonSerializerContextforTQueryandTResponsewith the JSON hub protocol, for exampleservices.AddSignalR().AddJsonProtocol(o => o.PayloadSerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default)), and observe the SignalR NativeAOT restrictions.
See NativeAOT and Trimming in the NetEvolve.Pulse README for the full list and the payload serialization setup.
Requirements
- .NET 8.0, .NET 9.0, or .NET 10.0
- ASP.NET Core (included in the SDK)
NetEvolve.Pulse(or anyIMediatorimplementation) registered in the DI container
Related Packages
- NetEvolve.Pulse - Core CQRS mediator
- NetEvolve.Pulse.Extensibility - Extensibility contracts
- NetEvolve.Pulse.Polly - Polly v8 resilience policies integration
- NetEvolve.Pulse.EntityFramework - Entity Framework Core outbox persistence
- NetEvolve.Pulse.SqlServer - SQL Server ADO.NET outbox persistence
Contributing
Contributions are welcome! Please read the Contributing Guidelines before submitting a pull request.
Support
- Issues: Report bugs or request features on GitHub Issues
- Documentation: Read the full documentation at https://github.com/dailydevops/pulse
License
This project is licensed under the MIT License - see the LICENSE file for details.
Made with ❤️ by the NetEvolve Team
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- NetEvolve.Pulse.Extensibility (>= 0.84.106)
-
net8.0
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.14)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Options (>= 10.0.14)
- NetEvolve.Pulse.Extensibility (>= 0.84.106)
-
net9.0
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.14)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Options (>= 10.0.14)
- NetEvolve.Pulse.Extensibility (>= 0.84.106)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.