Skip to content

OData, Filtering & Facets

Cargonerds layers a full Microsoft.AspNetCore.OData v8 query surface on top of the Hub domain. It is the read/query backbone for shipments, organizations, code lists and the analytics dashboards: $filter, $orderby, $top/$skip, $select, $expand, $search and $apply/groupby/aggregate are all supported, plus a bespoke faceting engine (the "filter sidebar" with per-value counts) that is exposed both as an OData instance annotation and as plain DTO properties.

Everything OData lives in the Hub module

The entire OData/facet stack is in modules/hub (Hub.HttpApi, Hub.Application, Hub.Domain*, Hub.EntityFrameworkCore, Hub.UI). The root src/Cargonerds.* projects only carry the small ABP built-in OData configs (Identity / OrganizationUnit). The whole layer is mounted into the single host Cargonerds.HttpApi.Host via [DependsOn(typeof(HubHttpApiModule))] — see HTTP API & auto-API.

Two parallel query surfaces

There are two query paths over Hub entities, both driven by the same EDM model and the same FacetBuilder. Knowing which one you are on explains most of the behaviour and the gotchas below.

Surface Route Driver Returns Used by
OData controllers /odata/... [EnableQuery] applies options to the returned IQueryable OData JSON payload (optionally with cn.facets annotation) $apply aggregation tiles (React rockline dashboard, POC-report chart widgets), the generic OData viewer
ABP application services /api/app/..., /api/hub/... ODataServerQueryApplier parses the query string manually PagedResultDtoWithFilters<TDto> (data + facet groups + applied-filter chips) Paged grids that need the facet sidebar and applied-filter chips inline
flowchart TD
    C[HTTP request] -->|/odata/Shipment| O[ODataControllerBase&lt;T&gt;.Get]
    C -->|/api/app/...| A[HubEntityBaseService]

    subgraph ODATA[OData controller path]
        O --> O1[GetQueryable .AsNoTracking]
        O1 --> O2[FilterAll: ABP IQueryFilter org/tenant]
        O2 --> O3[ApplyAllWithoutODataApply: every IQueryApplier EXCEPT ODataServerQueryApplier]
        O3 --> O4[ApplyODataSearch: $search to full-text]
        O4 --> O5[TagWith recompile]
        O5 --> O6[EnableQuery applies $filter/$orderby/$top/$skip/$expand/$apply]
        O6 --> O7[FacetResourceSetSerializer adds cn.facets]
    end

    subgraph APP[AppService path]
        A --> A1[ODataServerQueryApplier: ApplyOData search-filter-orderby-skip-top]
        A1 --> A2[FacetBuilder.BuildAsync]
        A2 --> A3[ODataAppliedExtractor to AppliedFilterDto chips]
        A3 --> A4[PagedResultDtoWithFilters]
    end

    O7 --> R[Response]
    A4 --> R

A single IEdmModel singleton is built once at startup by HubEdmModelContributor (convention-based, camelCase) and shared by everything.

The OData controller layer

Base classes

ODataControllerBase<T> is the generic base for ~60 thin per-entity controllers. It has two flavours, both [Authorize]:

// modules/hub/src/Hub.HttpApi/Controllers/ODataControllerBase.cs
[Authorize]
public class ODataControllerBase<T>(IServiceProvider serviceProvider) : ODataController
    where T : HubBaseGuidEntity
{
    protected virtual Task<IQueryable<T>> GetQueryable(IServiceProvider p) =>
        Task.FromResult(p.GetRequiredService<HubDbContext>().Set<T>().AsNoTracking());
    // ...
}

[Authorize]
public class ODataControllerBase<TRepository, TEntity>(IServiceProvider serviceProvider)
    : ODataControllerBase<TEntity>(serviceProvider)
    where TRepository : IRepository<TEntity>
    where TEntity : HubBaseGuidEntity
{
    protected override async Task<IQueryable<TEntity>> GetQueryable(IServiceProvider p) =>
        (await p.GetRequiredService<TRepository>().GetQueryableAsync()).AsNoTracking();
}
  • ODataControllerBase<T> queries HubDbContext.Set<T>().AsNoTracking() directly.
  • ODataControllerBase<TRepository, TEntity> instead resolves an ABP repository and calls GetQueryableAsync() — used by CalculatedShipmentController (ODataControllerBase<ICalculatedShipmentRepository, CalculatedShipment>).

Concrete controllers are almost all one-liners:

// ShipmentController.cs
[Authorize(HubPermissions.Shipment.View)]
public class ShipmentController(IServiceProvider sp) : ODataControllerBase<Shipment>(sp);

Most code-list controllers have no permission attribute

Only the heavy entities (Shipment, Organization, Order, CalculatedShipment) add an [Authorize(HubPermissions...)]. The entire #region CodeEntities set (ContainerType, Currency, PackType, WeightUnit, …) relies on the base [Authorize], i.e. any authenticated user can read them. See Authorization & permissions.

MyInsightsController.cs is the exception that does not derive from the base: MyInsightsDataController : ODataController returns an in-memory data.AsQueryable() from IMyInsightsDataService with [EnableQuery(MaxNodeCount = 1000)]. Because the source is in-memory, $apply runs as LINQ-to-Objects there — which is why the $apply crash below does not affect /odata/MyInsightsData.

The Get() pipeline

The single most important method in the layer:

// ODataControllerBase<T>.Get
[EnableQuery]
public virtual async Task<IQueryable<T>> Get(CancellationToken ct = default)
{
    var res = (await GetQueryable(serviceProvider))
        .FilterAll(serviceProvider)                  // ABP IQueryFilter org/tenant scoping
        .ApplyAllWithoutODataApply(serviceProvider); // every IQueryApplier EXCEPT the OData one
    res = res.ApplyODataSearch(
        Request.ODataQueryOptions<T>(Get<IEdmModel>()).Search,
        dbContext: serviceProvider.GetRequiredService<HubDbContext>()
    );
    res = res.TagWith(RecompileCommandInterceptor.Tag); // adds "-- recompile" (see gotcha)
    await TryBuildFacets(ct);                            // optional facet sidebar
    return res;                                          // [EnableQuery] then applies the OData options
}

The action returns an unmaterialized IQueryable<T>. The [EnableQuery] attribute then applies $filter/$orderby/$top/$skip/$expand/$apply to it. The order of operations inside the action matters:

  1. FilterAll — applies ABP global query filters (IQueryFilter) for org/tenant scoping before OData runs. See Org filtering and the ABP data filtering docs.
  2. ApplyAllWithoutODataApply — runs every registered IQueryApplier except ODataServerQueryApplier. The OData applier is excluded deliberately: [EnableQuery] already applies the OData options, so running the applier too would double-apply them.
  3. $search is applied manually (the rest of OData is left to [EnableQuery]).
  4. TagWith(recompile) tags the SQL so an EF interceptor appends OPTION (RECOMPILE).
// modules/hub/src/Hub.Domain/Extensions/QueryableExtensions.cs
public static IQueryable<T> ApplyAllWithoutODataApply<T>(
    this IQueryable<T> queryable, IServiceProvider serviceProvider) where T : class
{
    var appliers = serviceProvider
        .GetServices<IQueryApplier>()
        .Where(applier => applier.GetType().Name != "ODataServerQueryApplier"); // literal name!
    return appliers.Aggregate(queryable, (current, applier) => applier.Apply(current));
}

Double-apply avoidance hinges on a class-name string

The exclusion is a literal string compare on applier.GetType().Name, not a typeof. Renaming ODataServerQueryApplier would silently double-apply OData options. This is non-obvious and fragile.

EDM model

The EDM is built once at startup and registered as a singleton:

// modules/hub/src/Hub.HttpApi/HubHttpApiModule.cs (PreConfigureServices)
context.Services.AddSingleton<IEdmModel>(sp =>
    sp.GetRequiredService<IEdmModelContributor>().GetEdmModel());
context.Services.AddSingleton<IConfigureOptions<ODataOptions>, ODataOptionsConfiguration>();

PreConfigure<IMvcBuilder>(mvcBuilder =>
{
    mvcBuilder.AddApplicationPartIfNotExists(typeof(HubHttpApiModule).Assembly);
    mvcBuilder.AddOData();
});

HubEdmModelContributor (ISingletonDependency, [ExposeServices(typeof(IEdmModelContributor))]) is the active builder. It uses an ODataConventionModelBuilder with EnableLowerCamelCase(), explicitly registers every EntitySet<> (Shipment, Organization, CalculatedShipment, ~40 code entities, etc.), lets injected IODataEntityTypeConfiguration services contribute, and declares one bound collection action Shipment/export:

// modules/hub/src/Hub.Application/OData/HubEdmModelContributor.cs
var builder = new ODataConventionModelBuilder();
builder.EnableLowerCamelCase();
// ...
var shipmentEntitySet = builder.EntitySet<Shipment>(nameof(Shipment));
// ...
var exportAction = shipmentEntitySet.EntityType.Collection.Action("export");
exportAction.Parameter<ExportFilterDto>("input");
exportAction.Returns<Guid>();

Legacy EDM switch

HubEdmModelContributor has a private legacy = false flag. When true it falls back to EdmByAttributeContributor.GetEdmModel() — an attribute scanner ([ProvideEdm]) that caches the model statically and reflects over all loaded assemblies (heavier and order-sensitive). The default is the explicit convention builder.

OData options

// modules/hub/src/Hub.HttpApi/OData/ODataOptionsConfiguration.cs
public void Configure(ODataOptions options)
{
    options.RouteOptions?.EnablePropertyNameCaseInsensitive = true;
    options
        .EnableQueryFeatures()
        ?.AddRouteComponents(HubConsts.ODataRoute, edmModel, services =>
        {
            services.AddSingleton<IODataSerializerProvider, FacetSerializerProvider>();
            services.AddSingleton<ODataUriResolver>(_ => new ODataUriResolver
            {
                EnableCaseInsensitive = true,
            });
        });
}
  • Route prefix is "odata" (HubConsts.ODataRoute), so URLs are /odata/Shipment, /odata/CalculatedShipment, /odata/MyInsightsData, etc.
  • Property names and the URI resolver are case-insensitive.
  • A custom FacetSerializerProvider is registered (drives the cn.facets annotation).

HubHttpApiModule.OnApplicationInitialization also wires app.UseDelta<HubDbContext>(), which adds ETag / Last-Modified-style 304 support keyed off DB changes.

$apply / groupby / aggregate

There is no server C# that builds $apply

$apply aggregation (groupby/aggregate) is purely an [EnableQuery] feature. The clients construct the expression; OData translates it into GroupBy(...).Select(new GroupByWrapper/AggregationWrapper{...}) on top of the IQueryable the Get action returns.

The React rockline dashboard (frontend/realtime/src/services/rocklineDashboardService.ts) builds the query with the odata-query npm package and follows @odata.nextLink / @odata.count:

// rocklineDashboardService.ts (URL-encoded before sending)
const applySegments = [];
if (request.filter) {
    applySegments.push(`filter(${request.filter})`);
}
applySegments.push(`groupby((${groupedFields.join(',')}),aggregate($count as shipmentCount))`);

let nextUrl = `/odata/MyInsightsData?$apply=${encodeURIComponent(applySegments.join('/'))}`;

So the effective request looks like:

/odata/MyInsightsData?$apply=filter(<expr>)/groupby((<fields>),aggregate($count as shipmentCount))

For MyInsightsData the source is in-memory (LINQ-to-Objects). The same shape against the SQL-backed /odata/CalculatedShipment (and any HubDbContext-backed entity) runs against SQL Server — which is exactly where the TagWith gotcha bites.

The POC-report chart widgets consume the same OData surface for their groupby/aggregate tiles. See POC reports DTO/config for the widget-config DTO story.

Faceting

Faceting is the "filter sidebar with per-value counts". It is declarative: you put [Facet] attributes on entity properties, and a single FacetBuilder service does the rest.

Declaring facets

// modules/hub/src/Hub.Domain.Shared/Attributes/Facets/FacetAttribute.cs
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public sealed class FacetAttribute : Attribute
{
    public string? Label { get; set; }
    public FilterType Type { get; set; } = FilterType.CheckboxList;
    public bool MultiSelect { get; set; } = true;
    public GroupOperator GroupOperator { get; set; } = GroupOperator.Or;
    public int Order { get; set; }
    public int TopDistinct { get; set; }
    public string? ValuePath { get; set; }  // e.g. "TransportMode/Id" or "TransportModeId"
    public string? LabelPath { get; set; }  // e.g. "TransportMode/Name"
    public Type? ValueType { get; set; }     // required when ValuePath points at a navigation
}

Real usage on CalculatedShipment (the entity behind the dashboards):

// modules/hub/src/Hub.Domain/Entities/Calculated/CalculatedShipment.cs
[Facet] public ShipmentState State { get; set; }
[Facet] public string? TransportMode { get; set; }
[Facet] public string? OriginCountryName { get; set; }
[Facet] public string? DestinationCountryName { get; set; }
[Facet] public string? ConsignorName { get; set; }
[Facet] public string? ConsigneeName { get; set; }

FilterType values: CheckboxList, Radio, Range, DateRange, Search, TokenList. GroupOperator is And/Or.

[Facet] paths are stringly-typed

ValuePath/LabelPath are slash-delimited strings, translated at runtime to dynamic-LINQ (slashes → dots) and OData (dots → slashes). Wrong casing or a wrong path does not fail at compile time — the facet just produces no options or throws a runtime dynamic-LINQ error. Set ValueType whenever ValuePath targets a navigation property (otherwise it defaults to string).

The FacetBuilder

FacetBuilder (modules/hub/src/Hub.Application/Services/FacetBuilder.cs, [ExposeServices(typeof(IFacetBuilder))], IScopedDependency) reflects the [Facet] properties (cached per type in a ConcurrentDictionary<Type, FacetTypeCache>), then:

  • List facets (CheckboxList/TokenList/Radio) — builds one combined query by .Concat-ing per-facet GroupBy(...).Select(new(... Count() ...)) projections via System.Linq.Dynamic.Core, tags it FacetBuilder: Dynamic list facets, and materializes it. Each option carries a ready-to-use OData fragment.
  • Range / DateRange facets — produce preset buckets (e.g. last 7/14/30 days); each preset count is a separate CountAsync, optionally parallelized via a SemaphoreSlim (MaxDbParallelism = 4), each in its own requiresNew UoW + fresh DI scope (uses the ABP UoW manager).
  • Disjunctive faceting — when enabled, each group's counts are computed against the query with all other groups' filters applied but not its own, so an already-selected value doesn't zero out its sibling options.

Behaviour is controlled by FacetBuilderOptions:

Option Default Meaning
BuildFacets false Build facets at all (else only extract applied filters)
ComputeDynamicLists true Compute distinct value lists for list facets
DisjunctiveFacets true Count each group ignoring its own filter
DisjunctiveByGroupOperator true Honour AND/OR when not fully disjunctive
DefaultTopDistinct 15 Default cap on discrete options
ParallelizeRanges true Run range counts in parallel
MaxDbParallelism 4 Parallel-count degree
BuildLiterals false Build OData literals (false for EDM-provided models)

Facet serialization (the cn.facets annotation)

On the OData controller path, facets are returned as an OData instance annotation rather than a separate response shape. ODataControllerBase.ShouldBuildFacets() returns true only when the request carries a Prefer header containing odata.include-annotations="*" (HubConsts.ODataAnnotations) or the literal cn.facets:

// HubConsts.cs
public const string ODataRoute = "odata";
public const string ODataPrefer = "Prefer";
public const string ODataAnnotations = "odata.include-annotations=\"*\"";

If so, TryBuildFacets stashes the List<FilterGroupDto> in HttpContext.Items["cn.facets"], and FacetResourceSetSerializer reads it back and emits it as a kebab-cased JSON instance annotation:

// modules/hub/src/Hub.HttpApi/OData/FacetResourceSetSerializer.cs
public const string FacetsAnnotationName = "cn.facets";

public override ODataResourceSet CreateResourceSet(
    IEnumerable resourceSet, IEdmCollectionTypeReference collectionType,
    ODataSerializerContext writeContext)
{
    var set = base.CreateResourceSet(resourceSet, collectionType, writeContext);
    if (writeContext.Request?.HttpContext.Items.TryGetValue(FacetsAnnotationName, out var facetsObj) == true
        && facetsObj is not null)
    {
        var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower };
        var raw = JsonSerializer.Serialize(facetsObj, options);
        set.InstanceAnnotations.Add(
            new ODataInstanceAnnotation(FacetsAnnotationName, new ODataUntypedValue { RawValue = raw }));
    }
    return set;
}

FacetSerializerProvider swaps this serializer in for any resource-set payload.

Hub.UI OData clients (Blazor)

The Blazor Hub.UI consumes the same /odata surface. The relevant clients live under modules/hub/src/Hub.UI/:

  • Services/ODataApiClient.cs (ITransientDependency) — a generic client for the dynamic OData viewer page. Queries /odata/{entitySet} with dynamic JSON parsing (no typed DTOs), supports $top, $skip, $count, $search, $filter, $orderby, and reads facet annotations via the Prefer header. It deserializes the annotation with PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower (matching the serializer above).
  • Services/HubApiClient.cs — lightweight HTTP client (with Simple.OData.Client) whose default headers always include { HubConsts.ODataPrefer, HubConsts.ODataAnnotations }, so Hub.UI requests always ask for the cn.facets annotation. It binds the response property @cn.facets back into typed DTOs.
  • OData/IEntitySetClient.cs / OData/BoundClientExtensions.cs — typed IBoundClient<T> per entity set.

See API clients and Blazor UI for the wider client story.

The AppService faceting path

The /api/app and /api/hub paths do not use [EnableQuery]. Instead a base service (HubEntityBaseService, plus CalculatedShipmentAppService, MyInsightsDataService) reads the OData query string with ODataServerQueryApplier, pages the result, and returns a PagedResultDtoWithFilters<TDto>:

// modules/hub/src/Hub.Application.Contracts/Dtos/PagedResultDtoWithFilters.cs
public class PagedResultDtoWithFilters<T> : PagedResultDto<T>, IFacetResponse
{
    public List<FilterGroupDto> Filters { get; set; } = new();   // groups for UI rendering
    public List<AppliedFilterDto> Applied { get; set; } = new(); // active filters as chips
    public string? ODataFilter { get; set; }                     // combined OData filter
    public QueryMetaDto Query { get; set; } = new();             // $orderby/$top/$skip, raw query
}

This extends ABP's PagedResultDto<T> (see ABP DTOs and application services). ODataServerQueryApplier.ApplyOData runs search → filter → orderby → skip → top itself, with HandleNullPropagation = HandleNullPropagationOption.False. ODataAppliedExtractor walks the $filter AST (BinaryOperatorNode, SingleValuePropertyAccessNode) to turn the active filter into AppliedFilterDto "chips". The full facet DTO family (FilterGroupDto, FilterOptionDto, AppliedFilterDto, RangeDefinitionDto, IFacetResponse, QueryMetaDto) is documented in DTOs.

$expand is translated to EF .Include(...)

On both paths ApplyODataExpandIncludes walks the SelectExpandClause, maps EDM nav names to CLR property names (case-insensitive), and issues nested .Include("A.B.C") chains. This is why $expand works on the AppService path even though it bypasses the OData formatter.

EF perf interceptors

Two DbCommandInterceptors sit on the pooled HubDbContext (pool size 256) and rewrite SQL based on tags added by the OData/facet layer. Both are singletons registered in HubEntityFrameworkCoreModule. See Entity Framework.

RecompileCommandInterceptor

Appends OPTION (RECOMPILE) to any command whose text contains the -- recompile tag (added by ODataControllerBase.Get via TagWith):

// modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/RecompileCommandInterceptor.cs
public const string Tag = "recompile";
private const string TagMarker = "-- " + Tag;
private const string Hint = "\nOPTION (RECOMPILE)";

private static void ApplyHint(DbCommand command)
{
    var text = command.CommandText;
    if (text.Contains(TagMarker, StringComparison.Ordinal)
        && !text.Contains("OPTION (RECOMPILE)", StringComparison.Ordinal))
    {
        command.CommandText = text + Hint;
    }
}

This forces SQL Server to pick a fresh plan per parameter set — a deliberate fix for the "app slower than Rider" parameter-sniffing problem.

FacetJoinInterceptor

Rewrites LEFT JOININNER JOIN for any command tagged FacetBuilder: — safe because the facet queries always add WHERE {path} != null, making the two joins equivalent and giving SQL Server better plans:

// modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/FacetJoinInterceptor.cs
private void RewriteIfFacetQuery(DbCommand command)
{
    if (!command.CommandText.Contains(FacetConstants.FacetQueryTagPrefix, StringComparison.OrdinalIgnoreCase))
        return;
    command.CommandText = command.CommandText.Replace(
        "LEFT JOIN", "INNER JOIN", StringComparison.OrdinalIgnoreCase);
}

Gotchas

TagWith(recompile) + $apply → NullReferenceException

Live, currently unguarded on main

ODataControllerBase.Get calls res = res.TagWith(RecompileCommandInterceptor.Tag) and returns the query to [EnableQuery]. When the request is an OData $apply aggregation (groupby/aggregate), OData builds GroupBy(...).Select(new GroupByWrapper/AggregationWrapper{...}) on top of the already-tagged query. EF Core cannot translate "EF query tag + OData aggregation wrappers" in one expression tree and throws a NullReferenceException.

Plain $filter/$top queries (no wrapper) are unaffected — which is why some tiles work and others fail. This hits the POC-report chart widgets and the React rockline tiles that hit /odata/CalculatedShipment (and any SQL-backed entity). /odata/MyInsightsData is not affected because its controller returns an in-memory IQueryable.

The TagWith line arrived via a merge from main (a perf optimization to force OPTION (RECOMPILE)), hence "it worked before the merge". As of the current main the line is present and there is no $apply guard. Candidate fixes (not yet applied in code):

  • Comment the line out — loses OPTION (RECOMPILE) for all OData Get, including non-aggregated lists; or
  • Guard it so it only tags non-aggregation queries:
if (string.IsNullOrEmpty(Request.Query["$apply"]))
{
    res = res.TagWith(RecompileCommandInterceptor.Tag);
}

Dead ends during the original diagnosis (not the cause): encoded-comma %2C, System.Uri re-canonicalization, EF.Constant inlining.

Nextended response filter must skip OData $apply wrapper types

The Nextended response filters (registered in HubHttpApiModule.ConfigureServices via AddNextendedResponseFilters) cannot traverse OData's internal wrapper types, so HubHttpApiModule configures SkipResponseType to detect them — both by namespace and by inspecting the IEnumerable<T> element type (the root value of an aggregation is a System.Linq.EnumerableQuery<GroupByWrapper>):

// modules/hub/src/Hub.HttpApi/HubHttpApiModule.cs
options.SkipResponseType = t =>
{
    if (t.Namespace?.StartsWith("Microsoft.AspNetCore.OData") == true)
        return true;
    var elementType = Array
        .Find(t.GetInterfaces(),
            i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>))
        ?.GetGenericArguments()[0];
    return elementType?.Namespace?.StartsWith("Microsoft.AspNetCore.OData") == true;
};

Forgetting this breaks every aggregation response (Nextended 10.1.6 cannot safely traverse GroupByWrapper / SelectExpandWrapper).

$search is applied manually, not by [EnableQuery]

The controller pulls ODataQueryOptions<T>().Search and routes it to a full-text WhereFTSContains (ApplyODataSearch); the rest of the OData options are left to [EnableQuery]. OData's own search pipeline is bypassed. On the AppService path, ApplyOData runs search → filter → orderby → skip → top itself.

ODataServerQueryApplier swallows OData parse errors

On any exception the applier logs (LogFailedODataQuery) and falls back to a Nextended.Core.OData string parser. A malformed $filter may therefore silently return unfiltered or differently-filtered data rather than a 400.

Facet localization is currently bypassed

FacetBuilder.L(...) has an unconditional return key; before the real localization lookup ("Temporary bypass of localization"). Facet labels and option labels are not localized today.

ABP patterns used

  • IQueryApplier strategy + DI fan-out — appliers (ODataServerQueryApplier, QuickFilterQueryApplier) are registered with [ExposeServices] + IScopedDependency and applied by enumerating GetServices<IQueryApplier>(). See dependency injection.
  • ABP global query filters / IQueryFilterFilterAll<T> applies non-auto-active filters whose EntityType == typeof(T) before OData runs; ties into ABP data filtering.
  • ABP repositoriesODataControllerBase<TRepository, TEntity> resolves an IRepository<TEntity> and calls GetQueryableAsync().
  • Lifetime markers / [ExposeServices]FacetBuilder (IScopedDependency), HubEdmModelContributor (ISingletonDependency), ODataApiClient (ITransientDependency).
  • ABP module lifecycleHubHttpApiModule.PreConfigureServices registers the EDM + OData options and calls mvcBuilder.AddOData(); OnApplicationInitialization wires UseDelta<HubDbContext>(). See modularity.
  • ABP application service + DTO mapping — the AppService path returns PagedResultDtoWithFilters<T> : PagedResultDto<T>; see application services and DTOs.

Non-ABP framework pieces in play: Microsoft.AspNetCore.OData v8, System.Linq.Dynamic.Core (dynamic facet LINQ), Nextended.ResponseFilters / Nextended.Core.OData (response shaping + fallback parser), Simple.OData.Client (Blazor client), and Delta (UseDelta).