Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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' });
  }
}

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,
  };
}

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.

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.


  • Invoked by @ApplicationService and @Factory.
  • Reads from @Repository ports for cross-aggregate data.
  • Returns Result<T, E> for typed failures.
  • May coordinate event emission by passing events back to the @AggregateRoot that owns them — the service does not emit events directly, since the cross-pattern rule DDD0132 enforces aggregate-only emission.

Back to the series index.

⬇ Download