Domain Services¶
A domain service holds domain logic that operates on multiple aggregates or value objects, or that needs infrastructure (hashing, a clock, configuration) that does not belong inside an entity. It is the home for behaviour that is "too important to leak into an application service, but doesn't fit on a single entity".
In the Cargonerds codebase "domain service" actually covers two distinct things that live in
the *.Domain projects:
- Classic ABP
DomainServicesubclasses — the "Manager" pattern. There is exactly one in the whole solution:ApiKeyManager. - Injectable domain/infrastructure helpers registered through ABP's
DI lifetime interfaces
(
ISingletonDependency,IScopedDependency,ITransientDependency). These live inmodules/hub/src/Hub.Domain/Services/but do not extendDomainService.
This codebase is light on classic domain services
Most Hub aggregates are anemic with public setters, and mutation happens in application
services (see DDD Overview and Aggregate Roots). As a
result there is almost no "rich domain service" layer. The ApiKeyManager /
ApiKey pair in the host project is the one place that follows
stricter DDD, and it is the best worked example of the pattern in the repo.
Domain service vs. application service¶
The two layers answer different questions. The application service is the use-case boundary; the domain service protects an invariant.
| Aspect | Application service (ApplicationService) |
Domain service (DomainService) |
|---|---|---|
| Layer / project | *.Application |
*.Domain |
| Purpose | Orchestrate one use case end-to-end | Encapsulate domain logic / invariants spanning entities |
| Talks to | DTOs (input/output), repositories, other app/domain services | Entities, value objects, repositories |
| Authorization | [Authorize(...)], IAuthorizationService |
None — auth is an application concern |
| Transactions / UoW | Methods wrapped in a Unit of Work automatically | Participates in the caller's UoW |
| Returns | DTOs | Entities / primitives |
| Auto-exposed as API | Yes (auto API controllers) | No |
| In this repo | Many (ApiKeyAppService, ShipmentAppService, …) |
Exactly one: ApiKeyManager |
The decision rule used here: if the logic is "how do I build/validate/hash a valid ApiKey" it
belongs in the domain service; if it is "who is allowed to do this, is the name unique, persist it,
inherit permissions, return a DTO" it belongs in the application service.
flowchart TD
Client[HTTP / client proxy] --> AppSvc
subgraph App[Application layer]
AppSvc["ApiKeyAppService<br/>(ApplicationService)"]
end
subgraph Domain[Domain layer]
Mgr["ApiKeyManager<br/>(DomainService)"]
Agg["ApiKey aggregate<br/>(protected internal setters)"]
Provider["ApiKeyPrincipalProvider<br/>(ITransientDependency)"]
end
AppSvc -->|"auth, uniqueness checks,<br/>persistence, permission inheritance"| Mgr
AppSvc --> Repo[(IApiKeyRepository)]
Mgr -->|"Create() / Hash()"| Agg
Provider -->|"GetPrefix / GetRawKey / Verify"| Mgr
ApiKeyManager — the Manager pattern¶
ApiKeyManager is the only class in the solution that extends
Volo.Abp.Domain.Services.DomainService.
It is the sanctioned way to create and verify API keys, so that application services never construct
a key, set its hash, or run the hashing primitive directly.
// src/Cargonerds.Domain/ApiKeys/ApiKeyManager.cs
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Volo.Abp.Domain.Services;
namespace Cargonerds.ApiKeys;
public class ApiKeyManager(IPasswordHasher<object> passwordHasher, IOptions<ApiKeyCreateOption> createOptions)
: DomainService
{
public ApiKey Create(
string key, string prefix, Guid userId, string name,
string? description = null, bool isActive = true, DateTimeOffset? expirationTime = null)
{
if (prefix.Length != createOptions.Value.PrefixLength)
{
throw new ArgumentException(
$"The prefix length must be {createOptions.Value.PrefixLength} characters.", nameof(prefix));
}
var hashedKey = Hash(key);
return new ApiKey(
GuidGenerator.Create(), userId, prefix, name, description, isActive, expirationTime, CurrentTenant.Id)
{
Hash = hashedKey,
};
}
public string Hash(string key) => passwordHasher.HashPassword(null!, key);
public bool Verify(string key, string hashedKey) =>
passwordHasher.VerifyHashedPassword(null!, hashedKey, key) != PasswordVerificationResult.Failed;
public string GetPrefix(string key) => /* key[..PrefixLength] with length guard */;
public string GetRawKey(string key) => /* key[PrefixLength..] with length guard */;
}
What makes this a domain service rather than a helper:
- It is registered automatically as a
DomainService. ABP discoversDomainServicesubclasses by convention, so there is no manual registration inCargonerdsDomainModule(the module'sConfigureServicesdoes not mention it). - It uses the base-class context.
GuidGenerator.Create()andCurrentTenant.Idare inherited protected members ofDomainService— the manager is multi-tenant-aware without takingICurrentTenantas a constructor parameter. - It is the only place the
ApiKeyinvariant lives. TheApiKeyaggregate exposesprotected internalsetters withCheck.NotNullOrWhiteSpace/Check.Lengthguards, so it cannot be mutated from outside the Domain assembly. The manager is the factory:
// src/Cargonerds.Domain/ApiKeys/ApiKey.cs (excerpt)
public class ApiKey : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public string Name
{
get;
protected internal set => field = Check.NotNullOrWhiteSpace(value, nameof(value), ApiKeyConsts.MaxNameLength);
} = string.Empty;
public string Hash
{
get;
protected internal set => field = Check.NotNullOrWhiteSpace(value, nameof(value), ApiKeyConsts.MaxHashLength);
} = string.Empty;
// ...
}
Managers enforce invariants
ApiKeyManager.Create is the only correct way to build a valid key: it validates the prefix
length against ApiKeyCreateOption.PrefixLength and hashes the raw secret before the aggregate
is ever persisted. Because the aggregate's setters are protected internal, an application
service literally cannot bypass it.
How the application service consumes it¶
ApiKeyAppService shows the division of labour cleanly. The application service handles
authorization, target-user resolution, uniqueness checks, key/prefix generation, persistence and
permission inheritance; it delegates only the invariant-bearing construction to the manager.
// src/Cargonerds.Application/ApiKeys/ApiKeyAppService.cs (excerpt)
[Authorize(CargonerdsPermissions.ApiKeys.Default)]
public class ApiKeyAppService(
IApiKeyRepository apiKeyRepository,
ApiKeyManager apiKeyManager,
IOptions<ApiKeyCreateOption> createOptions,
/* ... */) : ApplicationService, IApiKeyAppService
{
[Authorize(CargonerdsPermissions.ApiKeys.Create)]
public async Task<ApiKeyCreateResultDto> CreateAsync(CreateApiKeyDto input)
{
var targetUserId = await ResolveTargetUserIdAsync(input.UserId); // app concern: authz
var rawKey = await createOptions.Value.KeyGenerator(createContext); // app concern: generation
var prefix = await createOptions.Value.PrefixGenerator(createContext);
await CheckNameAlreadyExistsAsync(input.Name, targetUserId); // app concern: uniqueness
await CheckPrefixAlreadyExistsAsync(prefix);
var apiKey = apiKeyManager.Create( // domain concern: invariant + hashing
rawKey, prefix, targetUserId, input.Name,
input.Description, input.IsActive, input.ExpirationTime);
await apiKeyRepository.InsertAsync(apiKey, autoSave: true); // app concern: persistence
await permissionInheritance.InheritPermissionsAsync(apiKey.Id, targetUserId);
var resultDto = apiKey.ToCreateResultDto();
resultDto.Key = prefix + rawKey; // raw secret returned exactly once
return resultDto;
}
}
The same domain service is reused outside the application layer for the read/verify path.
ApiKeyPrincipalProvider (an ITransientDependency in the Domain project, used by the
authentication pipeline) calls GetPrefix/GetRawKey/Verify on the very same manager, so the
prefix-splitting and hash-verification rules live in one place:
// src/Cargonerds.Domain/ApiKeys/ApiKeyPrincipalProvider.cs (excerpt)
prefix = apiKeyManager.GetPrefix(key);
rawKey = apiKeyManager.GetRawKey(key);
// ...
new ApiKeyVerificationCacheItem { IsValid = apiKeyManager.Verify(rawKey, apiKeyInfo.Hash) }
ApiKeyCreateOption is never configured — defaults are load-bearing
ApiKeyCreateOption (src/Cargonerds.Domain.Shared/ApiKeys/ApiKeyCreateOption.cs) is consumed
via IOptions<ApiKeyCreateOption> in both the manager and the app service, but no
Configure<ApiKeyCreateOption>(...) / PreConfigure<...> call exists anywhere in the solution.
Behaviour therefore comes entirely from the constructor defaults: PrefixLength = 16, and
PrefixGenerator/KeyGenerator built from Guid.CreateVersion7().ToString("N"). If you change
PrefixLength, change it in the options class default (or add a Configure call) — the manager
validates against createOptions.Value.PrefixLength, so an inconsistent generator would make
Create throw.
Domain-layer helper services (not DomainService)¶
The Hub Domain project hosts several injectable services under
modules/hub/src/Hub.Domain/Services/. They are "domain services" by location and intent, but are
registered through ABP DI lifetime marker interfaces instead of extending DomainService. They are
discovered automatically — HubDomainModule itself is an empty [DependsOn(...)] shell with no
manual registrations.
CurrentFilterContextProvider (ISingletonDependency)¶
A singleton that exposes the ambient organization/organization-unit filtering scope for the current
request. It wraps an AsyncLocal<IFilterContext?> and surfaces the active org/org-unit ids, the
current user, and user preferences for query filters and application services to enforce data
isolation.
// modules/hub/src/Hub.Domain/Services/CurrentFilterContextProvider.cs (excerpt)
namespace Hub.Services;
public class CurrentFilterContextProvider(ICurrentUser currentUser) : ISingletonDependency
{
private readonly AsyncLocal<IFilterContext?> _currentFilterContext = new();
public IFilterContext? CurrentFilterContext => _currentFilterContext.Value;
public HashSet<Guid> ActiveOrgIds => CurrentFilterContext?.OrganizationIds.ToHashSet() ?? [];
public HashSet<Guid> ActiveOrgUnitIds => CurrentFilterContext?.OrganizationUnitIds.ToHashSet() ?? [];
public Guid CurrentUserId => currentUser.GetId();
public UserPreferences Preferences => CurrentFilterContext?.Preferences ?? new();
public bool HasOrgFilter => ActiveOrgIds.Any();
public virtual IDisposable Change(IFilterContext? filterContext)
{
var parent = CurrentFilterContext;
_currentFilterContext.Value = filterContext;
return new DisposeAction(() => _currentFilterContext.Value = parent);
}
}
It is populated per request by FilterContextMiddleware
(src/Cargonerds.HttpApi.Host/Middlewares/FilterContextMiddleware.cs), which builds/loads the
context from a cache and calls Change(...):
// FilterContextMiddleware.InvokeAsync (excerpt)
var context = await filterContextCacheManager.GetAsync() ?? await filterContextCacheManager.BuildAndCacheAsync();
currentFilterProvider.Change(context); // AsyncLocal is copy-on-write — must set it at the top of the request
Singleton + AsyncLocal — set the scope at the boundary
Because the provider is a singleton holding request state in an AsyncLocal, you must call
Change(...) at the start of the logical flow (middleware, or a fresh DI scope in a background
job). The code comment is explicit: "AsyncLocal is copy-on-write, changes in child async
methods don't flow back" — mutating it deep inside a call tree will not be visible to the
caller. Background jobs that need the org scope create a scope and call Change(...) themselves
(see ExcelExportJob below). This provider is consumed by both domain query filters and
application/HTTP code, which is why it lives in the Domain layer despite being
infrastructure-flavoured.
ParallelChunkProcessor (ITransientDependency)¶
A transient helper for processing large collections in parallel. It splits the input into chunks and
runs each chunk inside its own DI scope, so scoped services (DbContext, repositories,
Unit of Work)
are never shared across threads. It uses a bounded Channel<T> for backpressure and bounded
concurrency.
// modules/hub/src/Hub.Domain/Services/ParallelChunkProcessor.cs (signature)
[ExposeServices(IncludeSelf = true)]
public sealed class ParallelChunkProcessor(IServiceScopeFactory scopeFactory) : ITransientDependency
{
public async Task RunAsync<TIn>(
IReadOnlyList<TIn> items,
Func<IServiceProvider, List<TIn>, CancellationToken, Task> processChunkAsync,
int chunkSize = 200,
int workers = 10,
int channelCapacity = 20,
CancellationToken ct = default)
{ /* bounded-channel producer/consumer; each chunk gets scopeFactory.CreateScope() */ }
}
Real usage is in the Excel export pipeline. ExcelExportJob<TEntity, TExport>
(modules/hub/src/Hub.Application/Services/Excel/ExcelExportJob.cs) batches large exports through
it, opening a fresh repository + Unit of Work per chunk and re-applying the current principal so
authorization/audit context survives the thread hop:
// ExcelExportJob.BuildRowsBatchedAsync (excerpt)
await chunkProcessor.RunAsync(
items: inputs,
processChunkAsync: async (sp, chunk, token) =>
{
using (currentPrincipalAccessor.Change(currentPrincipal))
{
var uowManager = sp.GetRequiredService<IUnitOfWorkManager>();
var repo = sp.GetRequiredService<IRepository<TEntity, Guid>>();
using var uow = uowManager.Begin(requiresNew: true, isTransactional: false);
foreach (var (index, id) in chunk)
{
var entity = await repo.FindAsync(id, includeDetails: false, cancellationToken: token);
if (entity is null) continue;
entity = await LoadEntityForBatchAsync(entity, sp, token);
results[index] = MapEntity(entity);
}
await uow.CompleteAsync(token);
}
},
ct: ct);
The concrete export jobs (ShipmentExcelExportJob, QuotationExcelExportJob, OrderExcelExportJob)
inherit this base and reuse the same processor.
WhiteLabelingTemplateRenderer (service replacement)¶
This service replaces ABP's default text-template renderer so that white-labeling / branding
values are injected into every rendered template (e.g. e-mails). It is the codebase's example of the
[Dependency(ReplaceServices = true)] + [ExposeServices(...)] override pattern, and it extends
AbpTemplateRenderer (the framework default) rather than implementing ITemplateRenderer from
scratch.
// modules/hub/src/Hub.Domain/Services/WhiteLabelingTemplateRenderer.cs (excerpt)
namespace Hub.Services;
[ExposeServices(typeof(ITemplateRenderer), IncludeSelf = true)]
[Dependency(ReplaceServices = true)]
public class WhiteLabelingTemplateRenderer(
IServiceScopeFactory serviceScopeFactory,
IFeatureChecker featureChecker,
ITemplateDefinitionManager templateDefinitionManager,
IOptions<AbpTextTemplatingOptions> options,
IOptions<WhiteLabelingOptions> whiteLabelingOptions,
IOptions<DataProtectionTokenProviderOptions> tokenOptions,
IConfiguration configuration
) : AbpTemplateRenderer(serviceScopeFactory, templateDefinitionManager, options)
{
public override async Task<string> RenderAsync(
string templateName, object? model = null, string? cultureName = null,
Dictionary<string, object>? globalContext = null)
{
globalContext ??= new();
foreach (var option in whiteLabelingOptions.Value.ToDictionary())
globalContext.TryAdd(option.Key, option.Value);
globalContext.TryAdd("DetailedQuotationEmails", await featureChecker.IsDetailedQuotationEmailsEnabledAsync());
globalContext.TryAdd("DetailedShipmentEmails", await featureChecker.IsDetailedShipmentEmailsEnabledAsync());
// Prefer a "{CompanyName}.{template}" override if one is defined, else the base template.
return await base.RenderAsync(await GetTemplateName(templateName), model, cultureName, globalContext);
}
}
Key behaviours, all confirmed in source:
- It looks up a tenant-/brand-specific template name (
{NormalizedCompanyName}.{templateName}) and falls back to the base template if none is registered. - It merges the white-labeling options dictionary plus a
PortalLink(from configurationApp:Realtime) and a verification-token lifespan into the template's global context. - It gates extra e-mail content via the
feature system
(
IFeatureChecker) — see ABP features for frontend gating.
Choosing the right home for logic¶
| You need to… | Put it in… |
|---|---|
| Validate/construct an aggregate so its invariants always hold | A DomainService (Manager), like ApiKeyManager |
| Run a use case (authorize, map DTOs, persist, return) | An ApplicationService |
| Override a framework service repo-wide | [Dependency(ReplaceServices = true)] + [ExposeServices], like WhiteLabelingTemplateRenderer |
| Share request-scoped ambient state (org scope) | A singleton + AsyncLocal provider, like CurrentFilterContextProvider |
| Fan out heavy work across scoped services safely | A transient helper, like ParallelChunkProcessor |
Registration is convention-based
None of these services are wired up by hand. ApiKeyManager is found because it derives from
DomainService; the Hub helpers are found because they implement ISingletonDependency /
ITransientDependency; WhiteLabelingTemplateRenderer replaces the default registration via its
attributes. HubDomainModule.ConfigureServices is empty — see
ABP patterns for the DI conventions.
Gotchas¶
- There is only one true domain service. A repo-wide search for
: DomainServicereturnsApiKeyManagerand nothing else. Do not assume a*Manager/*Serviceclass in aDomainproject is an ABPDomainService— most are DI-marker helpers. ApiKeyCreateOptionrelies on its defaults. NoConfigure<ApiKeyCreateOption>exists; the prefix length (16) and generators come from the constructor. The manager validates againstPrefixLength, so generator and validator must agree.CurrentFilterContextProvideris a singleton withAsyncLocalstate. Set the scope withChange(...)at the request/job boundary; do not expect mutations made deep in a call tree to propagate back up.ParallelChunkProcessorchunks run in separate DI scopes. ADbContext/UoW captured beforeRunAsyncis not valid inside a chunk — resolve scoped services from the per-chunkIServiceProvider, and re-establish principal/filter context inside the delegate (asExcelExportJobdoes).- Errors are thrown as ad-hoc exceptions. These services throw
ArgumentException,BusinessException("Hub:UnauthorizedOrganizationUnit"), etc. The*ErrorCodesconstant classes are empty placeholders — there is no central error-code catalog to import (see Error Codes reference).
See also¶
- DDD Overview — bounded contexts and how DDD is applied here
- Aggregate Roots — the
ApiKeyaggregate and itsprotected internalsetters - Entities — the Hub entity inheritance spine
- Value Objects —
Coordinates,Dimension<TUnit> - Repositories —
IApiKeyRepository,IHubRepository<,> - Service Patterns and Application Services — the orchestration layer that consumes domain services
- ABP Patterns — DI conventions, service replacement, features