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 (PreConfigureServices →
ConfigureServices → PostConfigureServices → OnPreApplicationInitialization →
OnApplicationInitialization) 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 pattern — Configure<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.:
CommentStatusChangedEventHandler—ITransientDependency(Pricing event handler).CurrentFilterContextProvider—ISingletonDependencywrapping anAsyncLocal<IFilterContext?>.QueryFilterBase<T>—IScopedDependency.
Beyond plain registration, the codebase uses the full ABP DI toolbox:
-
Service replacement —
[Dependency(ReplaceServices = true)]+[ExposeServices(...)].IdentityUserAppServicereplaces the ABP identity service;WhiteLabelingTemplateRendererreplaces ABP'sITemplateRenderer: -
Decoration (Scrutor) —
context.Services.Decorate<IPermissionAppService, ApiKeyPermissionAppService>()inCargonerdsApplicationModule, so API-key principals get correct permission listings. - Keyed services —
[ExposeKeyedService<IStorageService>(BlobStorageContainer.Document)]onDocumentStorageService, consumed with[FromKeyedServices(...)]. See Messaging & Storage. -
Replacing entries in ABP option lists by index —
CargonerdsDomainModule.PostConfigureServicesswapsIdentitySessionDynamicClaimsPrincipalContributorfor a custom subclass so stateless API-key requests (noSessionIdclaim) keep their principal:
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<Guid>"] --> 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, anAuditedAggregateRoot<Guid>carrying the[AutoGenerateDto(...)]and[ProvideEdm(...)]attributes that drive code generation (see pattern 20).CustomerOwnedGuidEntityadds the nullableGuid? CargonerdsCustomerIdused for multi-customer scoping (see pattern 13).CodeEntityis 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 (viaHubBaseGuidEntity). Quotationadditionally implementsISoftDelete.OrganizationUnitOwnerUseris a plain ABPEntitywith a composite key (GetKeys()).- The host
ApiKeyaggregate (src/Cargonerds.Domain/ApiKeys/ApiKey.cs) is the one entity that follows strict DDD:protected internal setproperties guarded withCheck.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. onAddress.Coordinates.Dimension<TUnit>(modules/hub/src/Hub.Domain/Base/Dimension.cs) — generic overUnitso weight/volume/temperature reuse one type with unit type-safety (Dimension<WeightUnit>vsDimension<VolumeUnit>). BecauseUnitis itself aCodeEntity(a DB row), aDimensionreferences 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 isDtoMapper(modules/hub/src/Hub.Application/Services/DtoMapper.cs), astatic partial classannotated[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))]inQuoteMapper. - AutoMapper — just one
CreateMapcall left, for an ABP type that already ships a DTO (IdentityUser → IdentityUserDtoinHubApplicationAutoMapperProfile). Still wired up (Configure<AbpAutoMapperOptions>(o => o.AddMaps<CargonerdsApplicationModule>())) soObjectMapperworks 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' groups —
CargonerdsPermissionDefinitionProvider 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 withisVisibleToClients: 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 } -
CargonerdsSettingDefinitionProviderregistersCargonerds.EnableTwoStepRegistrationandCargonerds.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:
- Auto-applied global query filters.
HubDbContextoverrides ABP'sShouldFilterEntity<T>andCreateFilterExpression<T>: it starts from ABP's base expression (soft-delete etc.), then for every registered provider withIsAutoActive == trueadapts the lambda and combines it into a real EF Core global query filter.SoftDeleteFilter,CustomerOwnedFilterandOptionallyCustomerOwnedFilterare registered this way via[ExposeServices(typeof(IQueryFilter))]. - Manually-applied filters via
IQueryable<T>.FilterAll(serviceProvider)(Hub.DomainQueryableExtensions), which pulls providers where!IsAutoActiveand 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:
- Publish —
CommentPublicAppService(host) callsIDistributedEventBus.PublishAsync(new CommentExtraPropertiesUpdatedEto { ... })when a comment's status/reference changes. The ETO lives inCargonerds.Domain.Sharedso contracts/clients can reference it. -
Handle —
CommentStatusChangedEventHandlerin Pricing consumes it and recomputesQuotation.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 forShipmentEmailJob,QuotationEmailJob, etc.; renders ABP text templates andemailSender.QueueAsync.QuoteGenerationJob([Queue(QuoteGenerationQueue)]).ExcelExportDispatcherJob : AsyncBackgroundJob<ExcelExportJobArgs>, registered withConfigure<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
Guidparameter becomes a path parameter; two simpleGuids 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
HubEntityBaseServiceand overrideReadPermission+ToDto. - Invalidate caches on writes —
HubEntityBaseService-derived writes must callInvalidateCacheAsync/WithCacheInvalidationAsync. - Declare permission grants with
[GrantInRoles]soRolesDataSeedContributorseeds them. - Add new org-scoped entities to the right filter path — auto-active
IQueryFilter(always applied) or manualFilterAll(you must call it). Verify which path each query uses. - Errors — there is no populated
*ErrorCodescatalog; useUserFriendlyException/BusinessException("Hub:UnauthorizedOrganizationUnit")with ad-hoc codes, as the rest of the code does.
Further reading¶
Sibling documentation¶
- Architecture Overview · Layered Architecture · Solution Structure · Aspire Integration
- Modules Overview · Module Structure · Hub · Pricing · Cargonerds host
- DDD Overview · Aggregate Roots · Entities · Value Objects · Domain Services · Repositories
- Entity Framework · Caching · Background Jobs · Messaging · Migrations
- REST API · OData Filtering · API Clients · Authentication
ABP framework documentation¶
- Modularity basics · Dependency injection
- Domain-Driven Design · Entities · Value objects · Domain services · Repositories · Application services · Data transfer objects
- Object-to-object mapping · Authorization · Settings · Features · Data filtering
- Local event bus · Distributed event bus · Background jobs · Background workers
- Auto API controllers · Dynamic C# clients · Multi-tenancy