Hub module¶
The Hub module (modules/hub) is the logistics bounded context of the Cargonerds solution and
the largest business module in the codebase (~190 entity files). It models shipments, consols,
containers, organisations, addresses, documents, orders, tracking, invoicing and pricing, and it
enforces customer/organisation-scoped access to that data. It is a self-contained ABP module that
the core application composes in through module dependencies,
and it follows ABP's layered architecture.
Two characteristics make the Hub unusual, and most of this page is about them:
- Its database is written by two processes — this web app and SPARK, an external
worker/integration platform that imports and exports data from carrier and forwarder systems
(Cargowise/CW1, Magaya, Hapag-Lloyd, Vizion, Röhlig). The
External*Identifierfamily, theTransmissionprovenance graph,ProcessingServiceName,SparkEnvironmentand theVersionTrackercache contract all exist so the two can coexist without breaking each other. - It layers two independent visibility filters. Every row carries a
CargonerdsCustomerId(hard isolation); most freight entities additionally filter by the organisations a user may see. These are implemented by different mechanisms — one an automatic EF global query filter, the other applied manually per query.
Projects and responsibilities¶
Each folder under modules/hub/src is one layer of the module.
| Project | Purpose |
|---|---|
| Hub.Domain.Shared | Shared constants, enums, error codes, settings keys, the [StaticEnum]/[ProvideEdm]/[Facet] attributes, ISearchable<>, IHubCacheableEntity, and the HubResource localization (Localization/Hub). No dependencies; referenced by all other Hub projects. |
| Hub.Domain | The core domain: entities (Shipment, Consol, Container, Organization, Order, the External*Identifier and Transmission families, pricing entities), value objects (Coordinates, Dimension<TUnit>), base classes (HubBaseGuidEntity, CustomerOwnedGuidEntity, ShipmentBase, CodeEntity), the IQueryFilter org filters, CurrentFilterContextProvider, and CodeGen.config.json. |
| Hub.Application.Contracts | Application-service interfaces, DTOs (including generated *Dto.g.cs), permission definitions (HubPermissions), the global-search and facet contracts, filter/request DTOs and validators. |
| Hub.Application | Application-service implementations (ShipmentAppService, HubOrganizationAppService, DocumentAppService, OrderAppService, CalculatedShipmentAppService, MarginAppService, …), the HubEntityBaseService base, search providers, the FacetBuilder, SSE support and the distributed AppCache. |
| Hub.EntityFrameworkCore | The HubDbContext, ~70 entity type configurations, repository implementations, the EF interceptor stack and the customer/organisation query filters. |
| Hub.HttpApi | API controllers (most auto-generated by ABP) plus the OData controllers and EDM/OData configuration. |
| Hub.HttpApi.Client | Strongly typed C# client proxies for the Hub APIs. |
| Hub.UI | A Razor/Blazor component library: ShipmentCard, global search, filters, maps, export-to-Excel, inbox, plus pages, menus, OData clients and the SSE stream reader. Referenced by Cargonerds.Blazor.Client. |
| Hub.Installer | Helper module ([DependsOn(AbpVirtualFileSystemModule)]) that ships its own embedded static assets/config via FileSets.AddEmbedded<HubInstallerModule>() when added to a host. Holds no business logic. |
| Hub.SourceGenerators | A Roslyn IIncrementalGenerator (StaticEnumGenerator) used at build time. |
How the Hub module is integrated¶
Hub is composed into the core via ABP module dependencies, not by manually wiring projects into a host:
CargonerdsDomainModuledeclares[DependsOn(typeof(HubDomainModule), …)](alongsidePricingDomainModule).Cargonerds.EntityFrameworkCorereferencesHub.EntityFrameworkCore.Cargonerds.Blazor.ClientreferencesHub.UI, so Hub pages appear in the admin shell.
The Hub schema is not EF-migrated
The Hub model exists only as a runtime query model. builder.ConfigureHub() is commented out
in CargonerdsDbContext, the Hub module ships no Migrations folder, and no
HubDbSchemaMigrator is registered — Cargonerds.DbMigrator migrates only the Default
database. The Hub schema (including SQL triggers and online/covering indexes that EF only
declares) is created and evolved outside EF Core. See
Migrations and Entity Framework.
The realtime Next.js frontend and the Blazor admin shell both render Hub data via the REST/OData
API. For running and deploying the system, see the deployment guide.
Domain model¶
Almost every Hub entity descends from HubBaseGuidEntity
(Hub.Domain/Base/HubBaseGuidEntity.cs), an ABP
AuditedAggregateRoot<Guid>
that carries the build-time code-generation attributes:
// Hub.Domain/Base/HubBaseGuidEntity.cs
[AutoGenerateDto(
AutoGenerateDerived = true,
BaseType = "Volo.Abp.Application.Dtos.IEntityDto<Guid>",
PropertiesToIgnore = [ /* ExtraProperties, ConcurrencyStamp, LastModifierId, CreatorId */ ],
GenerateMapping = false, …)]
[ProvideEdm(ProvideInherits = true)]
public class HubBaseGuidEntity : AuditedAggregateRoot<Guid>, IGuidEntity { … }
The inheritance spine:
classDiagram
AuditedAggregateRoot~Guid~ <|-- HubBaseGuidEntity
HubBaseGuidEntity <|-- CustomerOwnedGuidEntity
HubBaseGuidEntity <|-- CodeEntity
CustomerOwnedGuidEntity <|-- ShipmentBase
CustomerOwnedGuidEntity <|-- ExternalIdentifier
CustomerOwnedGuidEntity <|-- Transmission
CustomerOwnedGuidEntity <|-- Organization
CustomerOwnedGuidEntity <|-- Container
ShipmentBase <|-- Shipment
ShipmentBase <|-- Consol
CodeEntity <|-- ContainerType
CodeEntity <|-- Unit
class CustomerOwnedGuidEntity {
+Guid? CargonerdsCustomerId
}
class ShipmentBase {
+string Discriminator
}
A few structural facts to keep in mind (covered in depth on the entities, aggregate roots and value objects pages):
CustomerOwnedGuidEntityadds nullableGuid? CargonerdsCustomerId; this is the column the customer-isolation filter keys off.ShipmentBaseis the abstract TPH base forShipmentandConsol. It exposes the EF discriminator as astring Discriminator { get; private set; }property; the TPH discriminator column isHubDbContext.DiscriminatorColumnName = "Discriminator".CodeEntityis the base for all master/reference data ("code entities" such asContainerType,IncoTerm,TransportMode, units). It isIOptionallyCustomerOwned,IFreezableandIMasterData, supports a freeze protocol, and seeds itself from code viaISuperType<T>.Initialize+InitializeSuperType<T>.- Value objects are plain owned types, not ABP
ValueObjects.Coordinatesand the genericDimension<TUnit>(Dimension<WeightUnit>,Dimension<VolumeUnit>,Dimension<TemperatureUnit>, …) are mapped as EF owned/complex types and have no value-equality — see value objects. - Aggregates are anemic. Hub entities expose public setters throughout; mutation happens in the application layer, and no entity raises ABP domain events (the domain is an event subscriber only).
Capability is expressed through marker interfaces rather than base-class state:
ICustomerOwned (Guid? CargonerdsCustomerId), IOptionallyCustomerOwned,
IHasOrganization (Guid? OrganizationId), IUserOwned (Guid UserId), IFreezable,
IMasterData, ISearchable<T>, IExternallyIdentifiableEntity<T> and
IHubCacheableEntity / IHubCacheableEntityChild<T>.
SPARK integration data sources¶
The Hub database is co-owned with SPARK. Three entity families make the two-writer arrangement work.
External identifiers — one external key per (entity, system)¶
A shipment, organisation, order or container has different IDs in different external systems. The
solution is a polymorphic identifier table. ExternalIdentifier
(Hub.Domain/Base/ExternalIdentifier.cs) is abstract and holds the value plus which system it
belongs to:
public abstract class ExternalIdentifier : CustomerOwnedGuidEntity
{
public string Identifier { get; set; }
public ServiceType ServiceType { get; set; }
public bool IsActive { get; set; } = true;
public abstract void SetParentEntity(IGuidEntity parentEntity);
}
There are ~21 concrete subclasses (ExternalOrderIdentifier, ExternalOrganizationIdentifier,
ExternalContainerIdentifier, ExternalDocumentIdentifier, ExternalEventIdentifier,
ExternalPortIdentifier, the ExternalMagaya*Identifier set, etc.). Each binds a strongly-typed
parent navigation and implements SetParentEntity. Owning entities implement
IExternallyIdentifiableEntity<T> (a typed ICollection<T> ExternalIdentifiers) — for example
Organization : …, IExternallyIdentifiableEntity<ExternalOrganizationIdentifier>.
ServiceType (Hub.Domain/Enums/ServiceType.cs) names the external system with explicit integer
values so they are stable across the two processes:
[AutoGenerateDto(GenerateMapping = false)]
[StaticEnum]
public enum ServiceType
{
CW1 = 1, Magaya = 2, HapagLloyd = 4, Rohlig = 5,
Vizion = 6, Pricing = 7, UnitsNet = 8, Google = 9,
}
SparkEnvironment (Hub.Domain.Shared/Consts/SparkEnvironment.cs) is a name-keyed value object
identifying which SPARK deployment is the source: test, prod, prod-read-only, local, dev
(default dev).
All subclasses share one SQL table via EF table-per-hierarchy (TPH), and two custom conventions
make that automatic (both live in HubDbContextModelCreatingExtensions.cs):
OnModelCreatingcallsApplyConfigurationsFromTypeAssembly(GetType()).AddInheritedTypes().AddInheritedTypesreflects over every root entity type and registers all non-abstract subclasses, so EF picks up the whole hierarchy.ExternalIdentifierConfiguration : MultiTypeConfiguration<ExternalIdentifier>applies the same configuration to every subtype (defaultIsActive = true, a unique index on(Identifier, CargonerdsCustomerId)), withExternalCustomsDeclarationIdentifierexplicitly inExcludedTypes:
// Hub.EntityFrameworkCore/.../TypeConfigurations/Hub/ExternalIdentifierConfiguration.cs
protected override IEnumerable<Type> ExcludedTypes { get; } = [typeof(ExternalCustomsDeclarationIdentifier)];
protected override void ConfigureInternal<T>(EntityTypeBuilder<T> builder)
{
builder.Property(p => p.IsActive).HasDefaultValue(true);
builder.HasIndex(i => new { i.Identifier, i.CargonerdsCustomerId }).IsUnique();
}
GetByExternalIdentifier throws on IQueryable
The in-memory helpers in ExternalIdentifierExtensions (GetByExternalIdentifier,
GetExternalIdentifier(s), HasExternalIdentifier) deliberately throw if called on an
IQueryable — they would otherwise execute locally instead of in the database. Use a typed/DB
query (via IExternallyIdentifiableEntity<T>) on the query path.
Transmissions — an audit/provenance graph of SPARK processing¶
A Transmission (Hub.Domain/Base/Transmission.cs) is one import/export message processed by a
SPARK worker. The base is CustomerOwnedGuidEntity and models a self-referencing graph plus child
collections describing what the message did:
public abstract class Transmission : CustomerOwnedGuidEntity
{
public TransmissionDirection Direction { get; set; } // Export | Import | Internal
public TransmissionStatus Status { get; set; } // Pending | Completed | Failed | …
public string? DetailedStatus { get; set; }
public string? AppInsightsOperationId { get; set; }
public virtual Transmission? InitiatingTransmission { get; set; }
public virtual ICollection<Transmission> InitiatedTransmissions { get; set; } = [];
public virtual ICollection<DatabaseUpdate> DatabaseUpdates { get; set; } = [];
public virtual ICollection<ProcessedEntity> ProcessedEntities { get; set; } = [];
public virtual ICollection<DataExport> DataExports { get; set; } = [];
public virtual ICollection<EntityChange> EntityChanges { get; set; } = [];
public ProcessingServiceName? ProcessingServiceIdentifier { get; set; }
public bool Archived { get; set; }
}
Concrete subtypes are UserTransmission, EntityTransmission and BlobStorageTransmission.
ProcessingServiceName (Hub.Domain/Enums/ProcessingServiceName.cs) is the catalogue of SPARK
workers and reads like the product's integration surface — CW1.DocumentImport, CW1.InvoiceImport,
CW1.OrganizationImport, CW1.BookingExport, CW1.UniversalXmlImport, Magaya.ShipmentImport /
…Export, Magaya.DocumentSynchronization, ContainerTracking.HapagLloyd,
Event.HapagLloydPrediction, Hub.EventCreation, Insights.ExcelExport, etc.
ProcessingServiceName is persisted by its [Description], not its name
The members are PascalCase (CW1DocumentImport) but each carries a dotted
[Description("CW1.DocumentImport")]. TransmissionConfiguration persists
ProcessingServiceIdentifier with an EnumDescriptionConverter<ProcessingServiceName>, so the
string in the DB is the dotted description. Renaming a member is safe; changing its
[Description] is a breaking data change shared with SPARK.
Customer- and organisation-owned filtering¶
There are two independent visibility concerns, kept strictly separate.
flowchart TD
Q["Query over a Hub entity"] --> C{"CustomerOwnedFilter<br/>(IsAutoActive = true)"}
C -->|"folded into EF<br/>global query filter"| F["CargonerdsCustomerId ==<br/>appConfig.CargonerdsCustomerId"]
F --> O{"Org filter present?<br/>(IsAutoActive = false)"}
O -->|"only if code calls<br/>FilterAll() / FilteredSet()"| G["Addresses.Any(a =><br/>activeOrgIds.Contains(a.OrganizationId))"]
O -->|"path forgot FilterAll"| LEAK["⚠ rows leak across orgs"]
G --> R["Result"]
Mechanism 1 — customer isolation (automatic EF global query filter)¶
HubDbContext extends ABP's
data-filtering hooks rather
than replacing them. It collects every registered IQueryFilter whose IsAutoActive == true and
folds it into EF's global query filter for each entity:
// HubDbContext.cs
protected override bool ShouldFilterEntity<TEntity>(IMutableEntityType entityType) =>
GetFilterProviders<TEntity>().Any() || base.ShouldFilterEntity<TEntity>(entityType);
protected override Expression<Func<TEntity, bool>>? CreateFilterExpression<TEntity>(
ModelBuilder modelBuilder, EntityTypeBuilder<TEntity> entityTypeBuilder)
{
var expression = base.CreateFilterExpression(modelBuilder, entityTypeBuilder); // ABP's own filters
foreach (var provider in GetFilterProviders<TEntity>())
{
var typedLambda = provider.CreateFilterExpression().AdaptExpression<TEntity>();
expression = expression is null
? typedLambda
: QueryFilterExpressionHelper.CombineExpressions(expression, typedLambda);
}
return expression;
}
private IEnumerable<IQueryFilter> GetFilterProviders<TEntity>() =>
LazyServiceProvider.GetServices<IQueryFilter>()
.Where(p => p.IsAutoActive && p.EntityType.IsAssignableFrom(typeof(TEntity)));
The filter that matters here is CustomerOwnedFilter ([ExposeServices(typeof(IQueryFilter))],
QueryFilterBase<CustomerOwnedGuidEntity>, default IsAutoActive => true):
// Hub.EntityFrameworkCore/Filter/CustomerOwnedFilter.cs
public override Expression<Func<CustomerOwnedGuidEntity, bool>> CreateFilterExpression() =>
e => e.CargonerdsCustomerId == appConfig.CargonerdsCustomerId;
Because the match uses EntityType.IsAssignableFrom(typeof(TEntity)), this single filter applies to
every CustomerOwnedGuidEntity subtype. AdaptExpression<TEntity>
(Hub.EntityFrameworkCore/Helper/EntityFrameworkHelper.cs) is the glue: it rebinds a lambda written
against the base type onto each concrete entity's parameter so EF can attach it.
OptionallyCustomerOwnedFilter does the same for IOptionallyCustomerOwned but also allows
CargonerdsCustomerId == null (shared master data); SoftDeleteFilter is registered the same way.
On write, HubDbContext.HandlePropertiesBeforeSave:
- throws
InvalidOperationException("Customer owned entities can only be saved for the current customer")if any tracked customer-owned entity belongs to a different customer; - when
CargonerdsCustomerInjectionEnabled(defaulttrue), auto-stampsCargonerdsCustomerIdon newly added entities; - when
!AllowMasterDataCreation, also stamps optionally-owned entities; - freezes
IFreezableentities (unless insideAllowFreezableEntityModification()) and bumps the relevantVersionTrackers (see second-level cache).
Mechanism 2 — organisation visibility (manual, opt-in filters)¶
Organisation/org-unit filters are not auto-active. ShipmentBaseOrganizationQueryFilter and the
~13 sibling filters set IsAutoActive => false and read the ambient CurrentFilterContextProvider:
// Hub.Domain/Filters/ShipmentBaseOrganizationFilter.cs
public override bool IsAutoActive => false;
public override Expression<Func<T, bool>> CreateFilterExpression()
{
var ids = provider.ActiveOrgIds.ToArray();
return s => s.Addresses.Any(a => ids.Contains((Guid)a.OrganizationId));
}
They are applied explicitly via QueryableExtensions.FilterAll<T>(serviceProvider), which selects
non-auto-active filters matched by exact type (EntityType == typeof(T)):
// Hub.Domain/Extensions/QueryableExtensions.cs
public static IQueryable<T> FilterAll<T>(this IQueryable<T> queryable, IServiceProvider sp)
where T : class
{
var filters = sp.GetServices<IQueryFilter>()
.Where(f => !f.IsAutoActive && f.EntityType == typeof(T));
foreach (var f in filters)
queryable = queryable.Where(f.CreateFilterExpression());
return queryable;
}
HubEntityBaseService calls FilterAll(serviceProvider) on its list/query paths, the OData
controllers call it inside Get, and SearchProviderBase.BuildQueryContextAsync calls
(await repo.GetQueryableAsync()).FilterAll(sp). HubDbContext.FilteredSet<T>() is a convenience
wrapper (Set<T>().FilterAll(LazyServiceProvider)). The SQL-level variants in HubBaseRepository
serialise ActiveOrgIds to JSON and build OPENJSON-based EXISTS/INTERSECT clauses for
Dapper-backed queries.
The ambient context comes from CurrentFilterContextProvider
(Hub.Domain/Services/CurrentFilterContextProvider.cs), an ISingletonDependency wrapping an
AsyncLocal<IFilterContext?>:
public IFilterContext? CurrentFilterContext => _currentFilterContext.Value;
public HashSet<Guid> ActiveOrgIds => CurrentFilterContext?.OrganizationIds.ToHashSet() ?? [];
public HashSet<Guid> ActiveOrgUnitIds => CurrentFilterContext?.OrganizationUnitIds.ToHashSet() ?? [];
public virtual IDisposable Change(IFilterContext? filterContext)
{
var parent = CurrentFilterContext;
_currentFilterContext.Value = filterContext;
return new DisposeAction(() => _currentFilterContext.Value = parent);
}
Change(...) swaps "who is asking" for the current async flow and returns an IDisposable that
restores the parent — this is how the app sets the org scope per request and how background scopes
inherit it.
The two mechanisms look identical but aren't
IsAutoActive is the switch. true → folded into EF global query filters and applied to every
assignable subtype (IsAssignableFrom). false → applied only when code calls
FilterAll/FilteredSet, matched by exact type (EntityType == typeof(T), no inheritance).
Forgetting FilterAll on a query path silently leaks rows across organisations, and
FilteredSet<T>() applies only the manual org filters — customer isolation is the always-on EF
filter, so FilteredSet is not "fully filtered". The org-scoping model is documented further on
the OData & filtering page.
Two filter mechanisms coexist on purpose
A repository memory note ("FilterAll/IQueryFilter replaced by ABP Global Filter + IDataFilter")
describes the Cargonerds host side. Inside the Hub module both mechanisms are live:
auto-active IQueryFilters become real EF global filters; non-auto-active org filters are still
applied manually through FilterAll.
Global search¶
The ISearchable<> convention¶
Searchable entities use C# 11 static abstract interface members: each declares which string
columns participate in text search (Hub.Domain.Shared/Search/ISearchable.cs):
public interface ISearchable<TSelf> where TSelf : class
{
static abstract Expression<Func<TSelf, string?>>[] SearchProperties { get; }
static Expression<Func<TSelf, string?>>[] AllOfString(); // every string property
static Expression<Func<TSelf, string?>>[] AllWithAttribute<TAttr>(); // string props with [TAttr]
static Expression<Func<TSelf, string?>>[] Combine(params …[][] arrays);
}
Shipment, Container, Quotation and others implement it (Shipment : ShipmentBase,
ISearchable<Shipment>), declaring SearchProperties as an expression array.
Two-phase, parallel providers¶
A search provider implements IGlobalSearchProvider (ITransientDependency). The shared base
SearchProviderBase<TEntity, TDto> (Hub.Application/Search/SearchProviderBase.cs,
where TEntity : class, ISearchable<TEntity>, IEntity<Guid>) runs a two-phase strategy:
- Phase 1 — exact match on indexed identifier columns, streamed immediately via an
onItemFoundcallback. - Phase 2 — full-text/
LIKE(WhereFTSContains) for the canonical paged result and count.
Each provider enforces an ABP permission before running:
// SearchProviderBase.SearchAsync
if (RequiredPermission is not null)
{
var auth = sp.GetRequiredService<IAuthorizationService>();
if (!await auth.IsGrantedAsync(RequiredPermission))
return EmptyResult();
}
Concrete providers override the fast path. ShipmentSearchProvider (over CalculatedShipment)
hand-writes the exact match on the common identifier columns rather than letting the generic
WhereKeyMatches build a "27-property Union chain":
// Hub.Application/Search/ShipmentSearchProvider.cs
protected override string RequiredPermission => HubPermissions.Shipment.View;
protected override async Task<List<CalculatedShipment>> FindExactMatchesAsync(
IQueryable<CalculatedShipment> baseQuery, string searchTerm, int take, CancellationToken ct)
{
var query = baseQuery.Where(s =>
s.HouseBill == searchTerm || s.MasterBill == searchTerm
|| s.CargonerdsNumber == searchTerm || s.ExternalIdentifier == searchTerm
|| s.ContainerNumber == searchTerm || s.ConsolNumber == searchTerm
|| s.JobOrderReference == searchTerm);
if (Guid.TryParse(searchTerm, out var idValue))
query = query.Union(baseQuery.Where(s => s.Id == idValue));
return await query.Take(take).ToListAsync(ct);
}
The orchestrator (lives outside the module)¶
The orchestrator that implements the Hub-defined IGlobalSearchAppService is
Cargonerds.Services.GlobalSearchAppService in src/Cargonerds.Application — a deliberate
cross-module placement. It injects IEnumerable<IGlobalSearchProvider>, runs them in parallel,
each in its own BackgroundExecutionScope, and caches results through the distributed AppCache
(feature-gated by HubFeatures.Cache.Enabled):
// src/Cargonerds.Application/Services/GlobalSearchAppService.cs
public async Task<GlobalSearchResultDto> SearchAsync(GlobalSearchRequestDto input) =>
await appCache.GetOrAddAsync(
factory: (sp, token) => sp.GetRequiredService<GlobalSearchAppService>().ExecuteSearchAsync(input, token),
keyParts: new { input },
force: !await IsCacheEnabledAsync,
options: await BuildCacheOptionsAsync(),
ct: cancellationTokenProvider.Token);
SearchStreamAsync ([HttpGet]) fans provider items into an unbounded
Channel<SearchStreamEventDto> and writes them as they arrive, so fast providers appear before slow
ones finish. BackgroundExecutionScope (Hub.Application/BackgroundExecutionScope.cs) is the unit
that detaches a provider from the request — it creates a DI scope, optionally rehydrates the captured
HTTP context from an HttpRequestSnapshot, and begins a new, non-transactional unit of work:
var uowManager = _scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>();
_uow = uowManager.Begin(requiresNew: true, isTransactional: false);
SearchStreamAsync routing
SearchStreamAsync carries an explicit [HttpGet] attribute, so it is exposed as a GET (ABP's
auto-API conventions would
otherwise map a Search* method to POST). IGlobalSearchAppService is an ABP IApplicationService,
so it gets a generated controller and an
HttpApi.Client proxy.
Server-Sent Events (SSE) and realtime¶
The Hub uses two distinct realtime mechanisms.
1. SSE for search. SseJsonWriter<TEvent> (Hub.Application/Sse/SseJsonWriter.cs) configures the
response and — crucially — disables proxy buffering:
public void StartStream()
{
response.ContentType = "text/event-stream";
response.Headers["Cache-Control"] = "no-cache";
response.Headers["Connection"] = "keep-alive";
response.Headers["X-Accel-Buffering"] = "no"; // nginx/ingress buffers responses by default
response.HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
}
WriteAsync serialises one camel-cased JSON payload + \n + flush, and returns false (rather than
throwing) on client disconnect / broken pipe (OperationCanceledException, IOException,
ObjectDisposedException, InvalidOperationException) so the producer can stop instead of turning a
dropped client into a 500. SearchStreamEventDto.Type is "item" or "count". On the client side,
Hub.UI/Services/SseStreamReader.cs and GlobalSearchState consume the stream.
SSE only works if buffering is off end-to-end
The X-Accel-Buffering: no header and DisableBuffering() are load-bearing for any reverse
proxy. Removing them makes search streaming "stall on deployment" while still passing locally.
2. OData delta / ETag. HubHttpApiModule.OnApplicationInitialization calls
app.UseDelta<HubDbContext>() (the Delta NuGet). This middleware computes an ETag from the
DbContext and returns 304 Not Modified when nothing changed — a cheap freshness check for the OData
read surface that the SPA polls. See OData & filtering for the rest of
the OData/facet pipeline.
Code generation and source generators¶
Two independent generators run at build time. The development workflow for both is on the code-generation page.
StaticEnumGenerator (this repo's own Roslyn generator)¶
Hub.SourceGenerators/StaticEnumGenerator.cs is a [Generator(LanguageNames.CSharp)]
IIncrementalGenerator keyed on
ForAttributeWithMetadataName("Hub.Domain.Shared.Attributes.StaticEnumAttribute") (the attribute is
a marker, [AttributeUsage(AttributeTargets.Enum)]). For any enum tagged [StaticEnum] it emits
Static{EnumName}.g.cs containing an abstract Static{Enum} class, a nested sealed type per enum
member, an implicit conversion back to the enum, a Type → enum dictionary and ToEnum<T>(). The
effect is a type-level handle for each enum value, so a member can be used as a generic type
argument (e.g. StaticServiceType.CW1 in IMapableTo<TEntity, StaticServiceType.CW1>), not just a
runtime value.
StaticServiceType has no hand-written source
StaticServiceType is referenced everywhere (StaticServiceType.CW1,
StaticServiceType.ToEnum<T>()) but is generated from the [StaticEnum] ServiceType enum.
Grepping for its members in source will mislead.
The project targets netstandard2.0, is consumed by Hub.Domain via a project reference with
OutputItemType="Analyzer" ReferenceOutputAssembly="false":
<!-- Hub.Domain/Hub.Domain.csproj -->
<ProjectReference Include="..\Hub.SourceGenerators\Hub.SourceGenerators.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
Nextended.CodeGen (third-party DTO generator)¶
DTOs are generated by Nextended.CodeGen, driven by Hub.Domain/CodeGen.config.json (an
<AdditionalFiles> entry; the file is JSON-with-comments). The DtoGeneration block sets, among
others:
{
"DtoGeneration": {
"DeepProperties": true,
"OneFilePerClass": true,
"GeneratePartial": true,
"GenerateMapping": false,
"GenerateMappings": false,
"Namespace": "Hub.Application.Contracts.Shipments",
"Suffix": "Dto",
"OutputPath": "../Hub.Application.Contracts/Generated/",
"MappingOutputPath": "../Hub.Application/Extensions/Generated/"
}
}
Entities opt in via [AutoGenerateDto(...)]. Because HubBaseGuidEntity sets
AutoGenerateDerived = true, the whole entity tree gets DTOs written to
Hub.Application.Contracts/Generated/*Dto.g.cs, and [ProvideEdm] feeds the OData EDM model.
Mapping is Mapperly, not generated/AutoMapper
GenerateMapping/GenerateMappings are false because the app uses
Riok.Mapperly
(referenced in Hub.Domain.csproj). Entities call entity.MapTo<TDto>() (e.g. in
ShipmentSearchProvider.ToDto). Generated DTOs should not be edited by hand. See
ABP Patterns – Code generation.
Second-level cache and the VersionTracker bridge¶
The Hub DbContext is pooled and wrapped with an EF Core second-level cache, both configured
in HubEntityFrameworkCoreModule.ConfigureServices:
context.Services.AddEFSecondLevelCache(options =>
{
options.UseMemoryCacheProvider()
.ConfigureLogging(true)
.UseCacheKeyPrefix("EF_")
.UseDbCallsIfCachingProviderIsDown(TimeSpan.FromMinutes(1));
options.CacheAllQueries(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(1));
});
context.Services.AddDbContextPool<HubDbContext>((sp, opts) =>
{
var cs = sp.GetRequiredService<IConfiguration>().GetConnectionString(ConnectionStringNames.HubDb);
opts.UseSqlServer(cs, sql => { sql.EnableRetryOnFailure(); sql.CommandTimeout(180); });
opts.AddInterceptors(
sp.GetRequiredService<SecondLevelCacheInterceptor>(),
sp.GetRequiredService<CommandLoggingInterceptor>(),
sp.GetRequiredService<FacetJoinInterceptor>(),
sp.GetRequiredService<ArithAbortConnectionInterceptor>(),
sp.GetRequiredService<RecompileCommandInterceptor>());
}, poolSize: 256);
So: in-memory provider, key prefix EF_, all queries cached for an absolute 1 minute, a 1-minute
DB-fallback window if the cache is down, and a pool of 256. Repositories are registered with
AddDefaultRepositories<IHubDbContext>(includeAllEntities: true) plus custom overrides
(OrganizationRepository, MarginRepository, ShipmentBaseRepository,
CalculatedShipmentRepository) and a generic IHubRepository<,> → HubBaseRepository<,>. The full
interceptor stack (ArithAbortConnectionInterceptor, RecompileCommandInterceptor,
FacetJoinInterceptor, CommandLoggingInterceptor) is described on the
Entity Framework page.
Cache invalidation is cross-process. IHubCacheableEntity / IHubCacheableEntityChild<T>
(Hub.Domain.Shared/IHubCacheableEntity.cs) mark entities whose changes must invalidate the cache.
On SaveChanges, HubDbContext.UpdateVersionTrackers finds modified IHubCacheable entities, maps
them to their cache types, and bumps the matching VersionTracker.DateUpdatedUtc:
VersionTracker? existingVersionTracker = versionTrackers.FirstOrDefault(v => v.Type == entityType.Name);
if (existingVersionTracker is null)
throw new UnexpectedNullException(
$"Unable to find version tracker of type {entityType.Name}. Ensure that the hub has the correct corresponding entity first");
existingVersionTracker.DateUpdatedUtc = DateTimeOffset.UtcNow;
VersionTracker.Type is deliberately a string, not a System.Type:
// Hub.Domain/Entities/VersionTracker/VersionTracker.cs
/// It is so that when an IHubCacheableEntity is added on the Hub side Add/Update/Delete
/// operations in Spark don't fail due to missing Types.
public string Type { get; set; } = type;
This is the cross-process cache-coherence contract between the Hub and SPARK.
Cache gotchas
- Everything is cached for 60s by default (
CacheAllQueries). Reads can be up to ~1 minute stale; "why didn't my write show up" investigations and tests must account for this. It is the in-memory provider, so multiple Hub instances have independent caches. VersionTrackerrows must pre-exist. Adding a new cacheable entity requires seeding its tracker, orUpdateVersionTrackersthrowsUnexpectedNullException.- Do not "fix"
VersionTracker.TypetoSystem.Type— the string is intentional.
Separately, AppCache (Hub.Application/AppCache.cs, ITransientDependency) is a distributed
application cache over
IDistributedCache, unrelated to the EF
second-level cache. It uses a generation counter per tag (CacheGen:{tag}) for O(1) tag
invalidation, hashes keys with SHA-256, supports optional stale-while-revalidate behind a distributed
refresh lock, and reads its TTLs from
features (HubFeatures.Cache.*,
defaults 15 min absolute / 2 min refresh). The full dual-cache story is on the
caching page.
Localization and translations¶
Like the core, the Hub ships a Localization/Hub folder under Hub.Domain.Shared with a JSON file
per culture (HubResource). Add new keys to en.json and translate them with
Resourcetranslator.Cli, configured by translation.options.json:
resourcetranslator translate \
--options modules/hub/src/Hub.Domain.Shared/translation.options.json \
--input modules/hub/src/Hub.Domain.Shared/Localization/Hub/en.json \
--output modules/hub/src/Hub.Domain.Shared/Localization/Hub
ABP loads the resulting culture files automatically at runtime via its localization system. See also the UI localization page.
User interface¶
The Hub UI is the Hub.UI Razor/Blazor component library. It defines reusable components
(ShipmentCard, filter controls, maps, the GlobalSearchBar, export buttons, inbox), pages and menu
contributions, the SSE stream reader, and typed OData clients (HubApiClient,
IEntitySetClient<T>). It authenticates via the main AuthServer. Cargonerds.Blazor.Client
references Hub.UI, so these pages appear in the admin shell; the Next.js realtime frontend renders
the same Hub data via the REST/OData API. See Blazor UI and
realtime frontend.
Gotchas¶
| Area | Gotcha |
|---|---|
| Filtering | IsAutoActive distinguishes two mechanisms; the manual one matches by exact type. Forgetting FilterAll silently leaks cross-org rows; FilteredSet<T>() applies only the org filters, not customer isolation. |
| External identifiers | GetByExternalIdentifier throws on IQueryable by design — it is an in-memory FirstOrDefault. |
| SPARK | ProcessingServiceName persists by its [Description]; VersionTracker.Type is a string; VersionTracker rows must be seeded first. These are contracts shared with SPARK — do not change casually. |
| Cache | All Hub queries are cached for 60s (CacheAllQueries), in-process; expect stale reads. |
| Codegen | StaticServiceType and *Dto.g.cs are generated; they won't appear in a plain text search. Entities map via Mapperly (MapTo<TDto>()), not generated mappings. |
| SSE | X-Accel-Buffering: no + DisableBuffering() are load-bearing behind a reverse proxy. |
| DbContext | Both AddAbpDbContext<HubDbContext> and AddDbContextPool<HubDbContext> are registered, with interceptor wiring in two places (the pool options and OnConfiguring) — keep them in sync. Hub concurrency stamps are disabled (no-op overrides). |
| Migrations | The Hub schema is not EF-migrated — it is managed externally and EF only mirrors it. |
Related pages¶
- Module overview and module structure
- Pricing module — builds on Hub; pricing entities live in
Hub.Domain - Layered architecture · ABP patterns
- Entity Framework · Migrations · Caching
- OData & filtering · REST API
- Domain entities · Aggregate roots · Value objects · Repositories
- Code generation · Shipment service