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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="NetEvolve.Pulse.AspNetCore" Version="0.84.106" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="NetEvolve.Pulse.AspNetCore" Version="0.84.106" />
                    
Directory.Packages.props
<PackageReference Include="NetEvolve.Pulse.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add NetEvolve.Pulse.AspNetCore --version 0.84.106
                    
#r "nuget: NetEvolve.Pulse.AspNetCore, 0.84.106"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package NetEvolve.Pulse.AspNetCore@0.84.106
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=NetEvolve.Pulse.AspNetCore&version=0.84.106
                    
Install as a Cake Addin
#tool nuget:?package=NetEvolve.Pulse.AspNetCore&version=0.84.106
                    
Install as a Cake Tool

NetEvolve.Pulse.AspNetCore

NuGet Version NuGet Downloads License

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 returning 200 OK with the response. Defaults to POST when no method is specified; accepts any CommandHttpMethod value.
  • MapCommand<TCommand>: Maps a void command to an HTTP endpoint returning 204 No Content. Defaults to POST when no method is specified; accepts any CommandHttpMethod value.
  • MapQuery<TQuery, TResponse>: Maps a query to a GET endpoint returning 200 OK with the result.
  • MapStreamQuery<TQuery, TResponse>: Maps a stream query to a GET endpoint that streams items as SSE or NDJSON, serialized with the HTTP JSON options. See HTTP Stream Queries.
  • MapStreamQueryHub<TQuery, TResponse>: Maps a PulseStreamHub that exposes a stream query as a native SignalR server-to-client stream (requires AddSignalR()).
  • CommandHttpMethod enum: Strongly-typed HTTP method selection — Post, Put, Patch, Delete. GET is excluded by design since commands are state-changing operations.
  • CancellationToken propagation: Automatically propagates the HTTP request cancellation token.
  • OpenAPI compatible: Returns typed results (TypedResults) so WithOpenApi() produces correct response schemas.
  • DI-based: IMediator is resolved from the request scope at runtime — no compile-time dependency on NetEvolve.Pulse.
  • Outbox Inspector (MapOutboxInspector): Minimal API endpoints to inspect outbox messages and to replay or dismiss dead letters, backed by IOutboxManagement. 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 IPayloadSerializer and were PascalCase. They now follow the HTTP JSON options (camelCase by default). SSE string items on .NET 10 were previously written as raw text and are now quoted JSON strings. On .NET 10, a null SSE item is now written as data: null instead of an empty data: line, the same as NDJSON. A custom IPayloadSerializer no longer affects MapStreamQuery. 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.sql of the provider package after upgrading. The script is idempotent.

NativeAOT and Trimming

  • MapCommand, MapQuery, MapStreamQuery and the inspector extensions (MapOutboxInspector, MapAuditInspector, MapCommandDeadLetterInspector) carry [RequiresUnreferencedCode] and [RequiresDynamicCode], because they build request delegates with RequestDelegateFactory. 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 as OutboxMessageStatus, AuditResult and CommandDeadLetterStatus are written as numbers unless the application registers a converter such as JsonStringEnumConverter<TEnum>. Pulse appends an internal source-generated JsonSerializerContext to 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.
  • MapStreamQueryHub and PulseStreamHub<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-generated JsonSerializerContext for TQuery and TResponse with the JSON hub protocol, for example services.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 any IMediator implementation) registered in the DI container

Contributing

Contributions are welcome! Please read the Contributing Guidelines before submitting a pull request.

Support

License

This project is licensed under the MIT License - see the LICENSE file for details.


Made with ❤️ by the NetEvolve Team

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.84.106 127 11/8/2026
0.70.75 1,395 7/28/2026
0.70.16 398 7/27/2026
0.70.0 181 7/24/2026
0.67.140 147 7/23/2026
0.67.36 684 5/10/2026
0.17.3 1,121 3/28/2026