Skip to content
Kumar Chandrachooda
.NET

IQueryable In, OData Out

FastEndpoints.OData is ninety lines that bolt Microsoft's OData query engine onto a REPR endpoint - a sealed Configure(), a fake ParameterInfo, and an endpoint filter that fishes the response out of HttpContext.Items. Reading it is a masterclass in integrating with a framework through its narrowest seam. Part 7 of FastEndpoints — The Missing Chapters.

By Kumar Chandrachooda 17 Jun 2026 5 min read
Query options in, IQueryable out - OData shapes what the endpoint returns

OData is the API style you adopt when the client needs to compose its own queries: $filter, $orderby, $top, $expand in the query string, translated server-side into LINQ against your IQueryable. Microsoft's Microsoft.AspNetCore.OData assumes MVC-ish hosting; FastEndpoints assumes a class per endpoint. FastEndpoints.OData marries them in one file of consequence, and the wedding relies on a seam we have already met twice in this series.

The user experience first

The package's public surface is essentially one abstract class. Usage, modeled on the repo's own test harness:

// Program.cs — the OData half is stock Microsoft.AspNetCore.OData
bld.Services
   .AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("shop"))
   .AddOData(o => o.EnableAll())
   .AddFastEndpoints();

var app = bld.Build();
app.UseFastEndpoints();
app.MapODataMetadata("odata/$metadata", EdmModel.Instance);   // stock OData too
app.Run();

// the endpoint
sealed class CustomersEndpoint(AppDbContext db) : ODataEndpoint<Customer>
{
    protected override void Setup()
    {
        Get("/odata/customers");
        AllowAnonymous();
    }

    public override async Task<IQueryable> ExecuteAsync(
        ODataQueryOptions<Customer> req, CancellationToken ct)
    {
        await Task.CompletedTask;
        return req.ApplyTo(db.Customers);
    }
}

That endpoint now answers GET /odata/customers?$filter=startswith(Name,'Mich')&$orderby=Name asc&$top=3&$expand=*, returning the OData envelope - @odata.context plus a value array - with filtering, ordering, paging and expansion compiled into the EF query. The EDM model (ODataConventionModelBuilder, entity sets, complex types) stays entirely your responsibility and entirely stock OData; the package only handles the borders of the endpoint.

Notice two oddities. You override Setup(), not Configure(). And you implement ExecuteAsync returning Task<IQueryable>, not HandleAsync. Both are enforced: Configure() is sealed in the base class, and HandleAsync is sealed to throw NotSupportedException. The base class needs Configure() for itself - and what it does in there is the whole article.

Reading the sealed Configure()

ODataEndpoint<TEntity> derives from Endpoint<ODataQueryOptions<TEntity>, IQueryable> - the request DTO is the OData query options object. Its Configure() makes four moves:

1. Replace the binder. FastEndpoints' eight-source binder has no idea how to build ODataQueryOptions<T>. A private ODataBinder implements the library's IRequestBinder<T> and delegates to OData's own ODataQueryOptions<TEntity>.BindAsync(httpContext, parameterInfo). That OData API expects a ParameterInfo - it was designed to bind an action-method parameter - and here the package does something delightfully pragmatic: it hands over ODataParamInfo, a synthetic ParameterInfo subclass whose members return empty attribute arrays and whose nested MemberInfo throws NotImplementedException for everything nontrivial. It works because OData's binder only touches the members the shim actually implements. A fake reflection object, exactly as deep as it needs to be and not one property deeper.

2. Disable auto-send. DontAutoSendResponse() tells the pipeline not to serialize the handler's return value. If FastEndpoints JSON-serialized the IQueryable you would get… something, but not OData: no envelope, no $select projection, no OData-specific wire format. The response must leave through OData's formatter instead.

3. Declare the response shape for documentation. The real response type at the endpoint level is the non-generic IQueryable, useless to OpenAPI consumers. So Configure() clears the default metadata and declares Produces<ODataResult<TEntity>>(200, "application/json"), where ODataResult<TEntity> is a two-property DTO mirroring the envelope ([JsonPropertyName("@odata.context")] and value). It is a deliberate lie-in-a-good-cause - the docs describe the wire shape, not the CLR shape - and the source is disarmingly honest about the uncertainty, in an inline comment: “not sure if this is the correct thing to do with swagger” (from the FastEndpoints source, ODataEndpoint.cs). I have more trust in libraries that annotate their own doubt than in ones that don't.

4. Install the OData formatter and a filter. Options(x => { x.WithODataResult(); x.AddEndpointFilter(...); }) - WithODataResult() is Microsoft's minimal-API extension that attaches OData's result serialization to the route. The endpoint filter is where the two frameworks actually shake hands.

The seam: FeRequestHandler and a well-known Items key

Recall the parent series' routing trick: every FastEndpoints route is a Minimal API whose handler returns a singleton FeRequestHandler : IResult, and executing that result runs the whole endpoint pipeline. Minimal API endpoint filters wrap the handler delegate - so when OData's filter calls await next(ctx), what it gets back is not a response but that FeRequestHandler, inert.

The filter then does the two-step that makes everything work:

  1. It calls feHandler.ExecuteAsync(ctx.HttpContext) itself - running binding, validation, ExecuteAsync, the works, exactly as if no filter existed.
  2. Because DontAutoSendResponse() was set, the pipeline didn't write anything; instead it stored the handler's return value in HttpContext.Items["FastEndpointsResponse"] - the very seam Part 2 of this series flagged as load-bearing, placed there by the core library explicitly “for third party libs to access the response.” The filter reads the IQueryable back out and returns it from the filter, which puts it in the one place OData's formatter (installed by WithODataResult()) will pick it up and serialize with full OData semantics.

No fork of the pipeline, no reflection into private state, no custom middleware ordering contract. The integration consumes exactly two public-ish affordances - the IResult nature of FeRequestHandler and a documented Items key - and the core library changed nothing to accommodate it. When people ask what “designed for extensibility” looks like in practice, it looks like a seam narrow enough to describe in one sentence and stable enough to bet a package on.

Caveats from the source

Honest limits, because the package is honest about them. This is the one core-adjacent package not marked AOT-compatible - EDM models and OData's expression compilation are reflection all the way down, so it sits out the Native AOT story. The Swagger description is approximate by its own comment's admission - $select can reshape entities in ways ODataResult<TEntity> doesn't capture. And the repo's integration tests reveal a small kindness worth knowing: with EnableAll(), both $-prefixed ($filter=) and bare (top=3) query option spellings work, which the tests exercise in the same URL.

Also worth saying plainly: exposing req.ApplyTo(db.Customers) hands query composition to your clients. Pair it with OData's own guards ($top limits, expansion depth) before pointing it at a big table - that discipline is OData hygiene, not FastEndpoints-specific, and nothing in this package does it for you.

Next week we leave the query engine for the document generator, and the biggest architectural shift in the repository: the post-NSwag OpenAPI stack.