Skip to content

ABP Framework Patterns

This page is a catalog of the ABP Framework patterns actually used in the Cargonerds solution. For each pattern it explains the concept, shows exactly where and how this codebase implements it (with real class names, file paths, config keys and code), then calls out the local gotchas. Each pattern links to the canonical ABP documentation.

Cargonerds is an ABP 10.x application on .NET 10, composed of three ABP module families that stack by dependency: the Cargonerds.* host shell (src/), the Hub.* business module (modules/hub/) and the thin Pricing.* module (modules/pricing/). Pricing depends on Hub; the Cargonerds shell depends on both plus the full ABP commercial suite. See Solution Structure and the Modules Overview for the big picture.

Where the real code lives

The src/Cargonerds.* shell holds only host glue — identity/user overrides, API keys, settings, and ABP module wiring — not the freight domain. Substantive modeling lives in Hub.Domain (Shipment, Consol, Container, Organization, Document, plus the pricing entities Quotation/Offer/Margin). The examples below use the real types wherever possible.

Pattern catalog at a glance

# Pattern ABP docs Primary implementation in this repo
1 Modularity & [DependsOn] link CargonerdsDomainModule, HubApplicationModule, all *Module.cs
2 Dependency injection link ITransientDependency/IScopedDependency/ISingletonDependency, .UseAutofac()
3 DDD building blocks link HubBaseGuidEntity, CustomerOwnedGuidEntity, CodeEntity
4 Entities & aggregate roots link AuditedAggregateRoot<Guid> spine; ApiKey (strict)
5 Value objects link Coordinates, Dimension<TUnit> (owned types, not ABP ValueObject)
6 Domain services link ApiKeyManager : DomainService
7 Repositories link IRepository<,>, IHubRepository<,>, AddDefaultRepositories
8 Application services & DTOs link HubAppService, HubEntityBaseService<...>
9 Object-to-object mapping link Mapperly DtoMapper (standard); AutoMapper (residual)
10 Authorization & permissions link *Permissions, *PermissionDefinitionProvider, [GrantInRoles]
11 Settings link HubSettingDefinitionProvider, CargonerdsSettingDefinitionProvider
12 Features link HubFeatures.Cache.* (drives AppCache TTLs)
13 Data filtering link custom IQueryFilter via ShouldFilterEntity/CreateFilterExpression
14 Local event bus link UserRegisteredEventHandler : ILocalEventHandler<...>
15 Distributed event bus link CommentStatusChangedEventHandler over RabbitMQ
16 Background jobs link AsyncBackgroundJob<TArgs> + Hangfire
17 Background workers link ExcelExportSchedulerWorker
18 Auto API controllers link ConventionalControllers.Create(...)
19 Dynamic C# client proxies link AddHttpClientProxies(...)
20 Code generation (not ABP — Nextended.CodeGen) [AutoGenerateDto], CodeGen.config.json

The deep dives that follow expand each row.


1. Modularity & [DependsOn]

An ABP module is a plain class deriving from Volo.Abp.Modularity.AbpModule carrying a [DependsOn(...)] attribute. ABP walks the dependency graph from a single root module, topologically sorts it, and runs each module's lifecycle hooks (PreConfigureServicesConfigureServicesPostConfigureServicesOnPreApplicationInitializationOnApplicationInitialization) in dependency order.

Each of the three families repeats the standard layered template:

DomainShared → Domain → EntityFrameworkCore
DomainShared → Application.Contracts → Application
Application.Contracts → HttpApi / HttpApi.Client → Blazor / hosts

The composition at the top (CargonerdsHttpApiHostModule is the API host's root):

graph TD
    Host["CargonerdsHttpApiHostModule (root)"] --> App[CargonerdsApplicationModule]
    Host --> Ef[CargonerdsEntityFrameworkCoreModule]
    Host --> Api[CargonerdsHttpApiModule]
    App --> PApp[PricingApplicationModule]
    App --> HApp[HubApplicationModule]
    PApp --> HApp
    Ef --> PEf[PricingEntityFrameworkCoreModule]
    Ef --> HEf[HubEntityFrameworkCoreModule]
    PEf --> HEf
    App -.-> AbpPro["ABP Pro suite (Identity, Saas, CmsKit Pro, Chat, ...)"]

CargonerdsDomainModule (src/Cargonerds.Domain/CargonerdsDomainModule.cs) is a representative real module — it depends on the two business modules plus ~20 ABP modules:

[DependsOn(
    typeof(PricingDomainModule),
    typeof(HubDomainModule),
    typeof(CargonerdsDomainSharedModule),
    typeof(AbpIdentityProDomainModule),
    typeof(AbpOpenIddictProDomainModule),
    typeof(SaasDomainModule),
    typeof(CmsKitProDomainModule),
    // ... AuditLogging, BackgroundJobs, FeatureManagement, PermissionManagement,
    //     SettingManagement, Emailing, TextTemplateManagement, LanguageManagement,
    //     FileManagement, Gdpr, LeptonXThemeManagement, BlobStoringDatabase
)]
public class CargonerdsDomainModule : AbpModule { /* ... */ }

The dominant configuration mechanism is the options patternConfigure<TOptions> (and PreConfigure<TOptions> for things ABP reads at config time). Real example from the same module enabling/disabling multi-tenancy and dynamically registering CmsKit comment entity types by reflecting over ShipmentBase subclasses:

public override void ConfigureServices(ServiceConfigurationContext context)
{
    Configure<AbpMultiTenancyOptions>(options => options.IsEnabled = MultiTenancyConsts.IsEnabled);

    Configure<CmsKitCommentOptions>(options =>
    {
        var derivedTypes = typeof(ShipmentBase).Assembly.GetTypes()
            .Where(t => t.IsClass && !t.IsAbstract && t.IsSubclassOf(typeof(ShipmentBase)));
        options.EntityTypes.AddRange(derivedTypes.Select(t => new CommentEntityTypeDefinition(t.Name)));
        options.EntityTypes.Add(new CommentEntityTypeDefinition(PricingConsts.QuotationRequest));
    });
}

Each runnable host calls AddApplicationAsync<TRootModule>() in its Program.cs. There are six root modules:

Host project Root module
Cargonerds.HttpApi.Host CargonerdsHttpApiHostModule
Cargonerds.AuthServer CargonerdsAuthServerModule
Cargonerds.Blazor CargonerdsBlazorModule
Cargonerds.Blazor.Client CargonerdsBlazorClientModule
Cargonerds.Web.Public CargonerdsWebPublicModule
Cargonerds.DbMigrator CargonerdsDbMigratorModule

AppHost is not an ABP module

Cargonerds.AppHost/Program.cs is the .NET Aspire orchestrator. It does not call AddApplicationAsync and is not an AbpModule. Do not treat it as part of the module graph. See Aspire Integration.

abpSrc/ is vendored framework source

A naive **/*Module.cs search returns hundreds of Volo.* modules from the vendored ABP/LeptonX source under abpSrc/. Only the Cargonerds.*, Hub.* and Pricing.* modules are application code.

More on the module graph: Module Structure and Layered Architecture.


2. Dependency injection

ABP layers conventional registration on top of Microsoft.Extensions.DependencyInjection. Classes are auto-registered by naming convention (*AppService, *Repository, *Manager, *Controller, …) and by marker interfaces for lifetime: ITransientDependency, IScopedDependency, ISingletonDependency. All server hosts call .UseAutofac() so ABP's property injection and interception are available; the WASM client uses AbpAutofacWebAssemblyModule.

Marker interfaces are everywhere in this repo, e.g.:

  • CommentStatusChangedEventHandlerITransientDependency (Pricing event handler).
  • CurrentFilterContextProviderISingletonDependency wrapping an AsyncLocal<IFilterContext?>.
  • QueryFilterBase<T>IScopedDependency.

Beyond plain registration, the codebase uses the full ABP DI toolbox:

  • Service replacement[Dependency(ReplaceServices = true)] + [ExposeServices(...)]. IdentityUserAppService replaces the ABP identity service; WhiteLabelingTemplateRenderer replaces ABP's ITemplateRenderer:

    [ExposeServices(typeof(ITemplateRenderer), IncludeSelf = true)]
    [Dependency(ReplaceServices = true)]
    public class WhiteLabelingTemplateRenderer(...) : ITemplateRenderer { }
    
  • Decoration (Scrutor) — context.Services.Decorate<IPermissionAppService, ApiKeyPermissionAppService>() in CargonerdsApplicationModule, so API-key principals get correct permission listings.

  • Keyed services[ExposeKeyedService<IStorageService>(BlobStorageContainer.Document)] on DocumentStorageService, consumed with [FromKeyedServices(...)]. See Messaging & Storage.
  • Replacing entries in ABP option lists by indexCargonerdsDomainModule.PostConfigureServices swaps IdentitySessionDynamicClaimsPrincipalContributor for a custom subclass so stateless API-key requests (no SessionId claim) keep their principal:

    Configure<AbpClaimsPrincipalFactoryOptions>(options =>
    {
        var index = options.DynamicContributors.IndexOf(typeof(IdentitySessionDynamicClaimsPrincipalContributor));
        if (index >= 0)
            options.DynamicContributors[index] = typeof(ApiKeyAwareIdentitySessionDynamicClaimsContributor);
    });
    

Mutating ABP option lists by index is fragile

The contributor swap above and AbpPermissionOptions.ValueProviders.AddFirst(...) depend on ABP's default registration order. They are guarded (if (index >= 0)) but can silently no-op if a future ABP version reorders defaults.


3. DDD building blocks

ABP organizes a domain into entities, aggregate roots, value objects, domain services, domain events and repository interfaces. The Cargonerds domain follows the shape of this layering but makes several deliberate non-standard choices (documented under each pattern). See the Domain-Driven Design overview.

The Hub inheritance spine:

graph TD
    AAR["AuditedAggregateRoot&lt;Guid&gt;"] --> HBGE[HubBaseGuidEntity]
    HBGE --> COGE[CustomerOwnedGuidEntity]
    HBGE --> CE[CodeEntity]
    COGE --> SB[ShipmentBase]
    SB --> Shipment
    SB --> Consol
    COGE --> Organization
    COGE --> Container
    COGE --> Quotation
    CE --> ContainerType
    CE --> Unit
  • HubBaseGuidEntity (modules/hub/src/Hub.Domain/Base/HubBaseGuidEntity.cs) — the root of nearly every Hub entity, an AuditedAggregateRoot<Guid> carrying the [AutoGenerateDto(...)] and [ProvideEdm(...)] attributes that drive code generation (see pattern 20).
  • CustomerOwnedGuidEntity adds the nullable Guid? CargonerdsCustomerId used for multi-customer scoping (see pattern 13).
  • CodeEntity is the base for all master/reference data ("code entities") with a freeze protocol and static-enum seeding. See Value Objects and Entities.

4. Entities & aggregate roots

ABP supplies AggregateRoot<TKey>, AuditedAggregateRoot<TKey> and FullAuditedAggregateRoot<TKey> (the last adding soft-delete). Auditing is automatic: CreationTime/CreatorId on insert, LastModificationTime/LastModifierId on update, and IsDeleted/DeletionTime/DeleterId for full auditing.

In this codebase:

  • The Hub uses AuditedAggregateRoot<Guid> as its baseline (via HubBaseGuidEntity).
  • Quotation additionally implements ISoftDelete.
  • OrganizationUnitOwnerUser is a plain ABP Entity with a composite key (GetKeys()).
  • The host ApiKey aggregate (src/Cargonerds.Domain/ApiKeys/ApiKey.cs) is the one entity that follows strict DDD: protected internal set properties guarded with Check.NotNullOrWhiteSpace, a protected EF constructor and a public constructor taking all required values.

Hub aggregates are anemic with public setters

Most Hub entities expose { get; set; } on virtually all properties (state included), and invariants are enforced in application services, not at the entity boundary. ShipmentBase even exposes the EF TPH discriminator as a property (string Discriminator { get; private set; }). CargonerdsCustomerId is public despite [EditorBrowsable(Never)] — the comment in CustomerOwnedGuidEntity explains it must be public "because otherwise the serializer ignores it".

No domain events are raised from entities

A repo-wide search for AddLocalEvent / AddDistributedEvent finds nothing in any Domain project. Aggregates never raise events; the domain only handles events published from the application layer (see patterns 14–15).

Details: Aggregate Roots, Entities.


5. Value objects

ABP offers a ValueObject base (with GetAtomicValues() for value equality). Cargonerds does not use it. Instead, value-object-shaped types are plain classes mapped as EF owned/complex types:

  • Coordinates (modules/hub/src/Hub.Domain/Coordinates.cs) — decimal Latitude/Longitude, used e.g. on Address.Coordinates.
  • Dimension<TUnit> (modules/hub/src/Hub.Domain/Base/Dimension.cs) — generic over Unit so weight/volume/temperature reuse one type with unit type-safety (Dimension<WeightUnit> vs Dimension<VolumeUnit>). Because Unit is itself a CodeEntity (a DB row), a Dimension references a persisted unit, not an inline string.

The EF owned-type registration lives in HubDbContext.OnModelCreating:

modelBuilder.Owned<Dimension<TemperatureUnit>>();
modelBuilder.Owned<Dimension<WeightUnit>>();
modelBuilder.Owned<Dimension<VolumeUnit>>();
modelBuilder.Owned<Dimension<LengthUnit>>();
modelBuilder.Owned<Coordinates>();

These are not equality-comparable value objects

Coordinates / Dimension<TUnit> have no GetAtomicValues() and therefore no value equality. Treat them as data holders. Full discussion in Value Objects.


6. Domain services

A domain service holds business logic that does not fit on a single entity. ABP's DomainService base gives inherited helpers (GuidGenerator, CurrentTenant, Logger, …).

The only true DomainService in the codebase is ApiKeyManager (src/Cargonerds.Domain/ApiKeys/ApiKeyManager.cs) — it hashes/verifies keys and acts as the factory for the ApiKey aggregate:

public class ApiKeyManager(IPasswordHasher<object> passwordHasher, IOptions<ApiKeyCreateOption> createOptions)
    : DomainService
{
    public ApiKey Create(string key, string prefix, Guid userId, string name, /* ... */)
    {
        var hashedKey = Hash(key);
        return new ApiKey(GuidGenerator.Create(), userId, prefix, name, /* ... */, CurrentTenant.Id);
    }
}

Hub 'services' are not domain services

Hub's Services/ folder (CurrentFilterContextProvider, ParallelChunkProcessor, WhiteLabelingTemplateRenderer) holds infrastructure-flavored helpers registered via DI lifetime interfaces, not DomainService subclasses. See Domain Services.


7. Repositories

ABP generates a default repository for every aggregate root, exposing CRUD + IQueryable and integrating with the Unit of Work. Custom repositories are interfaces in the Domain layer with EF Core implementations in the EntityFrameworkCore layer.

Registration happens per DbContext in the EF Core module. The Hub registers default repos for all entities, sets default Include graphs for WithDetails, and adds four custom repos (modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/HubEntityFrameworkCoreModule.cs):

context.Services.AddAbpDbContext<HubDbContext>(options =>
{
    options.AddDefaultRepositories<IHubDbContext>(includeAllEntities: true);

    options.Entity<Shipment>(opt => opt.DefaultWithDetailsFunc = e => e.IncludeDefaultDetails());
    options.Entity<Quotation>(opt => opt.DefaultWithDetailsFunc = e => e.IncludeDefaultDetails());
    // ... Consol, CustomsDeclaration, Order, Address, Tag, Offer

    options.AddRepository<Organization, OrganizationRepository>();
    options.AddRepository<Margin, MarginRepository>();
    options.AddRepository<ShipmentBase, ShipmentBaseRepository>();
    options.AddRepository<CalculatedShipment, CalculatedShipmentRepository>();
});

includeAllEntities: true generates repos for non-aggregate entities too. DefaultWithDetailsFunc is the include graph applied by repository.WithDetailsAsync().

Custom repository interfaces extend IRepository<,>. IHubRepository<TEntity, TKey> (modules/hub/src/Hub.Domain/Repositories/IHubRepository.cs) adds audit-history queries and a graph add/attach:

public interface IHubRepository<TEntity, TKey> : IRepository<TEntity, TKey>
    where TEntity : class, IEntity<TKey>
{
    Task<(int TotalCount, IReadOnlyList<EntityPropertyChange> Query)> GetPropertyChanges(/* ... */);
    Task AddOrAttach(HubBaseGuidEntity entity, CancellationToken ct = default);
}

Specialized interfaces (IShipmentBaseRepository, IOrganizationRepository) add ...WithDetailsAsync loaders. The host registers EfCoreApiKeyRepository and replaces IIdentityUserRepository with CargonerdsIdentityUserRepository (to apply a country filter). Implementations derive from HubBaseRepository<TDbContext, TEntity, TId> : EfCoreRepository<...>.

The Unit of Work is implicit: ABP opens a UoW per web request / app-service call and commits it on success, rolls back on exception. Mark a method [UnitOfWork] to wrap several writes in one transaction; jobs/workers begin one manually via IUnitOfWorkManager.Begin(...).

Full persistence detail lives in Entity Framework and Repositories.


8. Application services & DTOs

ABP application services derive from ApplicationService, return DTOs (never entities), and get auto-validation, auditing, UoW and (via auto-API) REST endpoints for free. ABP also provides CrudAppService/ReadOnlyAppService base classes.

The codebase has two thin per-module base classes — CargonerdsAppService and HubAppService (modules/hub/src/Hub.Application/HubAppService.cs, setting LocalizationResource = typeof(HubResource) and ObjectMapperContext = typeof(HubApplicationModule)).

The real read/list engine, however, is a bespoke base, not ABP CrudAppService: HubEntityBaseService<TEntity, TDto, TListParamDto, TService> (modules/hub/src/Hub.Application/Services/HubEntityBaseService.cs). It implements IReadOnlyAppService<TDto, Guid, TListParamDto> and bundles caching, OData facet filtering, org-unit filtering and permission checks. The public GetAsync(string id) shows the shape:

[ExposeServices(IncludeSelf = true)]
public abstract class HubEntityBaseService<TEntity, TDto, TListParamDto, TService>(...)
    : HubAppService, IReadOnlyAppService<TDto, Guid, TListParamDto>
{
    protected virtual string ReadPermission => string.Empty;
    protected abstract TDto? ToDto(TEntity? entity);

    public virtual async Task<TDto?> GetAsync(string id)
    {
        await CheckPermissionAsync(ReadPermission);                 // imperative auth
        return await Cache.GetOrAddAsync(                           // 2nd-level app cache (AppCache)
            factory: async (sp, token) =>
            {
                var service = sp.GetRequiredService<TService>();    // self, for cache re-entrancy
                return service.ToDto(await service.FindByKeyAsync(id, token));
            },
            keyParts: new { id },
            force: !await IsCacheEnabledAsync,                      // HubFeatures.Cache.Enabled
            options: await BuildCacheOptionsAsync(),                // TTLs from features
            ct: CancellationToken);
    }
}

Concrete services override ReadPermission (e.g. ShipmentAppService.ReadPermission => HubPermissions.Shipment.View) and ToDto (delegating to the Mapperly mapper). The list path runs cache → org filtering (FilterAll) → OData ApplyAllQueryApplier → facet building, returning a PagedResultDtoWithFilters<TDto>.

DTO/contract conventions (standard ABP, in the *.Application.Contracts projects): PagedResultDto<T>, PagedAndSortedResultRequestDto, ICrudAppService<...> interfaces. Custom request DTOs extend the ABP paging DTOs (FilterablePagedAndSortedResultRequestDto, PagedAndSortedResultRequestWithFacetOptionsDto, ShipmentPageRequestDto); the result envelope PagedResultDtoWithFilters<T> adds facet/OData metadata.

HubEntityBaseService is not CrudAppService

Reads/lists go through the bespoke pipeline above, not ABP's CRUD base. GetAsync(Guid) and the single-arg GetListAsync(input) are [RemoteService(IsEnabled = false)]; the live endpoints are the string-keyed GetAsync(string id) and the 2-arg GetListAsync(input, force). Because results are cached (tag = typeof(TService).Name), writes must call InvalidateCacheAsync / WithCacheInvalidationAsync or stale data is served.

FluentValidation, not just DataAnnotations

The contracts modules depend on AbpFluentValidationModule, so validators in *.Application.Contracts/Validators/ (e.g. CreateUpdateBookingValidator, CreateQuotationValidator) are auto-applied to incoming DTOs in addition to standard [Required]/[StringLength] annotations.

More on the CRUD pattern, facets and caching: REST API, OData Filtering, Caching.


9. Object-to-object mapping

ABP exposes mapping through ObjectMapper.Map<TSource, TDestination>(...) and integrates both AutoMapper and (newer) Mapperly.

In Cargonerds, Mapperly is the real engine and AutoMapper is residual:

  • Mapperly (Riok.Mapperly, compile-time, source-generated) — the flagship is DtoMapper (modules/hub/src/Hub.Application/Services/DtoMapper.cs), a static partial class annotated [Mapper(UseReferenceHandling = true)]. It maps Shipment/Consol/Order/Document/Container/Quotation/Margin/… with [MapDerivedType<TFrom, TTo>] for polymorphic TPH hierarchies and [MappingTarget] ApplyUpdate(...) for in-place updates. Pricing composes it via [UseStaticMapper(typeof(DtoMapper))] in QuoteMapper.
  • AutoMapper — just one CreateMap call left, for an ABP type that already ships a DTO (IdentityUser → IdentityUserDto in HubApplicationAutoMapperProfile). Still wired up (Configure<AbpAutoMapperOptions>(o => o.AddMaps<CargonerdsApplicationModule>())) so ObjectMapper works for it.

Don't assume ObjectMapper.Map<> works for domain types

Most domain↔DTO conversions are Mapperly extension methods (entity.ToDto(), dto.ToNet()), not ObjectMapper. Mapperly reference handling is customized (a SuperTypeResolutionReferenceHandler dedups shared "super type" code entities during graph mapping); passing the wrong/null handler changes graph dedup, and [MapDerivedType<>] lists must stay in sync with the entity hierarchy or polymorphic mapping throws at runtime.


10. Authorization & permissions

ABP authorization is permission-based. Permission names are const string trees in *Permissions classes; a PermissionDefinitionProvider registers them into groups with localized display names; services enforce them with [Authorize(...)] or imperative IAuthorizationService checks.

Three permission trees + providers exist (CargonerdsPermissions, HubPermissions, PricingPermissions). The host provider also augments other modules' groupsCargonerdsPermissionDefinitionProvider pulls its permissions into the Identity and CmsKit groups:

var identityGroup = context.GetGroup(IdentityPermissions.GroupName);
var userGroup = identityGroup.GetPermissionOrNull(IdentityPermissions.Users.Default);
if (userGroup != null)
    userGroup.AddChild(CargonerdsPermissions.Users.SendPasswordMail, L("Permission:Users:SendPasswordMail"));

var commentGroup = context.GetGroup(CmsKitPermissions.GroupName);
commentGroup.AddPermission(CargonerdsPermissions.Comments.UpdateStatus);

Both authorization styles are used: class/method-level [Authorize(...)] (e.g. CodeEntityBaseService, ExcelExportAppService, Pricing QuotationAppService) and imperative CheckPermissionAsync / AuthorizationService.IsGrantedAnyAsync inside HubEntityBaseService (used for permission-gated Includes — e.g. documents are only joined if HubPermissions.Document.View is granted).

[GrantInRoles] — a Cargonerds convention

On top of standard ABP permissions, this codebase uses a custom [GrantInRoles] attribute (Hub.Attributes.GrantInRolesAttribute) on permission constants to declare which seeded roles receive a permission by default:

public static class Quotation
{
    [GrantInRoles(RoleConsts.DefaultCustomer)]
    public const string View = QuotationGroup + ".View";

    [GrantInRoles(RoleConsts.AccountOwner)]
    public const string Edit = QuotationGroup + ".Edit";

    [GrantInRoles(RoleConsts.DefaultCustomer, Inherit = false)]
    public const string QuoteRequest = QuotationGroup + ".QuoteRequest";
}

The grant logic lives in the Domain layer, not the contracts: RolesDataSeedContributor (src/Cargonerds.Domain/RolesDataSeedContributor.cs) reflects over the three permission classes, reads each [GrantInRoles], and calls permissionManager.SetForRoleAsync(role, permission, true) at seed time. Inherit = true (the default) walks up RoleConsts.Hierarchy (DefaultCustomer → Operator → AccountOwner → LocalRealtimeAdmin → SuperUser) so a permission granted to a lower role is granted to every role above it; Inherit = false pins it to exactly the listed role(s).

A const without [GrantInRoles] is granted to no role

Adding a permission constant without the attribute means no role gets it at seed time. And Inherit = true silently grants it to every higher role in the hierarchy. Definition (in *.Application.Contracts) and grant logic (in Cargonerds.Domain) live in different places — keep both in mind.

Cross-module group editing can throw at startup

CargonerdsPermissionDefinitionProvider guards Users/OrgUnits with GetPermissionOrNull(...) != null, but calls context.GetGroup(CmsKitPermissions.GroupName) directly for the comment group — a missing CmsKit module would throw at startup.

See REST API and the Features section for the feature side.


11. Settings

ABP settings are named, typed configuration values with defaults, optional encryption and an isVisibleToClients flag. A SettingDefinitionProvider declares them; code reads them via ISettingProvider.

Real providers:

  • HubSettingDefinitionProvider (modules/hub/src/Hub.Domain/Settings/) registers the custom-script feature with isVisibleToClients: true:

    public override void Define(ISettingDefinitionContext context)
    {
        context.Add(new SettingDefinition(HubSettings.CustomScript.Enabled,
            defaultValue: "false", isVisibleToClients: true));
        context.Add(new SettingDefinition(HubSettings.CustomScript.Value,
            defaultValue: DefaultCustomScriptValue, isVisibleToClients: true)); // GTM <script> block
    }
    
  • CargonerdsSettingDefinitionProvider registers Cargonerds.EnableTwoStepRegistration and Cargonerds.RequireConfirmPassword (both client-visible).

Setting keys are grouped const classes (HubSettings, CargonerdsSettings).

Settings vs Features

Pricing's PricingSettingDefinitionProvider is a stub (defines nothing). And the runtime knobs for caching are not settings — they are features (next section). Knowing which is which matters when changing behavior.


12. Features

ABP features gate functionality per tenant/edition and can carry values. They are read with IFeatureChecker (IsEnabledAsync, GetOrNullAsync).

The Hub second-level application cache (AppCache) is feature-driven via HubFeatures.Cache.* (modules/hub/src/Hub.Domain.Shared/Features/HubFeatures.cs). HubEntityBaseService reads them to build cache options at runtime:

AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(
    Convert.ToInt32(await FeatureChecker.GetOrNullAsync(
        HubFeatures.Cache.AbsoluteExpirationRelativeToNowInMinutes) ?? "15")),
RefreshInBackground = await FeatureChecker.IsEnabledAsync(HubFeatures.Cache.RefreshInBackground),
RefreshAfter = TimeSpan.FromMinutes(
    Convert.ToInt32(await FeatureChecker.GetOrNullAsync(HubFeatures.Cache.RefreshAfterInMinutes) ?? "2")),

Other feature groups include HubFeatures.DetailedEmails.* and HubFeatures.SourceUrlSubjectPrefix.*. The frontend uses features to show/hide functionality.

Feature value validators are pre-configured

CargonerdsDomainSharedModule.PreConfigureServices registers a custom OptionalEmailAddressStringValueValidator via PreConfigure<ValueValidatorFactoryOptions> — it must be in PreConfigureServices because ABP's feature-management JSON converters read these actions at config time (needed both server-side and in the WASM client).


13. Data filtering

ABP ships global data filters (ISoftDelete, IMultiTenant) toggled at runtime through IDataFilter. Cargonerds uses these for soft-delete, but its multi-customer / organization scoping is a custom mechanism plugged into ABP's query-filter extension points — it does not use IDataFilter for org scoping.

Multi-tenancy is disabled

MultiTenancyConsts.IsEnabled = false. SaaS is still installed and CargonerdsDbContext replaces ISaasDbContext, but the tenant filter is effectively inert. Customer scoping below is the live row-level isolation. See Multi-tenancy.

The custom system is IQueryFilter / QueryFilterBase<T> (modules/hub/src/Hub.Domain/Filters/) with two application paths:

  1. Auto-applied global query filters. HubDbContext overrides ABP's ShouldFilterEntity<T> and CreateFilterExpression<T>: it starts from ABP's base expression (soft-delete etc.), then for every registered provider with IsAutoActive == true adapts the lambda and combines it into a real EF Core global query filter. SoftDeleteFilter, CustomerOwnedFilter and OptionallyCustomerOwnedFilter are registered this way via [ExposeServices(typeof(IQueryFilter))].
  2. Manually-applied filters via IQueryable<T>.FilterAll(serviceProvider) (Hub.Domain QueryableExtensions), which pulls providers where !IsAutoActive and chains .Where(...). HubDbContext.FilteredSet<T>() = Set<T>().FilterAll(...), used widely in app services, OData, Excel export and search.

The org context comes from CurrentFilterContextProvider (ISingletonDependency, AsyncLocal<IFilterContext?>), exposing ActiveOrgIds, ActiveOrgUnitIds, DefaultOrgUnitId, UserId. Org filters such as ShipmentBaseOrganizationFilter read it to build expressions.

Two filter paths — easy to mix up

Auto-active filters are always applied (bypass with IgnoreQueryFilters). Non-auto filters only apply when you call .FilterAll() / FilteredSet<T>() — forgetting it silently leaks cross-org data. The manual path matches EntityType == typeof(T) exactly (no inheritance), unlike the auto path's IsAssignableFrom.

Stale MEMORY note

A memory note claims org filtering was replaced by ABP Global Filter + IDataFilter. The current code still uses both FilterAll and the custom IQueryFilter provider mechanism; the "global filter" here is the custom IQueryFilter integrated through ABP's ShouldFilterEntity/CreateFilterExpression, not IDataFilter.

Full treatment: OData Filtering and Entity Framework.


14. Local event bus

ABP's local (in-process) event bus publishes events within the same process — notably the automatic entity-change events (EntityCreatedEventData<T>, etc.). Handlers implement ILocalEventHandler<T> and self-register via ITransientDependency.

UserRegisteredEventHandler (src/Cargonerds.Domain/EventHandler/UserRegisteredEventHandler.cs) listens for new identity users and adds them to the default org unit:

public class UserRegisteredEventHandler
    : ILocalEventHandler<EntityCreatedEventData<IdentityUser>>, ITransientDependency
{
    public async Task HandleEventAsync(EntityCreatedEventData<IdentityUser> eventData) { /* ... */ }
}

15. Distributed event bus

The distributed event bus carries integration events across processes/services. Here it is backed by RabbitMQ (AbpEventBusRabbitMqModule, configured in the API host). Publish with IDistributedEventBus.PublishAsync; handle with IDistributedEventHandler<TEto>. The transport object (ETO) is a plain class.

Real cross-module flow:

  • PublishCommentPublicAppService (host) calls IDistributedEventBus.PublishAsync(new CommentExtraPropertiesUpdatedEto { ... }) when a comment's status/reference changes. The ETO lives in Cargonerds.Domain.Shared so contracts/clients can reference it.
  • HandleCommentStatusChangedEventHandler in Pricing consumes it and recomputes Quotation.Status:

    public class CommentStatusChangedEventHandler(
        ICommentRepository commentRepository,
        IHubRepository<Hub.Entities.Pricing.Quotation, Guid> quotationRepository
    ) : IDistributedEventHandler<CommentExtraPropertiesUpdatedEto>, ITransientDependency
    {
        public async Task HandleEventAsync(CommentExtraPropertiesUpdatedEto eventData) { /* ... */ }
    }
    

A separate Azure Service Bus consumer is NOT the ABP event bus

ServiceBusNotificationConsumer is a hand-rolled Azure.Messaging.ServiceBus BackgroundService (despite topic abp-notifications / DTO ServiceBusAbpNotificationEto). It does not use the ABP event bus or RabbitMQ, and no-ops locally when hubServiceBus is empty. See Messaging.


16. Background jobs

ABP background jobs queue serializable work for reliable, retried execution. A job derives from AsyncBackgroundJob<TArgs>; you enqueue with IBackgroundJobManager.EnqueueAsync(args). Here the backing store is Hangfire on SQL Server (AbpBackgroundJobsHangfireModule).

Configured in HubApplicationModule (ConfigureHangfire): SQL Server storage on the Default connection (not Hub), a Newtonsoft serializer with PreserveReferencesHandling.Objects, and two named queues (HangfireQueues: email_notification_queue, quote_generation_queue). Jobs route to queues with Hangfire's [Queue(...)] attribute:

  • EmailNotificationJobBase<TNotificationDto> ([Queue(EmailNotificationQueue)]) — base for ShipmentEmailJob, QuotationEmailJob, etc.; renders ABP text templates and emailSender.QueueAsync.
  • QuoteGenerationJob ([Queue(QuoteGenerationQueue)]).
  • ExcelExportDispatcherJob : AsyncBackgroundJob<ExcelExportJobArgs>, registered with Configure<AbpBackgroundJobOptions>(o => o.AddJob<ExcelExportDispatcherJob>()).

Details and the worker/queue topology: Background Jobs.


17. Background workers

ABP background workers run periodic, timer-based work (in-process), distinct from jobs. Register with context.AddBackgroundWorkerAsync<T>() in OnApplicationInitialization.

ExcelExportSchedulerWorker : AsyncPeriodicBackgroundWorkerBase (modules/hub/src/Hub.Application/Services/Excel/) runs every 10 minutes, scans SparkExcelExportMetadata for due recurring schedules, and enqueues ExcelExportJobArgs (i.e. a worker that feeds the job queue). It is registered in HubApplicationModule.OnApplicationInitialization.

HubApplicationModule.OnApplicationInitialization is async void

It awaits AddBackgroundWorkerAsync but is async void, so exceptions there are not observed by the host. Properly-awaited startup work (super-type seeding) is in OnPreApplicationInitializationAsync instead.


18. Auto API controllers

ABP can generate REST controllers directly from application-service classes (the auto/conventional controller system) — method-name prefixes map to HTTP verbs and route conventions.

This solution opts in explicitly, registering the three application assemblies in the API host (src/Cargonerds.HttpApi.Host/CargonerdsHttpApiHostModule.cs):

options.ConventionalControllers.Create(typeof(CargonerdsApplicationModule).Assembly);
options.ConventionalControllers.Create(typeof(HubApplicationModule).Assembly);
options.ConventionalControllers.Create(typeof(PricingApplicationModule).Assembly);

Auto-API conventions in effect here

  • Enums serialize as integers over HTTP by default (even though the Hub DB stores them as strings — a different layer).
  • A single leading Guid parameter becomes a path parameter; two simple Guids become query parameters.
  • Verb mapping: Get→GET, Create/Add/Insert/Post→POST, Update/Put→PUT, Delete/Remove→DELETE, anything else→POST.
  • Swagger is served at /swagger/v1/swagger.json.

See REST API.


19. Dynamic C# client proxies

ABP generates strongly-typed HTTP client proxies from the same service interfaces, so other processes (Blazor, tests) call services as if they were local. The host's client module wires this up (src/Cargonerds.HttpApi.Client/CargonerdsHttpApiClientModule.cs):

context.Services.AddHttpClientProxies(
    typeof(CargonerdsApplicationContractsModule).Assembly, RemoteServiceName);

Each module names its remote service: RemoteServiceName = "Default" for Cargonerds; Hub and Pricing have their own (HubRemoteServiceConsts.RemoteServiceName = "Hub", PricingRemoteServiceConsts). See API Clients.


20. Code generation (Nextended.CodeGen)

This is not an ABP pattern

It is a third-party source generator layered onto the ABP domain, but it is central to how DTOs are produced here, so it is documented alongside the ABP patterns.

The Hub and Pricing modules use Nextended.CodeGen to produce DTOs (and interfaces) from domain entities. The trigger is [AutoGenerateDto(...)] on the domain base type (HubBaseGuidEntity), which also drives the OData EDM via [ProvideEdm(...)]:

[AutoGenerateDto(
    AutoGenerateDerived = true,
    BaseType = "Volo.Abp.Application.Dtos.IEntityDto<Guid>",
    PropertiesToIgnore = [ nameof(AuditedAggregateRoot.ExtraProperties),
                           nameof(AuditedAggregateRoot.ConcurrencyStamp),
                           nameof(AuditedAggregateRoot.LastModifierId),
                           nameof(AuditedAggregateRoot.CreatorId) ],
    GenerateMapping = false,
    DefaultPropertyInterfaceAccess = InterfaceProperty.GetAndSet
)]
[ProvideEdm(ProvideInherits = true)]
public class HubBaseGuidEntity : AuditedAggregateRoot<Guid>, IGuidEntity { ... }

Project-wide generation settings live in CodeGen.config.json inside the domain project (modules/hub/src/Hub.Domain/CodeGen.config.json):

{
  "DtoGeneration": {
    "DeepProperties": true,
    "OneFilePerClass": true,
    "GeneratePartial": true,
    "GenerateMapping": false,
    "Namespace": "Hub.Application.Contracts.Shipments",
    "Suffix": "Dto",
    "OutputPath": "../Hub.Application.Contracts/Generated/",
    "MappingOutputPath": "../Hub.Application/Extensions/Generated/"
  }
}

Generated files land in the Generated/ folders as partial I{X}Dto + {X}Dto pairs whose inheritance mirrors the entity hierarchy (e.g. IShipmentDto : IShipmentBaseDto). Hand-written companions (e.g. Dtos/Partials/HubBaseGuidEntityDto.cs) add behavior; don't edit the generated files by hand.

Generated surface is invisible to text search

Much of the DTO/EDM surface — and the StaticServiceType type referenced as StaticServiceType.CW1 — does not exist as hand-written source. StaticServiceType is emitted by the separate Roslyn generator Hub.SourceGenerators/StaticEnumGenerator.cs from the [StaticEnum] ServiceType enum. Changing an entity, [AutoGenerateDto], [ProvideEdm] or [StaticEnum] ripples through code that won't show up in a plain grep.


Best practices in this codebase

  • Keep the Domain clean — business rules in the Domain layer; no infrastructure dependencies.
  • One application service per aggregate; return DTOs, never entities. For Hub list/read, derive from HubEntityBaseService and override ReadPermission + ToDto.
  • Invalidate caches on writesHubEntityBaseService-derived writes must call InvalidateCacheAsync / WithCacheInvalidationAsync.
  • Declare permission grants with [GrantInRoles] so RolesDataSeedContributor seeds them.
  • Add new org-scoped entities to the right filter path — auto-active IQueryFilter (always applied) or manual FilterAll (you must call it). Verify which path each query uses.
  • Errors — there is no populated *ErrorCodes catalog; use UserFriendlyException / BusinessException("Hub:UnauthorizedOrganizationUnit") with ad-hoc codes, as the rest of the code does.

Further reading

Sibling documentation

ABP framework documentation