Skip to content

Service Patterns

This page collects the recurring patterns used by Cargonerds application services: how lists are paged, sorted, filtered and faceted; how IQueryable flows from repository to DTO; how entities are mapped; and how the Hub centralises the customer / organization filter context that scopes every read.

Almost all of this machinery lives in the Hub module (modules/hub). The host (src/Cargonerds.*) and Pricing layers reuse it: PricingApplicationModule DependsOn HubApplicationModule and its QuotationAppService extends the same base class. The solution ships no vanilla ABP CrudAppService sample; even the most CRUD-like service (TagAppService) is built on this Hub machinery.

The patterns build on standard ABP application services but add a bespoke read/list engine, so it pays to know where the project deviates from the framework defaults.

Where to look

  • Base class: modules/hub/src/Hub.Application/Services/HubEntityBaseService.cs
  • Richest example: modules/hub/src/Hub.Application/Services/ShipmentAppService.cs
  • Filter context: modules/hub/src/Hub.Domain/Services/CurrentFilterContextProvider.cs
  • Query extensions: modules/hub/src/Hub.Domain/Extensions/QueryableExtensions.cs

Primary-constructor dependency injection

Services use C# primary constructors for DI. ABP resolves the constructor dependencies automatically (see dependency injection):

// modules/hub/src/Hub.Application/Services/ShipmentAppService.cs
[UsedImplicitly]
[ExposeServices(IncludeSelf = true)]
public class ShipmentAppService(
    IShipmentBaseRepository repository,
    IGoogleService googleService,
    IServiceProvider serviceProvider,
    ICw1BookingService cw1BookingService,
    ICalculatedDataService calculatedDataService)
    : HubEntityBaseService<ShipmentBase, CombinedShipmentDto, ShipmentPageRequestDto, ShipmentAppService>(
        repository, serviceProvider),
      IShipmentAppService
{
    protected override string ReadPermission => HubPermissions.Shipment.View;
}

[ExposeServices(IncludeSelf = true)]

The Hub base service is exposed as itself in addition to its interface. This is not cosmetic — the caching pipeline re-resolves the concrete service from DI (sp.GetRequiredService<TService>()) so the cache factory runs against a fresh scoped instance. Without IncludeSelf = true that resolution would fail. The fourth generic parameter (TService) is the service's own type, which is how the base knows what to re-resolve.

The two layer base classes set ABP's localization resource and object-mapper context:

// modules/hub/src/Hub.Application/HubAppService.cs
public abstract class HubAppService : ApplicationService
{
    protected HubAppService()
    {
        LocalizationResource  = typeof(HubResource);
        ObjectMapperContext   = typeof(HubApplicationModule);
    }
}
// src/Cargonerds.Application/CargonerdsAppService.cs sets LocalizationResource = typeof(CargonerdsResource)

The read/list engine: HubEntityBaseService

HubEntityBaseServicenot ABP's CrudAppService — is the project's list/read base. It implements IReadOnlyAppService<TDto, Guid, TListParamDto> and comes in two flavours:

// modules/hub/src/Hub.Application/Services/HubEntityBaseService.cs

// 3-generic convenience overload — defaults the list-param DTO
public abstract class HubEntityBaseService<TEntity, TDto, TService>(
    IRepository<TEntity, Guid> repository, IServiceProvider serviceProvider)
    : HubEntityBaseService<TEntity, TDto, PagedAndSortedResultRequestWithFacetOptionsDto, TService>(...)
    where TEntity : class, IEntity, IEntity<Guid> ...

// 4-generic base — custom request DTO (e.g. ShipmentPageRequestDto)
[ExposeServices(IncludeSelf = true)]
public abstract class HubEntityBaseService<TEntity, TDto, TListParamDto, TService>(
    IRepository<TEntity, Guid> repository, IServiceProvider serviceProvider)
    : HubAppService, IReadOnlyAppService<TDto, Guid, TListParamDto>
    where TEntity      : class, IEntity, IEntity<Guid>
    where TListParamDto : PagedAndSortedResultRequestWithFacetOptionsDto
    where TService      : HubEntityBaseService<TEntity, TDto, TListParamDto, TService>

A subclass typically overrides just a handful of hooks; everything else is inherited:

Member Kind Purpose / default
ToDto(TEntity?) abstract The only required override — maps entity → DTO (usually a Mapperly extension).
ReadPermission virtual string Permission checked before every read. Default string.Empty = no check.
WithDetailsAsync() virtual Include graph for single-entity reads. Default Repository.WithDetailsAsync().
WithListDetailsAsync() virtual Include graph for list reads. Default Repository.GetQueryableAsync() (no includes).
DefaultOrder() virtual string Default sort. Returns "LastModificationTime DESC".
GetListCoreAsync(...) virtual The list pipeline body (overridden by ShipmentAppService).
FindByKeyAsync(...) virtual Single-entity lookup by key (overridden by ShipmentAppService).

Because the base is read-only, mutating operations are not provided. Where a CRUD interface signature is required (e.g. IShipmentAppService : ICrudAppService<...>), the unsupported members are explicitly stubbed and writes go through dedicated methods instead:

// ShipmentAppService — writes go through CreateBookingAsync / SendBookingToCw1Async, not generic CRUD
public Task<CombinedShipmentDto> CreateAsync(CombinedShipmentDto input) => throw new NotImplementedException();
public Task<CombinedShipmentDto> UpdateAsync(Guid id, CombinedShipmentDto input) => throw new NotImplementedException();
public Task DeleteAsync(Guid id) => throw new NotImplementedException();

The GetListAsync pipeline

This is the heart of the page. GetListAsync is the exposed list endpoint; everything else is an internal step. Trace it from the public method down to the materialised page:

flowchart TD
    A["GetListAsync(input, force)"] --> B["CheckPermissionAsync(ReadPermission)"]
    B --> C["Cache.GetOrAddAsync<br/>(tag = TService name, key = input)"]
    C -->|cache miss / force| D["GetListCoreAsync(input)"]
    D --> E["using Repository.DisableTracking()"]
    E --> F["WithListDetailsAsync()<br/>(repository IQueryable + Includes)"]
    F --> G["FilterAllQueryFiltersAsync<br/>= .FilterAll(sp).OrderBy(sorting ?? DefaultOrder())"]
    G --> H["PaginationAsync"]
    H --> I["ApplyAllQueryApplier(sp)<br/>(OData + QuickFilter) + AsSingleQuery"]
    I --> J["CountAsync (totalCount)"]
    J --> K["Skip / Take -> ToListAsync"]
    K --> L["items = list.Select(ToDto)"]
    L --> M["ReadFacetsForAsync<br/>(FacetBuilder -> Filters + Applied)"]
    M --> N["PagedResultDtoWithFilters&lt;TDto&gt;"]

1. Permission check + caching wrapper

public virtual async Task<PagedResultDtoWithFilters<TDto>> GetListAsync(TListParamDto input, bool force)
{
    await CheckPermissionAsync(ReadPermission);

    return await Cache.GetOrAddAsync(
        factory: (sp, token) => sp.GetRequiredService<TService>().GetListCoreAsync(input, token),
        keyParts: new { input },
        force: force || !await IsCacheEnabledAsync,
        options: await BuildCacheOptionsAsync(),
        ct: CancellationToken);
}

CheckPermissionAsync no-ops on an empty ReadPermission, otherwise calls IAuthorizationService.CheckAsync(permission) (throws on denial). See authorization for the permission model and the custom [GrantInRoles] seeding convention.

Caching is gated on the Hub.Cache.Enabled feature. The cache key includes the whole input DTO, and the cache tag is typeof(TService).Name, which is also how the matching InvalidateCacheAsync / WithCacheInvalidationAsync helpers clear it. The options are feature-driven:

// BuildCacheOptionsAsync — keys come from HubFeatures.Cache.*
Tag = typeof(TService).Name,
AddRequestQueryAsKey = true,
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(/* Hub.Cache.AbsoluteExpirationRelativeToNowInMinutes ?? 15 */),
RefreshInBackground = /* Hub.Cache.RefreshInBackground */,
RefreshAfter = TimeSpan.FromMinutes(/* Hub.Cache.RefreshAfterInMinutes ?? 2 */),
RefreshLockTtl = TimeSpan.FromSeconds(10),

See caching for the IAppCache layer and the separate EF second-level cache (CacheAllQueries, 1-minute TTL) that sits underneath every Hub query.

2. Tracking disabled, query assembled

protected virtual async Task<PagedResultDtoWithFilters<TDto>> GetListCoreAsync(
    TListParamDto input, CancellationToken ct)
{
    using (Repository.DisableTracking())
    {
        var queryable = await FilterAllQueryFiltersAsync(await WithListDetailsAsync(), input.Sorting);
        return await PaginationAsync(queryable, input, ct);
    }
}

Repository.DisableTracking() is ABP's repository helper: the whole read runs AsNoTracking, so EF does no change-tracking bookkeeping for list rows.

3. Org/tenant filtering + sorting

protected virtual Task<IQueryable<TEntity>> FilterAllQueryFiltersAsync(
    IQueryable<TEntity> queryable, string? order)
    => Task.FromResult(queryable.FilterAll(serviceProvider).OrderBy(order ?? DefaultOrder()));

FilterAll applies the manual org filters (see Organization / customer filter context). OrderBy(string) is System.Linq.Dynamic.Coreinput.Sorting is the ABP Sorting string (e.g. "EstimatedDeparture DESC"), falling back to DefaultOrder() ("LastModificationTime DESC"). Dynamic LINQ sorting means the sort field is a string parsed at runtime, not a compile-time expression.

4. Paging, OData, projection and facets

protected virtual async Task<PagedResultDtoWithFilters<TDto>> PaginationAsync(
    IQueryable<TEntity> queryable, TListParamDto input, CancellationToken ct)
{
    var funcToDto    = (Func<TEntity, TDto>)(ToDto);
    var facetOptions = input.FacetBuilderOptions ?? new();
    var disjunctive  = facetOptions.DisjunctiveFacets || facetOptions.DisjunctiveByGroupOperator;

    var odataFiltered = queryable.ApplyAllQueryApplier(serviceProvider).AsSingleQuery();

    var skip = input?.SkipCount ?? 0;
    var take = input?.MaxResultCount ?? 10;

    var totalCount = await odataFiltered.CountAsync(ct);
    var list       = await odataFiltered.Skip(skip).Take(take).ToListAsync(ct);
    var items      = list.Select(funcToDto).ToList();

    return await ReadFacetsForAsync(disjunctive ? odataFiltered : queryable, facetOptions, totalCount, items, ct);
}

Note the ordering: Count and Skip/Take run over the OData-filtered query, so TotalCount reflects the active $filter, and only MaxResultCount rows are materialised. .AsSingleQuery() forces one SQL round-trip for the include graph (avoids EF split-query duplication math on the count). Projection happens in memory (list.Select(funcToDto)) because ToDto typically runs the Mapperly graph mapper, which EF cannot translate to SQL.

ReadFacetsForAsync then runs the FacetBuilder to produce the filter sidebar and "applied filter" chips, and packs everything into the result envelope. The disjunctive-vs-plain choice decides whether facet counts are computed against the filtered or unfiltered query — the deep mechanics live in OData, filtering & facets.

What the client gets back

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

It extends ABP's PagedResultDto<T> (so Items + TotalCount are standard), then adds the facet metadata. The DTO family is documented under DTOs.

Two methods are deliberately hidden from the HTTP API

Both the Guid-keyed GetAsync(Guid) and the single-argument GetListAsync(input) carry [RemoteService(IsEnabled = false)]. The live endpoints are the string-keyed GetAsync(string id) and the two-argument GetListAsync(input, force). Calling the wrong overload from a typed client gives a 404, not a compile error.

Reading a single entity: alternate-key lookup

GetAsync(string id) mirrors the list path (permission → cache) but resolves a single entity through FindByKeyAsync, which does alternate-key matching rather than a plain primary-key lookup:

protected virtual async Task<TEntity?> FindByKeyAsync(string key, CancellationToken ct = default)
{
    return await (await WithDetailsAsync())
        .FilterAll(serviceProvider)
        .WhereKeyMatches(await Repository.GetDbContextAsync(), key)   // PK OR any single-column alternate key
        .FirstOrDefaultAsync(ct);
}

WhereKeyMatches (modules/hub/src/Hub.EntityFrameworkCore/Extensions/AlternateMatchingExtensions.cs) inspects the EF model's keys and matches id against the primary key or any single-column string/Guid alternate key (e.g. a shipment's HouseBill / CargonerdsNumber). That is why a shipment can be fetched by GUID or by house-bill from the same endpoint. The org filters from FilterAll still apply, so a single-entity read cannot leak across organizations.

ShipmentAppService overrides FindByKeyAsync for richer behaviour: it uses a compiled query, loads addresses via change-tracker fixup, and applies an extra org-unit visibility gate (IsAvailableInOrgUnit) before returning the entity — see shipment service.

IQueryable usage and the applier/filter strategy

Two extension methods in modules/hub/src/Hub.Domain/Extensions/QueryableExtensions.cs are the project's filtering vocabulary. Both fan out over DI-registered strategy services.

FilterAll — manual org/tenant filters

public static IQueryable<T> FilterAll<T>(this IQueryable<T> queryable, IServiceProvider serviceProvider)
    where T : class
{
    var filter = serviceProvider.GetServices<IQueryFilter>()
        .Where(f => !f.IsAutoActive && f.EntityType == typeof(T));   // exact type match — no inheritance

    foreach (var f in filter)
        queryable = queryable.Where(f.CreateFilterExpression());

    return queryable;
}

ApplyAllQueryApplier — OData + quick filters

public static IQueryable<T> ApplyAllQueryApplier<T>(this IQueryable<T> queryable, IServiceProvider serviceProvider)
    where T : class
    => serviceProvider.GetServices<IQueryApplier>()
        .Aggregate(queryable, (current, applier) => applier.Apply(current));

IQueryApplier implementations include ODataServerQueryApplier (parses the $filter/$orderby/... from the current HttpContext) and QuickFilterQueryApplier. There is also ApplyAllWithoutODataApply, used by the OData controller path to avoid double-applying OData (it filters appliers by the class name string "ODataServerQueryApplier" — a fragile but deliberate detail). Full coverage in OData, filtering & facets.

FilterAll matches the type exactly

FilterAll only picks filters whose EntityType == typeof(T). It does not walk the inheritance chain. The auto-active path (real EF global query filters, configured on the DbContext) uses IsAssignableFrom and therefore does cover subclasses — see entity framework. Mixing the two up is the easiest way to accidentally leak cross-org data on a derived entity. If a read returns nothing for an entity that should be org-scoped manually, confirm a !IsAutoActive filter exists for that exact type.

Organization / customer filter context (the Hub base pattern)

Every Hub read is scoped to the caller's active organizations and organization units. The ambient state lives in CurrentFilterContextProvider — an ISingletonDependency backed by an AsyncLocal<IFilterContext?> so it flows through the async call chain without being passed around:

// modules/hub/src/Hub.Domain/Services/CurrentFilterContextProvider.cs
public class CurrentFilterContextProvider(ICurrentUser currentUser) : ISingletonDependency
{
    private readonly AsyncLocal<IFilterContext?> _currentFilterContext = new();

    public HashSet<Guid> ActiveOrgIds     => CurrentFilterContext?.OrganizationIds.ToHashSet() ?? [];
    public HashSet<Guid> ActiveOrgUnitIds => CurrentFilterContext?.OrganizationUnitIds.ToHashSet() ?? [];
    public Guid          DefaultOrgUnitId => CurrentFilterContext?.DefaultOrganizationUnitId ?? Guid.Empty;
    public Guid?         UserId           => CurrentFilterContext?.UserId;
    public UserPreferences Preferences    => CurrentFilterContext?.Preferences ?? new();
    // ...
}

The context shape (IFilterContext, in Hub.Domain.Shared) carries the user id, the default org unit, the accessible org-unit ids, the resolved organization ids, and user preferences. It is built and cached per user by FilterContextCacheManager (src/Cargonerds.Application/Services/), which loads the user's org units via IdentityUserManager, resolves their organizations, and reads profile preferences:

// FilterContextCacheManager.BuildAndCacheAsync (abridged)
filterContext.UserId                  = currentUser.Id;
filterContext.DefaultOrganizationUnitId = (await orgUnitRepo.FindDefaultUnitAsync()).Id;
var orgUnits = await identityUserManager.GetOrganizationUnitsAsync(user);
filterContext.OrganizationUnitIds = ...;            // user's (and child) org units
filterContext.OrganizationIds     = await orgService.GetOrganizationsForOrganizationUnitsAsync(...);
filterContext.Preferences         = await preferencesService.GetPreferencesAsync();

How filters read the context

Org filters are IQueryFilter implementations that read the provider to build their predicate. The project uses two application paths, distinguished by IsAutoActive:

// modules/hub/src/Hub.Domain/Filters/QueryFilterBase.cs
public abstract class QueryFilterBase<T> : IQueryFilter<T>, IScopedDependency
{
    public abstract Expression<Func<T, bool>> CreateFilterExpression();
    public virtual bool IsAutoActive => true;          // default: real EF global query filter
    Type IQueryFilter.EntityType => typeof(T);
}
Path IsAutoActive Applied by Example
Auto (EF global query filter) true (default) EF Core, on every query; bypass with IgnoreQueryFilters CustomerOwnedFilter
Manual false only when you call .FilterAll() / FilteredSet<T>() ShipmentBaseOrganizationQueryFilter

Customer isolation is the simplest auto-active case:

// modules/hub/src/Hub.EntityFrameworkCore/Filter/CustomerOwnedFilter.cs
[ExposeServices(typeof(IQueryFilter))]
public class CustomerOwnedFilter(AppConfiguration appConfig) : QueryFilterBase<CustomerOwnedGuidEntity>
{
    public override Expression<Func<CustomerOwnedGuidEntity, bool>> CreateFilterExpression() =>
        e => e.CargonerdsCustomerId == appConfig.CargonerdsCustomerId;
}

Shipment org scoping is the richer manual case. The base filters by addresses tied to an active organization; the Shipment subclass adds the rule that default-org-unit bookings are visible only to the booking user:

// modules/hub/src/Hub.Domain/Filters/ShipmentBaseOrganizationFilter.cs
public abstract class ShipmentBaseOrganizationQueryFilter<T>(CurrentFilterContextProvider provider)
    : QueryFilterBase<T> where T : ShipmentBase
{
    public override bool IsAutoActive => false;        // manual — only via FilterAll
    public override Expression<Func<T, bool>> CreateFilterExpression()
    {
        var ids = provider.ActiveOrgIds.ToArray();
        return s => s.Addresses.Any(a => ids.Contains((Guid)a.OrganizationId));
    }
}

// modules/hub/src/Hub.Domain/Filters/ShipmentOrganizationFilter.cs
public class ShipmentOrganizationQueryFilter(CurrentFilterContextProvider provider)
    : ShipmentBaseOrganizationQueryFilter<Shipment>(provider)
{
    public override Expression<Func<Shipment, bool>> CreateFilterExpression()
    {
        var baseExpression = base.CreateFilterExpression();
        var ouIds = provider.ActiveOrgUnitIds;
        if (ouIds.Count == 0) return s => false;       // no org units => see nothing
        // ... OR shipments booked in an accessible OU, with the default-OU/booking-user rule
    }
}

Services can also read the provider directly for in-memory checks. ShipmentAppService does this in IsAvailableInOrgUnit to gate a single shipment:

var currentFilterContextProvider = Get<CurrentFilterContextProvider>();
HashSet<Guid> activeOrgIds = currentFilterContextProvider.ActiveOrgIds;

Forgetting FilterAll silently leaks data

Manual (IsAutoActive = false) filters do nothing unless a code path calls .FilterAll(...) (or HubDbContext.FilteredSet<T>()). The base HubEntityBaseService always calls it, but a new hand-written query against Repository.GetQueryableAsync() does not get org scoping for free. The org context is the org context — ShipmentAppService.GetNewDocumentsCore and friends call .FilterAll(LazyServiceProvider) explicitly for exactly this reason.

The relationship between the custom IQueryFilter system and ABP's framework data filtering is covered in entity framework. Note: SaaS multi-tenancy is disabled in this solution, so "tenant" filtering is effectively org/customer filtering.

Mapping entities to DTOs

Hub services map with Mapperly-generated ToDto() extension methods, not ABP's ObjectMapper. ABP's object-to-object mapping page documents the Mapperly integration; here it is the standard path and AutoMapper is residual (only the IdentityUser → IdentityUserDto map still uses it).

// modules/hub/src/Hub.Application/Services/DocumentAppService.cs
protected override DocumentDto? ToDto(Document? entity)
    => entity?.ToDto(new SuperTypeResolutionReferenceHandler());

// modules/hub/src/Hub.Application/Services/ShipmentAppService.cs
protected override CombinedShipmentDto? ToDto(ShipmentBase? entity) => entity?.ToCombinedShipmentDto();

The SuperTypeResolutionReferenceHandler is a custom Mapperly IReferenceHandler that resolves shared "super-type" code entities (currencies, ports, units, …) and cyclic references while mapping the object graph, so the same code entity is not duplicated across the result. Polymorphic hierarchies (Shipment / CustomsDeclaration / Consol under ShipmentBase) are handled with Mapperly's [MapDerivedType<>]. Because mapping runs over an already-materialised list, it executes in memory, not in SQL.

ObjectMapper.Map<> will not cover most domain types

Most domain↔DTO conversions are Mapperly extension methods (entity.ToDto()), not ObjectMapper. Reach for the extension method first. Passing the wrong (or null) reference handler can change how shared code entities are de-duplicated in the graph.

Request and result DTO shapes

List endpoints take a request DTO that extends ABP's paging DTO with facet options, and may add entity-specific filters:

// modules/hub/src/Hub.Application.Contracts/Dtos/PagedAndSortedResultRequestWithFacetOptionsDto.cs
public class PagedAndSortedResultRequestWithFacetOptionsDto : PagedAndSortedResultRequestDto
{
    public FacetBuilderOptions? FacetBuilderOptions { get; set; }
}

// modules/hub/src/Hub.Application.Contracts/Dtos/ShipmentPageRequestDto.cs
public class ShipmentPageRequestDto : PagedAndSortedResultRequestWithFacetOptionsDto
{
    public string? QuickFilter { get; set; }
    public bool    SubscribedOnly { get; set; }
    public Guid[]? TagIds { get; set; }
}

PagedAndSortedResultRequestDto brings ABP's SkipCount, MaxResultCount and Sorting. The entity-specific fields (SubscribedOnly, TagIds, …) are consumed by the overridden GetListCoreAsync — for shipments, they pre-resolve a HashSet<Guid> of ids that narrows the query before the standard pipeline runs. See DTOs for the full DTO catalogue (including the facet DTOs) and REST API for the auto-API surface.

Cache invalidation on writes

Because reads are cached by service-type tag, writes must invalidate or stale data is served. The base provides the helpers; mutating methods call them:

protected virtual Task InvalidateCacheAsync()       => InvalidateCacheAsync<TService>();
protected virtual Task InvalidateCacheAsync<T>()    => Cache.InvalidateByTagAsync(typeof(T).Name, CancellationToken);
protected virtual async Task<T> WithCacheInvalidationAsync<T>(Func<Task<T>> action) { var r = await action(); await InvalidateCacheAsync(); return r; }
// ShipmentAppService — a booking write invalidates *both* shipment and calculated-shipment caches
private async Task InvalidateCachesAndRecalculateShipments()
{
    await calculatedDataService.RunUpdateCalculatedShipmentsJob();
    await InvalidateCacheAsync<CalculatedShipmentAppService>();
    await InvalidateCacheAsync<ShipmentAppService>();
}

Two cache layers

This IAppCache tag-based invalidation is the application-level result cache. Underneath it, the Hub EF second-level cache (CacheAllQueries, ~1 min) caches the SQL results themselves and is invalidated via a VersionTracker table on save. Both can serve stale data; see caching.

Authorization styles

Two styles coexist (both verified in the codebase):

  • Imperative, via the base CheckPermissionAsync(ReadPermission) and AuthorizationService.IsGrantedAnyAsync(...) (e.g. ShipmentAppService.WithDetailsAsync conditionally includes documents/invoices based on HubPermissions.Document.View / Invoice.View).
  • Declarative [Authorize(...)] on extra action methods (e.g. GetNewDocuments, GetRecentStatusChanges, GetTracking all carry [Authorize(HubPermissions.Shipment.View)]).

Both build on ABP's authorization. The permission constant trees, the custom [GrantInRoles] attribute, and role-hierarchy seeding are described in authorization.

CRUD-style services

The solution has no vanilla ABP CrudAppService sample. The closest thing is TagAppService, whose interface derives from ICrudAppService (full Create/Update/Delete) but whose implementation still extends HubEntityBaseService and maps via Mapperly — see application services.

Auto-API exposure

All application services are auto-exposed as REST controllers by ABP's auto API controllers, with matching dynamic C# clients. Hub endpoints are rooted under /api/hub/... (HubRemoteServiceConsts, module "hub") and core endpoints under /api/app/.... Remember the [RemoteService(IsEnabled = false)] overloads above are not part of that surface.

See also