Skip to content

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:

  1. Classic ABP DomainService subclasses — the "Manager" pattern. There is exactly one in the whole solution: ApiKeyManager.
  2. Injectable domain/infrastructure helpers registered through ABP's DI lifetime interfaces (ISingletonDependency, IScopedDependency, ITransientDependency). These live in modules/hub/src/Hub.Domain/Services/ but do not extend DomainService.

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 discovers DomainService subclasses by convention, so there is no manual registration in CargonerdsDomainModule (the module's ConfigureServices does not mention it).
  • It uses the base-class context. GuidGenerator.Create() and CurrentTenant.Id are inherited protected members of DomainService — the manager is multi-tenant-aware without taking ICurrentTenant as a constructor parameter.
  • It is the only place the ApiKey invariant lives. The ApiKey aggregate exposes protected internal setters with Check.NotNullOrWhiteSpace/Check.Length guards, 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 configuration App: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 : DomainService returns ApiKeyManager and nothing else. Do not assume a *Manager/*Service class in a Domain project is an ABP DomainService — most are DI-marker helpers.
  • ApiKeyCreateOption relies on its defaults. No Configure<ApiKeyCreateOption> exists; the prefix length (16) and generators come from the constructor. The manager validates against PrefixLength, so generator and validator must agree.
  • CurrentFilterContextProvider is a singleton with AsyncLocal state. Set the scope with Change(...) at the request/job boundary; do not expect mutations made deep in a call tree to propagate back up.
  • ParallelChunkProcessor chunks run in separate DI scopes. A DbContext/UoW captured before RunAsync is not valid inside a chunk — resolve scoped services from the per-chunk IServiceProvider, and re-establish principal/filter context inside the delegate (as ExcelExportJob does).
  • Errors are thrown as ad-hoc exceptions. These services throw ArgumentException, BusinessException("Hub:UnauthorizedOrganizationUnit"), etc. The *ErrorCodes constant classes are empty placeholders — there is no central error-code catalog to import (see Error Codes reference).

See also