Skip to content

The search box knows all the secrets -- try it!

Polecat is part of the Critter Stack ecosystem.

JasperFx Logo JasperFx provides formal support for Polecat and other Critter Stack libraries. Please check our Support Plans for more details.

Polecat.AspNetCore

Polecat ships a small companion package, Polecat.AspNetCore, with helpers for ASP.NET Core development. The main feature is a set of typed IResult wrappers that let you return Polecat documents and event-sourced aggregates directly from Minimal API endpoints with correct status codes, content types, and OpenAPI metadata.

Install the NuGet package:

powershell
PM> Install-Package Polecat.AspNetCore

Typed Streaming Result Types

For Minimal API endpoints (and frameworks like Wolverine.Http that dispatch any IResult return value), Polecat.AspNetCore ships three typed result wrappers:

TypeSourceResponse shape404 on miss?
StreamOne<T>IQueryable<T> — document querySingle Tyes
StreamMany<T>IQueryable<T> — document queryJSON array T[]no (empty array = 200)
StreamPaged<T>IQueryable<T> + page number/sizePaged JSON envelopeno (empty page = 200)
StreamPagedByCursor<T>IQueryable<T> + cursor/page sizeKeyset page envelopeno (empty page = 200)
StreamAggregate<T>IQuerySession + stream id — event-sourcedSingle Tyes

Each type implements both IResult (so ASP.NET dispatches it via ExecuteAsync) and IEndpointMetadataProvider (so OpenAPI generators see the right response shape).

StreamOne — single document with 404 on miss

csharp
app.MapGet("/issues/{id:guid}",
    (Guid id, IQuerySession session) =>
        new StreamOne<Issue>(session.Query<Issue>().Where(x => x.Id == id)));

Returns 200 application/json with the document JSON on a hit, 404 on a miss. Content-Length and Content-Type are set automatically.

StreamMany — JSON array

csharp
app.MapGet("/issues/open",
    (IQuerySession session) =>
        new StreamMany<Issue>(session.Query<Issue>().Where(x => x.IsOpen)));

Returns 200 application/json with a JSON array body. An empty result set yields [], not a 404.

StreamPaged — one page of documents plus paging metadata

csharp
app.MapGet("/issues/paged/{pageNumber:int}/{pageSize:int}",
    (int pageNumber, int pageSize, IQuerySession session) =>
        new StreamPaged<Issue>(
            session.Query<Issue>().OrderBy(x => x.Number), pageNumber, pageSize));

Streams a single page of documents plus paging metadata as one JSON envelope in a single database round trip — the total row count rides along on every row via COUNT(*) OVER(), so there is no separate COUNT query. Raw persisted document JSON is streamed straight into items with no deserialize/reserialize:

json
{"pageNumber":3,"pageSize":25,"totalItemCount":1207,"pageCount":49,
 "hasNextPage":true,"hasPreviousPage":true,"items":[...]}

The envelope key names and shape are byte-for-byte identical to Marten's StreamPaged, so clients are interchangeable across the two stores. Include an OrderBy on the query for a stable page order (SQL Server requires an ORDER BY for OFFSET/FETCH paging).

pageNumber is 1-based; both pageNumber and pageSize must be >= 1 (otherwise an ArgumentOutOfRangeException is thrown). An empty match — or paging past the end of the set — yields totalItemCount: 0, pageCount: 0, items: [].

WARNING

Paging past the end of a non-empty set (e.g. 10 items, page 5 of size 3 → OFFSET 12) returns zero rows, so the window-function total is lost and the envelope reports totalItemCount: 0 even though items exist. This matches PagedList/Marten behavior; page within range to get an accurate total.

The same one-round-trip envelope is available off any Polecat IQueryable<T> without ASP.NET Core via StreamPagedJsonArray:

csharp
await session.Query<Issue>().OrderBy(x => x.Number)
    .StreamPagedJsonArray(pageNumber, pageSize, destinationStream);

StreamPagedByCursor — keyset (cursor) pagination

csharp
app.MapGet("/issues/paged-cursor/{pageSize:int}",
    (int pageSize, string? cursor, IQuerySession session) =>
        new StreamPagedByCursor<Issue>(
            session.Query<Issue>().OrderBy(x => x.Number).ThenBy(x => x.Id), cursor, pageSize));

Streams one keyset (seek) page — constant cost at any depth — as a JSON envelope, and echoes the continuation cursor in a Polecat-Continuation response header:

json
{"items":[...],"nextCursor":"v1:..."}

nextCursor is null at the end of the set. The wrapped query must order so its terminal key is the document identity (e.g. OrderBy(x => x.Number).ThenBy(x => x.Id)); a query lacking an OrderBy or with a non-identity terminal key is rejected. See Keyset (Cursor) Pagination for the mechanics and rules.

StreamAggregate — event-sourced aggregate (latest)

csharp
app.MapGet("/orders/{id:guid}",
    (Guid id, IQuerySession session) =>
        new StreamAggregate<Order>(session, id));

Returns 200 application/json with the latest projected aggregate state, or 404 if no stream exists. A constructor overload accepts string ids for stores configured with string-keyed streams.

Streaming Event Stream Metadata and Events

StreamEventState and StreamEvents are the event-side siblings of StreamAggregate<T>, backed by the FetchStreamStatePlan / FetchStreamPlan query plans — so the same plan can be batched through IBatchedQuery.QueryByPlan() and returned from an endpoint.

csharp
app.MapGet("/orders/{id:guid}/state",  (Guid id, IQuerySession s) => new StreamEventState(s, id));
app.MapGet("/orders/{id:guid}/events", (Guid id, IQuerySession s) => new StreamEvents(s, id));

Both take Guid, string and pre-built-plan constructors, and both implement IResult and IEndpointMetadataProvider so OpenAPI advertises the right 200 and 404 shapes.

StreamEventState vs StreamAggregate

  • StreamEventState writes the stream's metadata — version, created/last timestamps, archived flag.
  • StreamAggregate<T> writes the projected aggregate state built from the stream's events.

The response DTOs

Neither result writes the framework's own types to the wire, because neither can. StreamState.AggregateType and IEvent.EventType are System.Type, and System.Text.Json refuses to serialize those:

NotSupportedException: Serialization and deserialization of 'System.Type' instances
is not supported. Path: $.AggregateType.

So the bodies are StreamStateResponse and EventResponse: the aggregate type reduces to its simple name, and IEvent's assembly-qualified DotNetTypeName is deliberately kept off the wire — use EventTypeName, Polecat's stable event type alias, as the client-side discriminator. The property names match Marten's equivalents, so a client can move between the two stores unchanged.

Empty streams are ambiguous

FetchStream yields an empty list both for a stream that does not exist and for a filter that excludes every event, and the two cannot be told apart. StreamEvents exposes OnEmptyStatus, defaulting to 404 to match the other single-resource results. Set it to 200 to return an empty array instead — which is what you want when paging forward with fromVersion and running off the end is expected:

csharp
app.MapGet("/orders/{id:guid}/events", (Guid id, long fromVersion, IQuerySession s) =>
    new StreamEvents(s, id, fromVersion: fromVersion)
    {
        OnEmptyStatus = StatusCodes.Status200OK
    });

Both responses set Content-Length, and serialization buffers through an ArrayBufferWriter<byte> so the JSON never round-trips through a .NET string.

StreamOne vs StreamAggregate

  • StreamOne<T> is for regular documents — objects stored via session.Store() and queried with session.Query<T>().
  • StreamAggregate<T> is for event-sourced aggregates — Polecat rebuilds (or reads the snapshot of) the latest aggregate state from events before writing the response.

ETag / Conditional Requests

StreamOne<T> and StreamAggregate<T> support HTTP conditional requests (ETag / If-None-Match304 Not Modified) so polling clients skip re-downloading unchanged documents/aggregates. It is on by default.

  • On a normal hit, an ETag response header carrying the version is emitted.
  • An incoming If-None-Match that matches the current version yields 304 Not Modified with an empty body and the ETag header — for StreamAggregate<T> this skips the aggregation entirely (the stream version is read cheaply first).
  • The version source differs by type:
    • StreamOne<T> uses the document's version column (read inline with the document JSON in a single round trip — no follow-up metadata query).
    • StreamAggregate<T> uses the event stream's version (via FetchStreamStateAsync, read before folding).
  • StreamMany<T> is intentionally out of scope (a cheap collection-wide ETag is hard to derive).

Opt out per endpoint with EmitETag = false, which restores the exact pre-ETag behavior (no header, no conditional handling):

csharp
app.MapGet("/issues/{id:guid}",
    (Guid id, IQuerySession session) =>
        new StreamOne<Issue>(session.Query<Issue>().Where(x => x.Id == id))
        {
            EmitETag = false
        });

TIP

ETag values are opaque per RFC 7232. Polecat document tables always carry a version column, so a document ETag is always available when EmitETag is on; the only way to suppress it is EmitETag = false. ETagHelpers handles the * wildcard, comma-separated If-None-Match lists, and W/ weak validators (weak comparison, the correct function for If-None-Match).

Customizing status code and content type

All three types expose init-only properties:

csharp
app.MapPost("/issues",
    (CreateIssue cmd, IQuerySession session) =>
        new StreamOne<Issue>(session.Query<Issue>().Where(x => x.Id == cmd.IssueId))
        {
            OnFoundStatus = StatusCodes.Status201Created,
            ContentType = "application/vnd.myapi.issue+json"
        });

TIP

StreamOne<T> and StreamPaged<T> stream the raw persisted document JSON straight through (no deserialize/reserialize). StreamMany<T> and StreamAggregate<T> materialize via the regular query/projection path and serialize through System.Text.Json. All of them eliminate the endpoint boilerplate (null-check, status code, content type, OpenAPI metadata).

Released under the MIT License.