Organization Service¶
HubOrganizationAppService provides read access to Organization aggregates (trading partners /
accounts — forwarders, shipping lines, brokers, consignees, …) in the Hub module. It supports
lookup by id, batch lookup, a name/identifier search used by UI pickers, and resolution from
external system identifiers (e.g. CargoWise One / CW1 org codes). All data it returns is implicitly
narrowed to the current CargonerdsCustomer by the Hub's global query filter pipeline.
Overview¶
| Implementation | modules/hub/src/Hub.Application/Services/HubOrganizationAppService.cs |
| Interface | Hub.Services.IHubOrganizationAppService (modules/hub/src/Hub.Application.Contracts/Services/IHubOrganizatioAppService.cs) |
| Base class | HubAppService → ABP ApplicationService |
| Repository | IOrganizationRepository (modules/hub/src/Hub.Domain/Repositories/IOrganizationRepository.cs) |
| DTO | OrganizationDto (source-generated, namespace Hub.Application.Contracts.Shipments) |
| Authorization | [Authorize] — authenticated only; data narrowed by customer/org filtering |
| Auto-API | Exposed as a conventional controller (see REST surface) |
This is a thin read-only application service:
it owns no write operations, delegates all querying to IOrganizationRepository, and projects
entities to DTOs with a Mapperly-generated
ToDto() mapper rather than ABP's IObjectMapper.
Filename quirk
The contract file is named IHubOrganizatioAppService.cs (missing the n), but the interface
itself is correctly named IHubOrganizationAppService. Only the filename is misspelled.
// modules/hub/src/Hub.Application/Services/HubOrganizationAppService.cs
[Authorize]
public class HubOrganizationAppService(
IOrganizationRepository repository,
IRepository<ExternalOrganizationIdentifier> externalIdRepository
) : HubAppService, IHubOrganizationAppService
{
public async Task<OrganizationDto?> GetAsync(Guid id)
{
var res = await repository.FindByIdAsync(id);
return res?.ToDto();
}
// ...
}
HubAppService (modules/hub/src/Hub.Application/HubAppService.cs) only sets the Hub localization
resource and object-mapper context; it adds no behaviour of its own:
public abstract class HubAppService : ApplicationService
{
protected HubAppService()
{
LocalizationResource = typeof(HubResource);
ObjectMapperContext = typeof(HubApplicationModule);
}
}
Interface¶
// modules/hub/src/Hub.Application.Contracts/Services/IHubOrganizatioAppService.cs
public interface IHubOrganizationAppService : IApplicationService
{
Task<PagedResultDto<OrganizationDto>> GetListAsync(
FilterablePagedAndSortedResultRequestDto? input,
Guid[]? exclude = null, CancellationToken cancellationToken = default);
Task<PagedResultDto<OrganizationDto>> GetFromExternalIdentifiersAsync(
FilterablePagedAndSortedResultRequestDto? input,
Guid[]? exclude = null, CancellationToken cancellationToken = default);
Task<PagedResultDto<OrganizationDto>> GetSearchAsync(
FilterablePagedAndSortedResultRequestDto? input,
Guid[]? exclude = null, CancellationToken cancellationToken = default);
Task<OrganizationDto[]> GetByIdsAsync(HashSet<Guid> ids, CancellationToken cancellationToken = default);
}
The implementation also exposes GetAsync(Guid id) returning a single OrganizationDto?. It is a
public method on the service (and therefore auto-API'd) even though it is not declared on the
interface.
What exclude is for
Every list/search method takes an optional Guid[]? exclude. It removes already-selected
organizations from picker results (Where(o => exclude == null || !exclude.Contains(o.Id))), so
a multi-select UI does not re-offer rows the user has already chosen.
Methods¶
| Method | Source path | Behaviour |
|---|---|---|
GetAsync(Guid id) |
repository → compiled query | Single organization by id (no WithDetails). |
GetByIdsAsync(HashSet<Guid> ids) |
GetQueryableAsync() + Where(ids.Contains) |
Batch resolve; returns [] for an empty input set. |
GetListAsync(...) |
GetQueryableAsync(), tracking disabled |
Name-prefixed picker list; excludes null/empty names. |
GetSearchAsync(...) |
repository.FindOrganizationsAsync(...) |
Search by name OR external identifier (LIKE), keyset-style two-step paging. |
GetFromExternalIdentifiersAsync(...) |
externalIdRepository.WithDetailsAsync(...) |
List driven from the ExternalOrganizationIdentifier table, projected back to the owning org. |
GetSearchAsync — name or external identifier¶
GetSearchAsync is the richest read path. It delegates to IOrganizationRepository.FindOrganizationsAsync,
which returns a tuple of (int TotalCount, IQueryable<Organization> Query) — the count of the full
match set plus an already-paged query the service projects to DTOs:
public async Task<PagedResultDto<OrganizationDto>> GetSearchAsync(
FilterablePagedAndSortedResultRequestDto? input, Guid[]? exclude, CancellationToken ct = default)
{
(int totalCount, IQueryable<Organization> query) = await repository.FindOrganizationsAsync(
input?.Filter, exclude, input?.SkipCount ?? 0, input?.MaxResultCount ?? 10, ct);
var items = query.Select(o => o.ToDto()).ToList();
return new PagedResultDto<OrganizationDto>(totalCount, items);
}
When no filter is supplied the default page size is 10 (input?.MaxResultCount ?? 10).
GetListAsync — name-only picker list¶
GetListAsync is simpler: it filters out stub rows with no name, applies the optional name Contains
filter, counts, then orders by Name and pages. It runs with change-tracking disabled via the ABP
repository helper DisableTracking():
using (repository.DisableTracking())
{
var dbSet = await repository.GetQueryableAsync();
var filtered = dbSet
.Where(x => x.Name != null && x.Name != "")
.WhereIf(!string.IsNullOrWhiteSpace(input?.Filter), x => x.Name.Contains(input!.Filter!))
.Where(o => exclude == null || !exclude.Contains(o.Id));
var totalCount = await filtered.CountAsync(cancellationToken);
var items = filtered.OrderBy(o => o.Name).Skip(input?.SkipCount ?? 0)
.Take(input?.MaxResultCount ?? 10).Select(e => e.ToDto()).ToList();
return new PagedResultDto<OrganizationDto>(totalCount, items);
}
GetFromExternalIdentifiersAsync — search the identifier table¶
This method queries the ExternalOrganizationIdentifier table directly (via the generic ABP
IRepository<ExternalOrganizationIdentifier>), loads the owning Organization navigation with
WithDetailsAsync(i => i.Organization), filters on the identifier string, then projects each row
back to Organization.ToDto(). It is the inverse of GetListAsync: you search by the external key
(e.g. a CW1 org code) and get organizations back.
TotalCount here is the page count, not the full match count
GetFromExternalIdentifiersAsync builds result after Skip/Take and then returns
result.Count() as the PagedResultDto.TotalCount. That count therefore reflects only the
current page (at most MaxResultCount), not the total number of matches — unlike GetListAsync
and GetSearchAsync, which count the unpaged set. Treat its TotalCount as "items on this page".
Repository: OrganizationRepository¶
OrganizationRepository (modules/hub/src/Hub.EntityFrameworkCore/Repositories/OrganizationRepository.cs)
implements the custom IOrganizationRepository and extends the Hub's repository base
HubBaseRepository<HubDbContext, Organization, Guid> (which in turn derives from ABP's
EfCoreRepository).
It is registered in HubEntityFrameworkCoreModule via options.AddRepository<Organization, OrganizationRepository>(),
overriding the default repository for Organization. It auto-registers through the IOrganizationRepository
interface (resolved by ABP's dependency injection).
public class OrganizationRepository(
IDbContextProvider<HubDbContext> dbContextProvider,
IServiceProvider serviceProvider,
CurrentFilterContextProvider filterContextProvider)
: HubBaseRepository<HubDbContext, Organization, Guid>(
dbContextProvider, serviceProvider, filterContextProvider),
IOrganizationRepository
Members beyond the standard ABP repository surface:
| Member | Purpose |
|---|---|
FindByIdAsync(Guid id) |
Single org via a compiled query (HubCompiledQueries.OrganizationFindById). |
FindOrganizationsAsync(filter, exclude, skip, take) |
Name-or-identifier search with two-step paging (see below). |
GetExternalIdentifiersAsync(ids, serviceType = CW1) |
External identifiers for a set of orgs, filtered by ServiceType. |
GetAssociationsAsync() |
Flat list of Associations with their organizations (AsSplitQuery). |
GetAllAssociationsWithOrganizationsAsync() |
Compiled query AssociationsWithOrganizations. |
GetAssociationTreeWithOrganizationsAsync() |
Returns root associations; children populated by EF change-tracker fixup. |
GetAssociationTreeAsync() |
Fast tree (no orgs) using AsTracking() so ChildAssociations fill via fixup. |
LoadAssociationOrganizationsAsync(association) |
Explicit lazy-load of one association's orgs. |
FindOrganizationsAsync — two-step keyset paging¶
The search avoids paging over a heavy projection. It first builds a lightweight id query (joining
Organization to ExternalOrganizationIdentifier only when a filter is present), counts it, takes a
page of ids, and then re-queries the full Organization rows for just those ids:
// modules/hub/src/Hub.EntityFrameworkCore/Repositories/OrganizationRepository.cs
var like = $"%{filter.Trim()}%";
baseQuery =
from o in ctx.Set<Organization>()
join ei in ctx.Set<ExternalOrganizationIdentifier>() on o.Id equals ei.OrganizationId into gj
from ei in gj.DefaultIfEmpty() // LEFT JOIN
where EF.Functions.Like(o.Name, like)
|| (ei != null && EF.Functions.Like(ei.Identifier, like))
select o;
baseQuery = baseQuery.AsNoTracking().Distinct();
// ...
var idQuery = baseQuery.Select(o => o.Id);
var total = await idQuery.CountAsync(ct);
var ids = await idQuery.Skip(skip).Take(take).ToListAsync(ct);
var finalQuery = ctx.Set<Organization>().Where(o => ids.Contains(o.Id)).AsNoTracking();
return (total, finalQuery);
Why the LEFT JOIN + Distinct()
The DefaultIfEmpty() makes the identifier join a LEFT JOIN so organizations with no external
identifier still match on name. Because one organization can have many identifiers, the join can
fan out rows, so Distinct() collapses duplicates before counting/paging. Everything runs
AsNoTracking() — these are read-only picker queries.
Compiled queries¶
FindByIdAsync and the association-tree loaders use HubCompiledQueries
(modules/hub/src/Hub.EntityFrameworkCore/CompiledQueries/HubCompiledQueries.cs) — EF.CompileAsyncQuery
delegates for hot paths:
public static readonly OrganizationFindByIdQuery OrganizationFindById = EF.CompileAsyncQuery(
(HubDbContext ctx, Guid id) => ctx.Organizations.FirstOrDefault(o => o.Id == id)).Invoke;
public static readonly AssociationsWithOrganizationsQuery AssociationsWithOrganizations =
EF.CompileAsyncQuery((HubDbContext ctx) =>
ctx.Association.Include(a => a.Organizations).AsSplitQuery()).Invoke;
GetAssociationTreeWithOrganizationsAsync relies on EF Core change-tracker fixup: it loads all
associations (with their organizations) and then returns only the roots
(ParentAssociationId == null). EF wires up each Association.ChildAssociations automatically
because every row is tracked in the same DbContext within the unit of work. See
Repositories for the broader compiled-query / Dapper story.
Domain model¶
Organization (modules/hub/src/Hub.Domain/Entities/Organization.cs) is a CustomerOwnedGuidEntity
that implements IExternallyIdentifiableEntity<ExternalOrganizationIdentifier>:
public class Organization : CustomerOwnedGuidEntity,
IExternallyIdentifiableEntity<ExternalOrganizationIdentifier>
{
public string Name { get; set; }
public Guid? AssociationId { get; set; }
public bool IsForwarder { get; set; }
public bool IsShippingLine { get; set; }
public bool IsBroker { get; set; }
// ... ~25 boolean role flags (IsConsignee, IsConsignor, IsAirLine, …)
public bool IsActive { get; set; }
public virtual ICollection<ExternalOrganizationIdentifier> ExternalIdentifiers { get; set; } = [];
public virtual Association? Association { get; set; }
public virtual ICollection<Address> Addresses { get; set; } = [];
public string? GetCW1OrgCode() =>
this.GetExternalIdentifiers(ServiceType.CW1)
.OfType<ExternalCw1OrgCodeIdentifier>().FirstOrDefault()?.Identifier;
}
Key shape facts:
- It is customer-owned:
CustomerOwnedGuidEntitycarriesGuid? CargonerdsCustomerId(modules/hub/src/Hub.Domain/Base/CustomerOwnedGuidEntity.cs). This is what the global customer-isolation filter keys off (see Organization-scoped filtering). - An organization classifies itself with ~25 boolean role flags rather than a single type enum.
ExternalOrganizationIdentifier(modules/hub/src/Hub.Domain/Entities/ExternalOrganizationIdentifier.cs) is a subclass of the polymorphicExternalIdentifier(TPH) family; it addsOrganizationId/Organizationand implementsSetParentEntity.GetCW1OrgCode()is a convenience that pulls the CW1 org code out of that collection. See Entities and Aggregate Roots.
Mapping¶
Entities are mapped with the Mapperly-generated ToDto() extension defined in
modules/hub/src/Hub.Application/Services/DtoMapper.cs, not ABP's ObjectMapper:
// modules/hub/src/Hub.Application/Services/DtoMapper.cs
[Mapper(UseReferenceHandling = true)]
public static partial class DtoMapper
{
public static partial OrganizationDto ToDto(this Organization entity);
// ...
}
OrganizationDto itself is generated (Nextended.CodeGen [AutoGenerateDto]) into
modules/hub/src/Hub.Application.Contracts/Generated/OrganizationDto.g.cs, in namespace
Hub.Application.Contracts.Shipments. It mirrors the entity (all role flags, plus DTO-typed
ExternalIdentifiers, Invoices, Contacts, Association). See DTOs and
the framework guidance on object-to-object mapping & Mapperly.
Organization-scoped filtering¶
There are two independent visibility concerns in the Hub, applied by two different mechanisms. For organizations, only the first applies automatically.
flowchart TD
A[HubOrganizationAppService query] --> B[OrganizationRepository / DbSet<Organization>]
B --> C{EF global query filters}
C -->|auto-active, IsAssignableFrom| D[CustomerOwnedFilter<br/>CargonerdsCustomerId == current]
C -->|auto-active, exact type| E[ExternalOrganizationIdentifierQueryFilter<br/>on the identifier table]
D --> F[(Hub DB)]
E --> F
G[Manual org/org-unit filters<br/>IsAutoActive == false] -. NOT applied to Organization .-> B
1. Customer isolation (automatic, always on)¶
HubDbContext overrides ABP's global-query-filter hooks (ShouldFilterEntity /
CreateFilterExpression) and folds in every registered IQueryFilter whose IsAutoActive == true.
CustomerOwnedFilter (modules/hub/src/Hub.EntityFrameworkCore/Filter/CustomerOwnedFilter.cs) is the
relevant one:
[ExposeServices(typeof(IQueryFilter))]
public class CustomerOwnedFilter(AppConfiguration appConfig) : QueryFilterBase<CustomerOwnedGuidEntity>
{
public override Expression<Func<CustomerOwnedGuidEntity, bool>> CreateFilterExpression() =>
e => e.CargonerdsCustomerId == appConfig.CargonerdsCustomerId;
}
QueryFilterBase<T>.IsAutoActive defaults to true
(modules/hub/src/Hub.Domain/Filters/QueryFilterBase.cs), and the auto path matches by
IsAssignableFrom. Because Organization is a CustomerOwnedGuidEntity, every query through
this service — including the raw ctx.Set<Organization>() calls in FindOrganizationsAsync and the
OrganizationFindById compiled query — is automatically constrained to the current customer. The
service code never has to mention CargonerdsCustomerId.
This is the same extension-point pattern ABP documents for
data filtering, but implemented
with the Hub's own IQueryFilter abstraction rather than ABP's IDataFilter.
Identifier-table filter
A second auto-active filter, ExternalOrganizationIdentifierQueryFilter
(modules/hub/src/Hub.Domain/Filters/ExternalOrganizationIdentifierQueryFilter.cs), restricts the
ExternalOrganizationIdentifier set to active CW1 org-code rows
(s => s is ExternalCw1OrgCodeIdentifier && s.IsActive). This shapes the join in
FindOrganizationsAsync and the GetFromExternalIdentifiersAsync query.
2. Org / org-unit visibility (manual, NOT applied here)¶
The Hub's per-organization-unit visibility filters (e.g. ShipmentBaseOrganizationFilter,
OrderOrganizationFilter, AddressOrganizationFilter) set IsAutoActive => false and are applied
only when code explicitly calls IQueryable<T>.FilterAll(serviceProvider)
(modules/hub/src/Hub.Domain/Extensions/QueryableExtensions.cs). That path matches by exact type:
public static IQueryable<T> FilterAll<T>(this IQueryable<T> q, IServiceProvider sp) where T : class
{
var filter = sp.GetServices<IQueryFilter>().Where(f => !f.IsAutoActive && f.EntityType == typeof(T));
foreach (var f in filter) q = q.Where(f.CreateFilterExpression());
return q;
}
HubOrganizationAppService does not call FilterAll, and there is no non-auto-active filter
registered for Organization. So an organization is visible to any authenticated user of the current
customer regardless of which organization units they belong to. Visibility narrowing by org unit
happens on the transactional entities (shipments, orders, addresses), not on the master-data
organization list that powers pickers.
The ambient "who is asking" context for the manual filters comes from CurrentFilterContextProvider
(modules/hub/src/Hub.Domain/Services/CurrentFilterContextProvider.cs), an ISingletonDependency
holding an AsyncLocal<IFilterContext?> with ActiveOrgIds, ActiveOrgUnitIds, UserId, etc.
OrganizationRepository receives it via constructor injection (it is needed by the base
HubBaseRepository for SQL-level access filters on other entities), but the organization read
methods themselves do not consult it.
Don't assume org-unit narrowing on organizations
GetListAsync / GetSearchAsync return all organizations of the current customer. If you need an
org-unit-scoped subset you must filter explicitly — the automatic global filter only enforces
customer isolation, not org-unit visibility. Conversely, forgetting FilterAll on the
manual path elsewhere silently leaks rows across organizations (see
OData & filtering).
For the full dual-mechanism story (auto vs. manual, IsAssignableFrom vs. exact-type, the
OPENJSON SQL access filters in HubBaseRepository) see
OData & filtering and the Hub overview in
Hub module.
Caching¶
Reads through this service are subject to the Hub's EF Core second-level cache:
HubEntityFrameworkCoreModule configures CacheAllQueries(CacheExpirationMode.Absolute, 1 min), so
organization queries can be served from an in-memory cache and may be up to ~60 seconds stale.
Writes that should bust the cache go through the VersionTracker mechanism. See
Caching.
REST surface¶
The Hub application assembly is registered as ABP
auto-API controllers in the
host (options.ConventionalControllers.Create(typeof(HubApplicationModule).Assembly) in
src/Cargonerds.HttpApi.Host/CargonerdsHttpApiHostModule.cs), so HubOrganizationAppService becomes
a controller automatically. With ABP's default root path and method-name conventions
(Get* → GET), the routes look like:
GET /api/app/hub-organization/{id} # GetAsync(Guid id)
GET /api/app/hub-organization/by-ids # GetByIdsAsync
GET /api/app/hub-organization/list # GetListAsync
GET /api/app/hub-organization/search # GetSearchAsync
GET /api/app/hub-organization/from-external-identifiers # GetFromExternalIdentifiersAsync
A strongly-typed C# proxy is also generated for in-process / service-to-service callers via the
Hub.HttpApi.Client module (ABP
dynamic C# client proxies).
Exact routes are convention-derived
ABP derives the final route templates from the service/method names at startup; the paths above
follow the default convention but the authoritative list is the running Swagger document
(/swagger). The [Authorize] attribute means every endpoint requires an authenticated caller.
Gotchas¶
GetFromExternalIdentifiersAsync.TotalCountis page-local, not the full match count — it callsresult.Count()afterSkip/Take. The other two list methods count the unpaged set. Don't drive a pager off it expecting a grand total.- No org-unit filtering on organizations. Only customer isolation is automatic; this service never
calls
FilterAll, and no manual filter is registered forOrganization. All current-customer orgs are visible to any authenticated user. - Customer isolation is invisible in the service code. It is enforced by the EF global filter on
CustomerOwnedGuidEntity, including inside compiled queries and rawctx.Set<Organization>()calls. Bypassing it (e.g.IgnoreQueryFilters) leaks cross-customer data. - Mapperly, not
ObjectMapper. Useentity.ToDto()(the generatedDtoMapper); callingObjectMapper.Map<Organization, OrganizationDto>is not wired for this type. - Reads can be ~60s stale because of
CacheAllQuerieson the Hub context — relevant for tests and "my write didn't show up" investigations (see Caching).