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¶
HubEntityBaseService — not 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<TDto>"]
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.Core — input.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)andAuthorizationService.IsGrantedAnyAsync(...)(e.g.ShipmentAppService.WithDetailsAsyncconditionally includes documents/invoices based onHubPermissions.Document.View/Invoice.View). - Declarative
[Authorize(...)]on extra action methods (e.g.GetNewDocuments,GetRecentStatusChanges,GetTrackingall 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¶
- Application Services — service taxonomy and the plain-CRUD baseline
- DTOs — request/result DTOs and the facet DTO family
- Authorization — permissions,
[GrantInRoles], role hierarchy - OData, Filtering & Facets — the OData appliers and
FacetBuilder - Entity Framework —
IQueryFilterauto vs manual, global filters - Caching —
IAppCachetags and the EF second-level cache - Shipment Service — the fullest concrete example
- Repositories —
HubBaseRepository, compiled queries - ABP Patterns and Architecture Overview