Skip to content

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: CustomerOwnedGuidEntity carries Guid? 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 polymorphic ExternalIdentifier (TPH) family; it adds OrganizationId / Organization and implements SetParentEntity. 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&lt;Organization&gt;]
    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.TotalCount is page-local, not the full match count — it calls result.Count() after Skip/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 for Organization. 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 raw ctx.Set<Organization>() calls. Bypassing it (e.g. IgnoreQueryFilters) leaks cross-customer data.
  • Mapperly, not ObjectMapper. Use entity.ToDto() (the generated DtoMapper); calling ObjectMapper.Map<Organization, OrganizationDto> is not wired for this type.
  • Reads can be ~60s stale because of CacheAllQueries on the Hub context — relevant for tests and "my write didn't show up" investigations (see Caching).

See also