Skip to content

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:

  1. 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*Identifier family, the Transmission provenance graph, ProcessingServiceName, SparkEnvironment and the VersionTracker cache contract all exist so the two can coexist without breaking each other.
  2. 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:

  • CargonerdsDomainModule declares [DependsOn(typeof(HubDomainModule), …)] (alongside PricingDomainModule).
  • Cargonerds.EntityFrameworkCore references Hub.EntityFrameworkCore.
  • Cargonerds.Blazor.Client references Hub.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):

  • CustomerOwnedGuidEntity adds nullable Guid? CargonerdsCustomerId; this is the column the customer-isolation filter keys off.
  • ShipmentBase is the abstract TPH base for Shipment and Consol. It exposes the EF discriminator as a string Discriminator { get; private set; } property; the TPH discriminator column is HubDbContext.DiscriminatorColumnName = "Discriminator".
  • CodeEntity is the base for all master/reference data ("code entities" such as ContainerType, IncoTerm, TransportMode, units). It is IOptionallyCustomerOwned, IFreezable and IMasterData, supports a freeze protocol, and seeds itself from code via ISuperType<T>.Initialize + InitializeSuperType<T>.
  • Value objects are plain owned types, not ABP ValueObjects. Coordinates and the generic Dimension<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):

  • OnModelCreating calls ApplyConfigurationsFromTypeAssembly(GetType()).AddInheritedTypes(). AddInheritedTypes reflects 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 (default IsActive = true, a unique index on (Identifier, CargonerdsCustomerId)), with ExternalCustomsDeclarationIdentifier explicitly in ExcludedTypes:
// 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 (default true), auto-stamps CargonerdsCustomerId on newly added entities;
  • when !AllowMasterDataCreation, also stamps optionally-owned entities;
  • freezes IFreezable entities (unless inside AllowFreezableEntityModification()) and bumps the relevant VersionTrackers (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.

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 onItemFound callback.
  • 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.
  • VersionTracker rows must pre-exist. Adding a new cacheable entity requires seeding its tracker, or UpdateVersionTrackers throws UnexpectedNullException.
  • Do not "fix" VersionTracker.Type to System.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.