Stateless Operations Across Aggregates
Some domain behaviour does not belong to any single aggregate. Computing a tax rate for a checkout — the rate depends on the customer's address (one aggregate) and the order's line items (another aggregate). Charging a card across two subscriptions in a merge operation. Forecasting next month's revenue from this month's subscription state. The operation has business meaning, the operation has a name in the ubiquitous language, but it lives between aggregates rather than inside one. @DomainService from @frenchexdev/ddd-domain-service reifies that home.
What @DomainService Reifies
Evans (Domain-Driven Design, chapter 5) introduces domain services cautiously. The pattern is over-applied in many codebases — every method ends up in a *Service class because service is the default destination for behaviour that has no obvious home. The discipline the corpus enforces is the opposite: a domain service is the last resort, the place where behaviour goes only when it genuinely does not belong to an entity, a value object, or an aggregate root.
Three properties identify a true domain service. Named in the ubiquitous language: TaxCalculator, PriceForecaster, RetryPolicyEvaluator — names the business stakeholders would recognise, not technical names like OrderManager. Cross-aggregate scope: the operation reads from or writes to multiple aggregates, so it cannot live inside any one of them. Stateless by default: the service has no fields of its own; the state lives in its inputs and the aggregates it reads.
The decorator's optional stateless: boolean field (default true) names the discipline. A stateful domain service is allowed but rare; if a service needs coordination state, the corpus's recommendation is to wrap the state in a small aggregate and let the domain service operate on that.
The Runtime: ddd-domain-service
decorator.ts declares the surface. DomainServiceOptions accepts only stateless?: boolean, defaulting to true. The decorator stamps __domainService and __domainServiceMetadata onto the class.
In our invented architecture, a tax-calculator that reaches across customer and order:
import { DomainService } from '@frenchexdev/ddd-domain-service';
import { ok, type Result, Guard } from '@frenchexdev/ddd-core';
import type { Customer, Order, TaxRate, Money } from '../domain/index.js';
import type { TaxRateRepositoryPort } from '../ports/tax-rate-repository.port.js';
@DomainService({ stateless: true })
export class TaxCalculator {
constructor(private readonly rates: TaxRateRepositoryPort) {}
async calculate(customer: Customer, order: Order): Promise<Result<Money, never>> {
Guard.defined(customer.shippingAddress, 'Customer.shippingAddress');
const rate = await this.rates.findByJurisdiction(customer.shippingAddress.country);
const subtotal = order.lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0);
const tax = subtotal * rate.percentage;
return ok({ value: tax, currency: order.lines[0]?.unitPrice.currency ?? 'EUR' });
}
}import { DomainService } from '@frenchexdev/ddd-domain-service';
import { ok, type Result, Guard } from '@frenchexdev/ddd-core';
import type { Customer, Order, TaxRate, Money } from '../domain/index.js';
import type { TaxRateRepositoryPort } from '../ports/tax-rate-repository.port.js';
@DomainService({ stateless: true })
export class TaxCalculator {
constructor(private readonly rates: TaxRateRepositoryPort) {}
async calculate(customer: Customer, order: Order): Promise<Result<Money, never>> {
Guard.defined(customer.shippingAddress, 'Customer.shippingAddress');
const rate = await this.rates.findByJurisdiction(customer.shippingAddress.country);
const subtotal = order.lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0);
const tax = subtotal * rate.percentage;
return ok({ value: tax, currency: order.lines[0]?.unitPrice.currency ?? 'EUR' });
}
}The service has no fields of its own beyond the injected repository — stateless. It reads from two aggregates (Customer, Order) and an infrastructure port. It returns a Result to remain composable with the rest of the typed-failure discipline. It does not mutate any aggregate — that responsibility belongs to the caller (typically an application service) once the tax is calculated.
The Analyzer: ddd-domain-service-analyzer
The analyzer for this pattern is hand-written, like Factory and Domain Event. codes.ts exports two diagnostic factories in the DDD0NNN namespace, both at error severity — the rules they enforce are structural, not stylistic.
DDD0160_DOMAIN_SERVICE_HAS_INSTANCE_STATE enforces the defining trait of the pattern. A @DomainService carries stateless: true by default; any instance field beyond constructor-injected dependencies is a structural violation:
export const DDD0160_DOMAIN_SERVICE_HAS_INSTANCE_STATE = 'DDD0160';
export function domainServiceHasInstanceState(className: string, fieldName: string, file: string, line: number): Diagnostic {
return {
code: DDD0160_DOMAIN_SERVICE_HAS_INSTANCE_STATE,
severity: 'error',
message: `@DomainService "${className}" carries instance state via "${fieldName}". DomainServices must be stateless — extract state to an Entity or AggregateRoot.`,
file,
line,
};
}export const DDD0160_DOMAIN_SERVICE_HAS_INSTANCE_STATE = 'DDD0160';
export function domainServiceHasInstanceState(className: string, fieldName: string, file: string, line: number): Diagnostic {
return {
code: DDD0160_DOMAIN_SERVICE_HAS_INSTANCE_STATE,
severity: 'error',
message: `@DomainService "${className}" carries instance state via "${fieldName}". DomainServices must be stateless — extract state to an Entity or AggregateRoot.`,
file,
line,
};
}DDD0161_DOMAIN_SERVICE_TOUCHES_INFRA is the layer-boundary check. A domain service that imports infrastructure (HTTP client, DB driver, file system) has stopped being a domain service and started being an application service in disguise. The diagnostic ships at error because the misclassification is structural — once infra leaks in, the service no longer belongs in the domain layer:
domainServiceTouchesInfra('TaxCalculator', 'fetch', 'domain/tax-calculator.ts', 3);
// DDD0161 [error] @DomainService "TaxCalculator" imports "fetch".
// DomainServices carry only domain logic — move infra calls into an ApplicationService.domainServiceTouchesInfra('TaxCalculator', 'fetch', 'domain/tax-calculator.ts', 3);
// DDD0161 [error] @DomainService "TaxCalculator" imports "fetch".
// DomainServices carry only domain logic — move infra calls into an ApplicationService.The hand-written shape places this analyzer on the same migration list as the other DDD0NNN cohorts. Future work tracked via PROP-DOMAINSERVICE-001 (code namespace migration to DDD-DOMAINSERVICE-NNN) and PROP-DOMAINSERVICE-002 (spec-first codegen pass) will rewrite both on top of defineAnalyzerSpec / defineCodegenSpec. The two codes are part of the published contract that must survive that migration.
The Codegen: ddd-domain-service-codegen
The codegen is also hand-written. It emits a registry of services so an application bootstrap can wire them generically. The deeper invariants — the service does not directly construct aggregates without going through a factory, cross-pattern boundary with @ApplicationService — live as future spec-first rules.
Cross-Links
- Invoked by
@ApplicationServiceand@Factory. - Reads from
@Repositoryports for cross-aggregate data. - Returns
Result<T, E>for typed failures. - May coordinate event emission by passing events back to the
@AggregateRootthat owns them — the service does not emit events directly, since the cross-pattern ruleDDD0132enforces aggregate-only emission.
Back to the series index.