Domain Services
A domain service holds a business operation that belongs to no single object: computing the shipping cost of an order, checking that a transfer between two accounts is allowed. It is stateless, speaks the language of the domain and returns a Result.
export class ShippingCost extends DomainService {
costOf(order: Order): Result<Money, UnsupportedDestination> { … }
}When to use
Reach for a domain service when a rule needs several aggregates, or when putting it on one of them would make that object know about things it should not. If the operation naturally belongs to an aggregate, an entity or a value object, make it a method there instead: a domain service is the exception, not the default home for logic.
It is also where a helper function goes. Free functions and plain classes are not allowed in the domain (building-blocks-only): a calculation that no value object owns becomes a method of a domain service.
Usage
Declare a domain service
Extend DomainService in domain/services/. Methods take domain objects and return a Result when the operation can fail.
import { DomainService, err, ok, type Result } from "@alveolus/core";
import type { Order } from "../aggregates/order.aggregate";
import { UnsupportedDestination } from "../errors/unsupported-destination.error";
import { Money } from "../value-objects/money.value-object";
export class ShippingCost extends DomainService {
constructor(
private readonly freeAbove: number,
private readonly flatRate: number,
) {
super();
}
costOf(order: Order): Result<Money, UnsupportedDestination> {
if (!order.isDomestic) {
return err(new UnsupportedDestination({ country: order.country }));
}
return ok(Money.of(order.total.amount >= this.freeAbove ? 0 : this.flatRate));
}
}Keep it stateless
A domain service may hold configuration in readonly fields, such as a rate or a threshold. It never holds an aggregate, an entity or anything that changes between calls.
export class ShippingCost extends DomainService {
private order: Order | undefined;
select(order: Order): void {
this.order = order;
}
}export class ShippingCost extends DomainService {
constructor(private readonly flatRate: number) {
super();
}
costOf(order: Order): Result<Money, UnsupportedDestination> { … }
}Why?
A service that keeps state between calls depends on the order of those calls and cannot be shared safely. Passing everything as parameters keeps it a plain function of the domain, easy to test and to reason about.
Call it from a command handler
The command handler loads the aggregates, asks the service, and passes its answer to the aggregate. The service never loads, saves or publishes anything.
const shipping = this.shippingCost.costOf(order);
if (!shipping.ok) {
return shipping;
}
const placed = order.place(shipping.value, this.ids.next(), this.clock.now());Build it in the composition root
The domain imports no framework, so a domain service has no decorator. The composition root builds it with its configuration and passes it to the handlers that need it:
const shippingCost = new ShippingCost(5000, 490);With a container, register that instance: see Integrations.
Reference
abstract class DomainServiceDomainService has no members: extending it marks the class as a domain service, for you and for alveolus arch check.
Caveats
- A domain service is not an application service: it never loads, saves or publishes. That is the job of a command handler.
- It may hold configuration in
readonlyfields, never an aggregate or an entity. - It lives in
domain/services/*.service.ts(placement).
Import from @alveolus/core or @alveolus/core/domain-services.
See also
- Aggregates, where most rules belong
- Value Objects, the other home for calculations
- Command handlers, which call domain services
building-blocks-only