Skip to content

Shipment Service

ShipmentAppService is the core read/booking service of the Hub module. It exposes query and booking operations over the Shipment aggregate and its sibling Consol — both modelled as the abstract ShipmentBase (single-table inheritance). It is the busiest service in the solution and a good worked example of the Hub's house patterns: a shared HubEntityBaseService base, an application-level cache wrapper, manual organization/org-unit visibility filtering, declarative facets, and a strict separation between reads (this service + the OData controllers) and writes (the CargoWise One — "CW1" — booking flow).

Overview

Implementation modules/hub/src/Hub.Application/Services/ShipmentAppService.cs
Interface Hub.Services.IShipmentAppService (modules/hub/src/Hub.Application.Contracts/Services/IShipmentAppService.cs)
Base class HubEntityBaseService<ShipmentBase, CombinedShipmentDto, ShipmentPageRequestDto, ShipmentAppService>
Aggregate Shipment : ShipmentBase (modules/hub/src/Hub.Domain/Entities/Shipment.cs), abstract base modules/hub/src/Hub.Domain/Base/ShipmentBase.cs
Repository IShipmentBaseRepository (modules/hub/src/Hub.Domain/Repositories/IShipmentBaseRepository.cs)
Read DTO CombinedShipmentDto : ShipmentBaseDto (modules/hub/src/Hub.Application.Contracts/Dtos/CombinedShipmentDto.cs)
List request DTO ShipmentPageRequestDto (modules/hub/src/Hub.Application.Contracts/Dtos/ShipmentPageRequestDto.cs)
Read permission HubPermissions.Shipment.View
Write permission HubPermissions.Shipment.Edit (booking push/deactivate)
// 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 IShipmentBaseRepository ShipmentBaseRepository { get; } = repository;
    protected override string ReadPermission => HubPermissions.Shipment.View;
    // ...
}

[ExposeServices(IncludeSelf = true)] registers the concrete ShipmentAppService type in DI as well as its interface — the base class re-resolves the concrete type (sp.GetRequiredService<TService>()) inside every cache factory so the cached call runs through the real subclass. This is the standard ABP dependency-injection service-registration pattern, used here so the application cache can re-enter the service in a fresh DI scope.

Interface

// modules/hub/src/Hub.Application.Contracts/Services/IShipmentAppService.cs
public interface IShipmentAppService : ICrudAppService<CombinedShipmentDto, Guid, ShipmentPageRequestDto>
{
    Task<PagedResultDtoWithFilters<CombinedShipmentDto>> GetListAsync(ShipmentPageRequestDto input, bool force);
    Task<PagedResultDto<NewDocumentDto>> GetNewDocuments(PagedResultRequestDto input, bool includeTotalCount = true);
    Task<PagedResultDto<StatusUpdateDto>> GetRecentStatusChanges(PagedResultRequestDto input);
    Task<PagedResultDto<EntityChangeInfoDto<ShipmentBaseDto>>> GetPropertyUpdates(
        PagedResultRequestDto input, string[] properties, bool includeTotalCount);
    Task<ICollection<EntityChangeInfoDto>> GetPropertyUpdates(string[] properties, Guid shipmentId);
    Task<ShipmentActivityDto[]> GetLastActivityAsync(Guid shipmentId);
    Task<CombinedShipmentDto> SendBookingToCw1Async(Guid shipmentId);
    Task<CombinedShipmentDto> DeactivateBookingAsync(Guid shipmentId);
}

It is an ICrudAppService by signature, not by behaviour

The interface inherits ICrudAppService for signature/proxy compatibility, but ShipmentAppService does not implement generic write CRUD. CreateAsync, UpdateAsync and DeleteAsync all throw NotImplementedException (see the bottom of ShipmentAppService.cs). Creating a shipment goes through CreateBookingAsync(CreateUpdateBookingDto) and the CW1 booking flow instead. Treat this service as read + booking, not generic CRUD.

Several concrete public methods (e.g. GetUpcomingShipments, GetTracking, GetTrackingNew, GetAllTrackingContainers, CreateBookingAsync) live on the class but are not on IShipmentAppService. ABP's auto API controllers still expose them because they are public methods on an application service.

The HubEntityBaseService base

ShipmentAppService derives from HubEntityBaseService<TEntity, TDto, TListParamDto, TService> (modules/hub/src/Hub.Application/Services/HubEntityBaseService.cs). The base implements IReadOnlyAppService<TDto, Guid, TListParamDto> and provides the read pipeline that every Hub list service reuses. The pieces a subclass overrides:

Member Default ShipmentAppService override
ReadPermission string.Empty (no check) HubPermissions.Shipment.View
ToDto(TEntity?) abstract entity?.ToCombinedShipmentDto() (Mapperly)
WithDetailsAsync() Repository.WithDetailsAsync() adds permission-gated document / invoice / revenue includes
WithListDetailsAsync() Repository.GetQueryableAsync() inherited
FindByKeyAsync(string, ct) WithDetailsAsync().FilterAll(...).WhereKeyMatches(...) custom: id-or-identifier lookup + org-unit check
GetListCoreAsync(input, ct) filter → paginate custom: subscription + tag pre-filter
DefaultOrder() LastModificationTime DESC inherited

The base also centralizes:

  • Permission checkGetAsync/GetListAsync call CheckPermissionAsync(ReadPermission) which delegates to IAuthorizationService.CheckAsync(...) (authorization).
  • Application caching — every read is wrapped in Cache.GetOrAddAsync(...) (the Hub's distributed IAppCache, not the EF second-level cache). The cache tag is typeof(TService).Name ("ShipmentAppService"), the current request query string is folded into the key (AddRequestQueryAsKey = true), and TTLs come from HubFeatures.Cache.* (features) with defaults of 15 min absolute / 2 min background refresh. Caching is bypassed (force: true) when HubFeatures.Cache.Enabled is off.
  • Faceting + pagingPaginationAsync runs the OData query appliers, computes totalCount, applies Skip/Take, maps to DTOs, and builds the facet sidebar, returning PagedResultDtoWithFilters<TDto>. See OData, Filtering & Facets.
flowchart TD
    A["GET /api/hub/shipment"] --> B["GetListAsync(input, force)"]
    B --> C["CheckPermissionAsync(Shipment.View)"]
    C --> D["Cache.GetOrAddAsync<br/>tag=ShipmentAppService"]
    D -->|miss| E["GetListCoreAsync"]
    E --> F["SubscribedOnly / TagIds<br/>pre-filter to ID set"]
    F --> G["WithListDetailsAsync()"]
    G --> H["FilterAll(sp)<br/>org/org-unit IQueryFilter"]
    H --> I["PaginationAsync"]
    I --> J["ApplyAllQueryApplier<br/>(OData $filter/$orderby/...)"]
    J --> K["Count + Skip/Take + ToDto"]
    K --> L["FacetBuilder → filters + applied"]
    L --> M["PagedResultDtoWithFilters&lt;CombinedShipmentDto&gt;"]
    D -->|hit| M

Read operations

GetAsync — single shipment by id or identifier

The public read entry point is GetAsync(string id) (inherited). A GetAsync(Guid id) overload exists but is marked [RemoteService(IsEnabled = false)] and simply forwards to the string version — so the wire contract accepts a string key. ShipmentAppService overrides FindByKeyAsync to support either a Guid primary key or a free-form external identifier, and then enforces org-unit visibility:

// ShipmentAppService.FindByKeyAsync (abridged)
protected override async Task<ShipmentBase?> FindByKeyAsync(string key, CancellationToken ct = default)
{
    using (ShipmentBaseRepository.DisableTracking())
    {
        ShipmentBase? entity = Guid.TryParse(key, out Guid guidKey)
            ? await ShipmentBaseRepository.FindByIdWithDetailsAsync(guidKey, ct)
            : await ShipmentBaseRepository.FindByIdentifierAsync(key, ct);

        if (entity is null) return entity;

        // Load addresses via compiled query; EF Change Tracker fixup populates entity.Addresses
        await ShipmentBaseRepository.LoadAddressesWithDetailsAsync(entity.Id, ct);

        if (!IsAvailableInOrgUnit(entity)) return null;          // org-unit visibility gate

        DbContext context = await Repository.GetDbContextAsync();
        return await entity.LoadAllDetailsAsync(context, ct);
    }
}

Detail loading uses compiled queries + change-tracker fixup

Addresses are loaded by a separate compiled query (LoadAddressesWithDetailsAsync) and EF's change tracker stitches them onto the parent because both run on the same DbContext inside one Unit of Work. This is the pattern recorded for HubCompiledQueries/CargonerdsCompiledQueries. The trailing EnsureCoordinatesAsync(googleService, ...) call after return is dead code (commented //under investigation) and never executes.

GetListAsync(ShipmentPageRequestDto input, bool force)

The faceted list. force: true bypasses the application cache for that call. The list request adds three shipment-specific knobs on top of ABP's PagedAndSortedResultRequestDto:

// ShipmentPageRequestDto.cs
public class ShipmentPageRequestDto : PagedAndSortedResultRequestWithFacetOptionsDto
{
    public string? QuickFilter { get; set; }   // resolved to a HashSet<Guid> of shipment ids by QuickFilterQueryApplier
    public bool SubscribedOnly { get; set; }    // only shipments the current user subscribed to
    public Guid[]? TagIds { get; set; }         // intersect with shipments carrying these tags
}

GetListCoreAsync pre-filters to an explicit id set before the main query when SubscribedOnly/TagIds are set, then applies the org filters and pages:

protected override async Task<PagedResultDtoWithFilters<CombinedShipmentDto>> GetListCoreAsync(
    ShipmentPageRequestDto input, CancellationToken ct)
{
    using (Repository.DisableTracking())
    {
        HashSet<Guid>? ids = input.SubscribedOnly
            ? await ShipmentBaseRepository.GetSubscribedShipmentIdsAsync(CurrentUser.GetId(), ct)
            : null;

        if (input.TagIds?.Any() == true)
        {
            var byTagIds = await ShipmentBaseRepository.GetShipmentsByTagIdsAsync(input.TagIds, ct);
            ids = ids == null ? byTagIds : ids.Where(byTagIds.Contains).ToHashSet();
        }

        var shipments = (await WithListDetailsAsync()).Where(s => ids == null || ids.Contains(s.Id));
        var queryable = await FilterAllQueryFiltersAsync(shipments, input.Sorting);
        return await PaginationAsync(queryable, input, ct);
    }
}
var page = await _shipmentAppService.GetListAsync(
    new ShipmentPageRequestDto
    {
        MaxResultCount = 20,
        Sorting = "LastModificationTime DESC",
        SubscribedOnly = true,
    },
    force: false);

The returned PagedResultDtoWithFilters<CombinedShipmentDto> (PagedResultDto<T> + IFacetResponse) carries Items, TotalCount, the computed facet Filters, the Applied filter chips, the raw ODataFilter, and Query.RawODataQuery. The [Facet] attributes on ShipmentBase (e.g. HouseBill, TransportMode, PortOfLoading, IncoTerm) drive that sidebar — see OData, Filtering & Facets.

GetUpcomingShipments(input, startDate?, endDate?)

A concrete (non-interface) method returning departures within a window. Defaults are applied internally: startDate = UtcNow, endDate = UtcNow + 14 days, default sort EstimatedDeparture DESC. The WHERE clause is hard-coded business logic — only Air, or Sea with an FCL/LCL container mode, and only shipments whose key route/mode fields are populated:

// GetUpcomingShipmentsCore (abridged) — runs in SQL
queryable.Where(s =>
    s.EstimatedDeparture > range.Start && s.EstimatedDeparture < range.End
    && s.EstimatedArrival != null
    && s.FirstConsolLegDeparturePort != null && s.LastConsolLegArrivalPort != null
    && s.TransportMode != null && s.ContainerMode != null
    && (s.TransportMode.Code == "Air"
        || (s.TransportMode.Code == "Sea"
            && (s.ContainerMode.Code == "FCL" || s.ContainerMode.Code == "LCL"))))

Tracking

Method Returns Notes
GetTracking(Guid id) TrackingContainerDto[] Calls ValidateUserAccessToShipmentDataAsync(id, includeChildren: true, includeParents: false) first, then loads tracking containers for that shipment
GetTrackingNew(Guid id) TrackingContainerNewDto[] Thin projection over GetTracking
GetAllTrackingContainers() TrackingContainerDto[] All tracking containers across the org-visible CalculatedShipment set (uses ApplyAllQueryApplier)

Document & audit-trail reads

Method Returns Permission Source
GetNewDocuments(input, includeTotalCount) PagedResultDto<NewDocumentDto> Document.View Shipment ⋈ ShipmentBaseDocument where FileDate > UtcNow-28d, ordered by FileDate DESC
GetRecentStatusChanges(input) PagedResultDto<StatusUpdateDto> Shipment.View GetShipmentsWithLastEventsAsync; emits the two highest-priority Events as new/previous state
GetPropertyUpdates(input, properties, includeTotalCount) PagedResultDto<EntityChangeInfoDto<ShipmentBaseDto>> Shipment.View IShipmentBaseRepository.GetPropertyChanges(...) over the EF change history
GetPropertyUpdates(properties, shipmentId) ICollection<EntityChangeInfoDto> Shipment.View Property change history for a single shipment
GetLastActivityAsync(shipmentId) ShipmentActivityDto[] Shipment.View De-duplicated, localized (L["Activity:Change:Status", ...]) view over the property updates

GetPropertyUpdates is built to track estimated-date drift; the properties argument is typically EstimatedDeparture, EstimatedArrival, EstimatedDelivery, EstimatedPickup.

Every read is cached the same way

These methods follow one shape: a thin public [Authorize(...)] method that calls Cache.GetOrAddAsync(factory: sp => sp.GetRequiredService<ShipmentAppService>().XxxCore(...), ...), delegating the real work to a protected virtual ...Core method. The cache key is the method arguments (keyParts) plus the request query string. See Caching.

Booking operations (writes)

Booking is delegated to ICw1BookingService (modules/hub/src/Hub.Application.Contracts/Services/ICw1BookingService.cs); ShipmentAppService is the API facade plus cache/recalculation orchestration.

Method Permission Purpose
CreateBookingAsync(CreateUpdateBookingDto dto) none on method (validation only) Create a shipment booking via cw1BookingService.ProcessBooking inside a new, non-transactional UoW
SendBookingToCw1Async(Guid shipmentId) Shipment.Edit ([HttpPost]) Push an existing booking to CargoWise One
DeactivateBookingAsync(Guid shipmentId) Shipment.Edit ([HttpPost]) Deactivate an existing booking

All three return the updated CombinedShipmentDto. After every write, the service calls InvalidateCachesAndRecalculateShipments(), which (1) kicks ICalculatedDataService.RunUpdateCalculatedShipmentsJob() and (2) invalidates both the CalculatedShipmentAppService and ShipmentAppService cache tags:

private async Task InvalidateCachesAndRecalculateShipments()
{
    await calculatedDataService.RunUpdateCalculatedShipmentsJob();
    await InvalidateCacheAsync<CalculatedShipmentAppService>();
    await InvalidateCacheAsync<ShipmentAppService>();
}

CreateBookingAsync opens its own UoW explicitly because the CW1 round-trip should not be wrapped in the ambient request transaction:

public virtual async Task<CombinedShipmentDto> CreateBookingAsync(CreateUpdateBookingDto dto)
{
    var settings = new AbpUnitOfWorkOptions { IsTransactional = false };
    using (UnitOfWorkManager.Begin(settings, requiresNew: true))
    {
        var result = await cw1BookingService.ProcessBooking(dto, CancellationToken);
        await InvalidateCachesAndRecalculateShipments();
        return result;
    }
}

CreateUpdateBookingDto (modules/hub/src/Hub.Application.Contracts/Dtos/CreateUpdateBookingDto.cs) requires BookedInOrganizationUnitId, ContainerMode, TransportMode, IncoTerm, ConsigneeAddress and ShipperAddress, with optional pickup/delivery addresses or ports.

Data isolation — two visibility layers

The Hub layers two independent filters; ShipmentAppService participates in both. See the Hub module and OData, Filtering & Facets pages for the full mechanics, and ABP's data filtering docs for the framework concept.

  1. Customer isolation (automatic). ShipmentBase : CustomerOwnedGuidEntity, so the always-on CustomerOwnedFilter (an IQueryFilter with IsAutoActive => true) is folded into EF's global query filter and restricts every query to the current CargonerdsCustomerId. Nothing in the service needs to opt in.
  2. Organization / org-unit visibility (manual, opt-in). ShipmentBaseOrganizationQueryFilter (modules/hub/src/Hub.Domain/Filters/ShipmentBaseOrganizationFilter.cs) has IsAutoActive => false and is applied only where code calls FilterAll(serviceProvider). It keys off the shipment's addresses and the ambient CurrentFilterContextProvider.ActiveOrgIds:
// ShipmentBaseOrganizationQueryFilter<T>.CreateFilterExpression()
var ids = provider.ActiveOrgIds.ToArray();
return s => s.Addresses.Any(a => ids.Contains((Guid)a.OrganizationId));

Every list/query path in this service goes through FilterAll (directly, or via FilterAllQueryFiltersAsync). For single-entity reads, the org filter would not by itself enforce the org-unit rule, so FindByKeyAsync additionally calls IsAvailableInOrgUnit(entity) and returns null (404) when the shipment is not visible:

protected virtual bool IsAvailableInOrgUnit(ShipmentBase entity)
{
    var ctx = Get<CurrentFilterContextProvider>();
    HashSet<Guid> activeOrgCodeIds = ctx.ActiveOrgIds;

    if (entity.Addresses.Any(a => a.OrganizationId.HasValue && activeOrgCodeIds.Contains(a.OrganizationId.Value)))
        return true;

    if (entity is Shipment shipment)
    {
        return shipment.BookedInOrganizationUnitId.HasValue
            && ctx.ActiveOrgUnitIds.Contains(shipment.BookedInOrganizationUnitId.Value)
            && (shipment.BookedInOrganizationUnitId != ctx.DefaultOrgUnitId
                || shipment.BookedByUserId == ctx.UserId);
    }
    return false;
}

FilterAll is opt-in and matched by exact type

The org filter only applies where FilterAll/FilterAllQueryFiltersAsync is called, and it is matched by exact entity type (EntityType == typeof(T)), not assignability. A custom query path that forgets FilterAll will silently leak shipments across organizations. Customer isolation (layer 1) is always on; org/org-unit visibility (layer 2) is not.

DTOs

CombinedShipmentDto extends the generated ShipmentBaseDto (produced by the Nextended.CodeGen [AutoGenerateDto] pipeline — see Code Generation) and flattens the shipment + consol + customs view the UI needs:

// CombinedShipmentDto.cs (abridged)
public class CombinedShipmentDto : ShipmentBaseDto
{
    public JobTypeDto? JobType { get; set; }
    public List<CustomsDeclarationDto> CustomsDeclarations { get; set; } = [];
    public ShipmentDto? ParentShipment { get; set; }
    public ICollection<ShipmentDto> Shipments { get; set; } = [];
    public ICollection<ConsolDto> Consols { get; set; } = [];
    public ICollection<OrderDto> Orders { get; set; } = [];
    public ICollection<TrackingContainerDto> TrackingContainers { get; set; } = [];
    public Guid? BookedInOrganizationUnitId { get; set; }
    public Guid? BookedByUserId { get; set; }

    public bool? HasCw1Number => ExternalIdentifiers.Any(s => s.ServiceType == ServiceTypeDto.CW1);
    public ICollection<ExternalIdentifierDto> ExternalIdentifiers => [ /* shipment + consol + customs identifiers */ ];
}

Mapping is via Riok.Mapperly (entity.ToCombinedShipmentDto()), not AutoMapper — ABP's object-to-object mapping is configured to use Mapperly across the Hub. See DTOs for the wider DTO family (including PagedResultDtoWithFilters and the facet DTOs).

Permissions

Defined in HubPermissions.Shipment (modules/hub/src/Hub.Application.Contracts/Permissions/HubPermissions.cs):

public static class Shipment
{
    [GrantInRoles(RoleConsts.DefaultCustomer)]
    public const string ShipmentGroup = GroupName + ".Shipment";   // "Hub.Shipment"

    [GrantInRoles(RoleConsts.DefaultCustomer)]
    public const string View   = ShipmentGroup + ".View";          // reads
    public const string Create = ShipmentGroup + ".Create";
    public const string Edit   = ShipmentGroup + ".Edit";          // booking push / deactivate
    public const string Delete = ShipmentGroup + ".Delete";
}

Hub.Shipment.View is granted to the DefaultCustomer role by the [GrantInRoles] attribute; the write permissions are not. Read methods enforce View; SendBookingToCw1Async / DeactivateBookingAsync require Edit. See Authorization and the permissions reference.

REST & OData surface

ShipmentAppService is auto-exposed by ABP's auto API controllers under the Hub prefix /api/hub/shipment/.... Routes are derived from method names (Get*→GET, Create/Send*→POST, etc.; the first Guid parameter becomes a path segment):

GET    /api/hub/shipment/{id}                                  # GetAsync (id may be a Guid or an identifier)
GET    /api/hub/shipment                                       # GetListAsync
GET    /api/hub/shipment/upcoming-shipments                    # GetUpcomingShipments
GET    /api/hub/shipment/new-documents                         # GetNewDocuments
GET    /api/hub/shipment/recent-status-changes                 # GetRecentStatusChanges
GET    /api/hub/shipment/tracking/{id}                         # GetTracking
POST   /api/hub/shipment/create-booking                        # CreateBookingAsync
POST   /api/hub/shipment/send-booking-to-cw-1/{shipmentId}     # SendBookingToCw1Async
POST   /api/hub/shipment/deactivate-booking/{shipmentId}       # DeactivateBookingAsync

Exact routes come from ABP conventions

The paths above are illustrative of ABP's naming convention; confirm the generated routes against Swagger (/swagger) for the running app. Verified in source are the method names, HTTP verbs ([HttpPost] on the two booking-write methods), and that GetAsync accepts a string key.

In parallel, there is a separate OData read surface for the same aggregate. ShipmentController is a one-liner OData controller — it does not use ShipmentAppService at all:

// modules/hub/src/Hub.HttpApi/Controllers/ShipmentController.cs
[Authorize(HubPermissions.Shipment.View)]
public class ShipmentController(IServiceProvider serviceProvider) : ODataControllerBase<Shipment>(serviceProvider);

This exposes /odata/Shipment with $filter/$orderby/$top/$skip/$select/$expand/$apply, and is what the React insight dashboards and POC-report chart widgets aggregate against (more often the CalculatedShipment read-model). The two surfaces share the same FilterAll org filtering and the same FacetBuilder. See OData, Filtering & Facets for the controller pipeline, the cn.facets annotation, and the live TagWith(recompile) + $apply gotcha.

Gotchas

  • Generic CRUD throws. CreateAsync/UpdateAsync/DeleteAsync are NotImplementedException. Use CreateBookingAsync / the booking flow.
  • Reads can be up to ~15 min stale. The application-cache TTL defaults to 15 minutes absolute (feature-driven), on top of the EF second-level cache's 1-minute window. Writes invalidate the ShipmentAppService and CalculatedShipmentAppService tags, but unrelated SPARK-side imports do not. See Caching.
  • GetAsync returns null (→ 404) for out-of-org-unit shipments, even when the row exists — the IsAvailableInOrgUnit gate is intentional, not a bug.
  • Org visibility depends on FilterAll. Any new query path must call FilterAll/ FilterAllQueryFiltersAsync or it leaks rows across organizations (customer isolation still holds).
  • force: true only bypasses the app cache, not the EF second-level cache.
  • FindByKeyAsync has unreachable code (EnsureCoordinatesAsync after a return). It does not run; coordinate enrichment is effectively disabled here today.

See also