
In many .NET systems, writing a web service that returns query results means some combination of:
- Query data from EF Core — which is going to do who knows what to build up SQL, execute that, then spend some time materializing the raw database results into .NET objects
- Since we’ve all been taught for years that it’s harmful to expose our internal entity shapes to the outside world, maybe you’re running the results through some kind of object to object mapping to a different DTO shape
- Finally, after all the database querying and object mapping, you’ll finally use a JSON serializer to write results to the HTTP response stream
Whew. That’s a non-trivial amount of your time (or AI tokens) and a significant amount of runtime overhead with all the transformations and thrashing your memory with all the object allocations involved.
Now let’s talk about some capabilities in Marten and Polecat to sidestep the mass majority of that overhead in some cases — but first, I do need to say that if you’re using Event Sourcing, the persisted data in a Marten or Polecat database for query models is purpose built for clients as it is. No extra mapping necessary. In a way, the “AutoMapper” activity happens directly in projections for a system using Event Sourcing.
If you are building HTTP services on top of Marten or Polecat, both of these tools have a “JSON Streaming” feature that can be used to build very fast web services by writing the raw JSON stored in PostgreSQL or SQL Server directly to the HTTP response for the most efficient possible HTTP web services in the read side of a CQRS architecture.
Core team member Anne Erdtsieck just made some a bunch of extensions to Marten and Polecat‘s ability to stream the raw, persisted JSON data stored in the database straight to HTTP responses, and that makes now a good time to show off what we have.
For Minimal API endpoints (and for frameworks like Wolverine.Http that dispatch any IResult return value), Marten.AspNetCore (Polecat.AspNetCore has similar support) ships seven typed result wrappers that carry the streaming behavior above as endpoint return values while also contributing correct OpenAPI metadata:
| Type | Source | Response shape | 404 on miss? |
|---|---|---|---|
StreamOne<T> | IQueryable<T> — regular Marten document query | Single T | yes |
StreamMany<T> | IQueryable<T> — regular Marten document query | JSON array T[] | no (empty array = 200) |
StreamAggregate<T> | IDocumentSession + stream id — event-sourced | Single T | yes |
StreamPaged<T> | IQueryable<T> — regular Marten document query | Paged JSON envelope | no (empty page = 200) |
StreamPagedByCursor<T> | IQueryable<T> (with OrderBy/ThenBy) | no (empty array = 200) | |
StreamEventState | IQuerySession + stream id — event stream | Single StreamStateResponse | yes |
StreamEvents | IQuerySession + stream id — event stream | JSON array EventResponse[] | yes (configurable) |
Each type implements both IResult (so ASP.NET Minimal API dispatches it via ExecuteAsync) and IEndpointMetadataProvider (so Swashbuckle, NSwag, and the built-in OpenAPI generator see the right response shape), while delegating the actual body write to WriteSingle/WriteArray/WriteLatest/WriteStreamState/WriteEvents. Returning one from an endpoint is a concise, typed alternative to writing the HTTP handshake manually.
StreamOne<T> — single document with 404 on miss
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, matching the behavior of WriteSingle<T>.
StreamMany<T> — JSON array
app.MapGet("/issues/open", (IQuerySession session) => new StreamMany<Issue>(session.Query<Issue>().Where(x => x.Open)));
Returns 200 application/json with a JSON array body. An empty result set yields [], not a 404 — matching the behavior of WriteArray<T>.
StreamPaged<T> — paged JSON envelope (single round trip)
app.MapGet("/issues/paged/{pageNumber:int}/{pageSize:int}", (int pageNumber, int pageSize, IQuerySession session) => new StreamPaged<Issue>(session.Query<Issue>().OrderBy(x => x.Description), pageNumber, pageSize));
Returns 200 application/json with a single JSON envelope combining paging metadata and the matching documents for that page:
{"pageNumber":3,"pageSize":25,"totalItemCount":1207,"pageCount":49,"hasNextPage":true,"hasPreviousPage":true,"items":[...]}
pageNumber is 1-based. totalItemCount and pageCount are computed from a count(*) OVER() window function added to the same SQL query that fetches the page, so the whole response — count and documents both — comes from a single database round trip. Documents inside items are streamed as raw, already-persisted JSON, without a deserialize/serialize step. An empty page still returns 200 with totalItemCount: 0, pageCount: 0, and an empty items array — never a 404.
Internally, StreamPaged<T> delegates to the IQueryable<T>.StreamPagedJsonArray() extension method described in the Paging docs, which can also be used directly (e.g. from an MVC controller action) instead of through the IResult wrapper.
StreamAggregate<T> — event-sourced aggregate (latest)
app.MapGet("/orders/{id:guid}", (Guid id, IDocumentSession session) => new StreamAggregate<Order>(session, id));
Returns 200 application/json with the JSON of the latest projected aggregate state, or 404 if no stream exists. A constructor overload accepts string ids for stores configured with string-keyed streams.
StreamEventState — event stream metadata
Writes the high level metadata of a single event stream — Marten’s StreamState — as JSON, or 404 when the stream does not exist:
app.MapGet("/minimal/order/{id:guid}/state", (Guid id, IQuerySession session) => new StreamEventState(session, id));
A constructor overload accepts a string stream key for stores configured with string-keyed streams.
The response body is a StreamStateResponse, not StreamState itself. StreamState.AggregateType is a System.Type, and System.Text.Json refuses to serialize those outright (Serialization and deserialization of 'System.Type' instances is not supported), so the aggregate type is projected down to its simple name in AggregateTypeName:
{ "id": "0198e1b4-5b1c-7a1e-9a3f-2f2f5b6c7d8e", "key": null, "version": 2, "aggregateTypeName": "Order", "lastTimestamp": "2026-07-26T09:41:02.113Z", "created": "2026-07-26T09:41:02.098Z", "isArchived": false}
StreamEvents — raw events of a stream 9.20
Writes the raw events of a single event stream as a JSON array:
app.MapGet("/minimal/order/{id:guid}/events", (Guid id, IQuerySession session) => new StreamEvents(session, id));
StreamEvents carries the same optional version, timestamp, and fromVersion filters as FetchStreamAsync(), and there is a string stream key overload as well.
Elements are EventResponse, not IEvent itself — IEvent.EventType is a System.Type and hits the same System.Text.Json wall as above. Use eventTypeName, Marten’s stable event type alias, to discriminate event types on the client. The assembly qualified .NET type name (DotNetTypeName) is deliberately left off the wire:
[ { "id": "0198e1b4-5b1c-7a1e-9a3f-2f2f5b6c7d8e", "version": 1, "sequence": 41, "streamId": "0198e1b4-5b1c-7a1e-9a3f-2f2f5b6c7d8e", "streamKey": null, "eventTypeName": "order_placed", "timestamp": "2026-07-26T09:41:02.098Z", "tenantId": "*DEFAULT*", "isArchived": false, "causationId": null, "correlationId": null, "headers": null, "data": { "description": "Widget", "amount": 99.95 } }]
Empty streams: 404 or an empty array?
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 therefore exposes an OnEmptyStatus that defaults to 404, matching the other single-resource results. Set it to 200 when running off the end of a stream is expected rather than exceptional — paging forward with fromVersion, for example:
// Paging forward through a stream: running off the end is expected, not a 404app.MapGet("/minimal/order/{id:guid}/events/from/{fromVersion:long}", (Guid id, long fromVersion, IQuerySession session) => new StreamEvents(session, id, fromVersion: fromVersion) { OnEmptyStatus = StatusCodes.Status200OK });


