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 check —
GetAsync/GetListAsynccallCheckPermissionAsync(ReadPermission)which delegates toIAuthorizationService.CheckAsync(...)(authorization). - Application caching — every read is wrapped in
Cache.GetOrAddAsync(...)(the Hub's distributedIAppCache, not the EF second-level cache). The cache tag istypeof(TService).Name("ShipmentAppService"), the current request query string is folded into the key (AddRequestQueryAsKey = true), and TTLs come fromHubFeatures.Cache.*(features) with defaults of 15 min absolute / 2 min background refresh. Caching is bypassed (force: true) whenHubFeatures.Cache.Enabledis off. - Faceting + paging —
PaginationAsyncruns the OData query appliers, computestotalCount, appliesSkip/Take, maps to DTOs, and builds the facet sidebar, returningPagedResultDtoWithFilters<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<CombinedShipmentDto>"]
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.
- Customer isolation (automatic).
ShipmentBase : CustomerOwnedGuidEntity, so the always-onCustomerOwnedFilter(anIQueryFilterwithIsAutoActive => true) is folded into EF's global query filter and restricts every query to the currentCargonerdsCustomerId. Nothing in the service needs to opt in. - Organization / org-unit visibility (manual, opt-in).
ShipmentBaseOrganizationQueryFilter(modules/hub/src/Hub.Domain/Filters/ShipmentBaseOrganizationFilter.cs) hasIsAutoActive => falseand is applied only where code callsFilterAll(serviceProvider). It keys off the shipment's addresses and the ambientCurrentFilterContextProvider.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/DeleteAsyncareNotImplementedException. UseCreateBookingAsync/ 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
ShipmentAppServiceandCalculatedShipmentAppServicetags, but unrelated SPARK-side imports do not. See Caching. GetAsyncreturnsnull(→ 404) for out-of-org-unit shipments, even when the row exists — theIsAvailableInOrgUnitgate is intentional, not a bug.- Org visibility depends on
FilterAll. Any new query path must callFilterAll/FilterAllQueryFiltersAsyncor it leaks rows across organizations (customer isolation still holds). force: trueonly bypasses the app cache, not the EF second-level cache.FindByKeyAsynchas unreachable code (EnsureCoordinatesAsyncafter areturn). It does not run; coordinate enrichment is effectively disabled here today.
See also¶
- Hub Module — module layout, filtering mechanics, SPARK integration
- OData, Filtering & Facets — the OData controller surface and facets
- Application Services and Service Patterns
- DTOs —
CombinedShipmentDto,PagedResultDtoWithFilters, facet DTOs - Caching —
IAppCachevs. the EF second-level cache - Authorization and Permissions reference
- Aggregate Roots and Repositories
- Document Service · Organization Service
- Architecture Overview