Domain services
Some logic in your domain doesn't fit neatly into a single entity or aggregate. When an operation spans multiple aggregates or represents a core business process that transcends ownership, you reach for domain services. Unlike application services that orchestrate infrastructure concerns, domain services live in your domain layer and encode pure business rules—they're stateless, focused, and named in the language your stakeholders use.
When entities aren't enough
Early in my career, I tried to force everything into entities. I'd add methods to Account like transferTo(otherAccount, amount), but that felt wrong. The Account entity doesn't own the transfer logic; it can't know about the target account's state or rules. A transfer is a collaboration between two aggregates, and the logic governing how they interact belongs elsewhere.
Consider a ride-hailing platform I worked on. An order goes to a driver, but accepting the order involves:
- Validating the driver is available (Driver aggregate)
- Checking the ride hasn't already been claimed (Order aggregate)
- Reserving capacity on the driver's vehicle (Driver aggregate)
- Recording metrics about assignment speed
None of these aggregates owns this workflow. Neither Driver nor Order should orchestrate the acceptance. That's where a domain service steps in.
Operations spanning multiple aggregates
Domain services exist precisely to handle operations that cross aggregate boundaries. They take what belongs to multiple aggregates and coordinate them according to business rules, but they don't mutate state themselves—aggregates do.
Here's a concrete example. In a trading platform I built, transferring funds between accounts requires checking balance rules, applying fees, recording the transaction for compliance, and updating both accounts. Let me show you the structure:
// Domain model - aggregates own their state
class Account {
private id: AccountId;
private balance: Money;
private accountHolder: AccountHolder;
constructor(id: AccountId, holder: AccountHolder, initialBalance: Money) {
this.id = id;
this.accountHolder = holder;
this.balance = initialBalance;
}
debit(amount: Money): void {
if (this.balance.isLessThan(amount)) {
throw new InsufficientFundsError();
}
this.balance = this.balance.subtract(amount);
}
credit(amount: Money): void {
this.balance = this.balance.add(amount);
}
getBalance(): Money {
return this.balance;
}
getId(): AccountId {
return this.id;
}
}
// Domain service - coordinates the transfer
class MoneyTransferService {
constructor(
private accountRepository: AccountRepository,
private feeCalculator: FeeCalculator,
private transactionRecorder: TransactionRecorder
) {}
transfer(
fromAccountId: AccountId,
toAccountId: AccountId,
amount: Money
): TransferId {
const fromAccount = this.accountRepository.findById(fromAccountId);
const toAccount = this.accountRepository.findById(toAccountId);
if (!fromAccount || !toAccount) {
throw new AccountNotFoundError();
}
const fee = this.feeCalculator.calculateFee(amount);
const totalDebit = amount.add(fee);
// Aggregates validate their own rules
fromAccount.debit(totalDebit);
toAccount.credit(amount);
// Persist changes
this.accountRepository.save(fromAccount);
this.accountRepository.save(toAccount);
// Record for audit/compliance
const transferId = TransferId.generate();
this.transactionRecorder.record(
new MoneyTransferTransaction(
transferId,
fromAccountId,
toAccountId,
amount,
fee,
new Date()
)
);
return transferId;
}
}
Notice the service doesn't own state. It orchestrates—it retrieves aggregates, calls their methods, and ensures persistence. The domain rules (insufficient funds check, fee calculation) remain in the aggregates or value objects. The service is a orchestrator of domain concepts, not a container for logic.
Stateless and named in domain language
A domain service must be stateless. It takes dependencies in the constructor and operates on data passed to its methods. This matters because it means domain services are inherently thread-safe and can be instantiated once and reused across your application.
More importantly, domain services are named after business operations, not technical patterns. I've seen codebases with services named TransferProcessor or TransactionHandler—generic, vague. Your domain language should shine. In the e-commerce platform I worked on, we had OrderFulfillmentService (not OrderProcessor), InventoryAllocationService (not StockManager), and ShippingCalculationService (not DeliveryHelper).
These names tell the story. When a new developer reads the codebase, they see domain concepts, not infrastructure plumbing.
// Good: domain language
class OrderFulfillmentService {
fulfill(orderId: OrderId, warehouse: Warehouse): FulfillmentResult {
// implementation
}
}
// Poor: generic, doesn't reflect domain
class OrderProcessor {
process(order: any): any {
// vague, unclear intent
}
}
// Another domain service example: CRM
class LeadScoringService {
scoreProspect(prospect: Prospect): LeadScore {
let score = 0;
if (prospect.companySizeIndicatesFit()) {
score += 20;
}
if (prospect.industryMatchesTarget()) {
score += 15;
}
if (prospect.engagementHistoryPromising()) {
score += 25;
}
return new LeadScore(score);
}
}
Don't move everything here
The biggest mistake I see is treating domain services as a dumping ground. Developers move business logic there to avoid making decisions about aggregate boundaries. Before you create a domain service, ask: Does this logic truly belong to multiple aggregates, or have I just not thought hard enough about my model?
In a travel booking platform I built, we initially had a BookingService that calculated availability, applied discounts, and reserved inventory. We ended up moving most of it into the Trip aggregate because it all centered on one concept. The service stayed small—just coordinating between Trip and Payment aggregates when a customer completed checkout.
Here's the guideline: if your logic fits inside one aggregate with reasonable cohesion, keep it there. Only reach for a domain service when you genuinely need to coordinate multiple aggregates or when the logic represents a use case distinct enough that it deserves its own name.
// Example: What NOT to do
class UserService {
// This is too broad - it's everything related to users
createUser(data: any) { }
updateProfile(userId: string) { }
changePassword(userId: string) { }
assignRole(userId: string, role: string) { }
notifyUser(userId: string, message: string) { }
generateReport(userId: string) { }
}
// Better: Domain services for specific cross-aggregate concerns
class UserRegistrationService {
register(email: Email, password: Password): User {
// Only handles registration logic
}
}
class UserPermissionService {
grantPermission(user: User, resource: Resource): void {
// Only handles permission coordination
}
}
// User aggregate still owns its own state and single-aggregate logic
class User {
private id: UserId;
private email: Email;
private profile: UserProfile;
updateProfile(updates: ProfileUpdate): void {
this.profile.update(updates);
}
}
Common mistakes
- Making them stateful. I've seen services accumulate properties over time, becoming quasi-aggregates. If you're adding state to a domain service, you've likely found a new aggregate or entity that needs modeling.
- Loading too much responsibility. A domain service shouldn't fetch data, validate every precondition, send emails, and log metrics. It orchestrates domain logic. Let application services or use cases handle cross-cutting concerns.
- Naming them generically. "Service" is everywhere. Be specific:
InventoryReservationService, notInventoryService.PriceCalculationService, notPriceService. The name should reflect the business operation. - Confusing domain services with application services. Domain services encode business rules and coordinate aggregates. Application services are use case handlers that sit above them and manage infrastructure (transactions, logging, security). They're different layers.
- Putting all cross-aggregate logic in one service. If your domain is complex enough to have multiple aggregates, you likely need multiple domain services—one for each conceptual workflow. Don't create a monolithic "DomainService" class.
Key takeaways
- Domain services handle operations that span multiple aggregates or represent business processes that transcend any single aggregate's responsibility.
- They are stateless—all state lives in aggregates. Dependencies go in the constructor; data in methods.
- Name them in domain language that reflects the business operation, not generic verbs like "process" or "handle."
- Before creating one, verify the logic can't fit into an existing aggregate with reasonable design—domain services are a last resort, not a default.
- Keep domain services focused. If they're growing large or handling unrelated concerns, split them or reconsider your aggregate boundaries.
- Domain services belong in your domain layer, not your application layer. They encode business rules, not infrastructure orchestration.






Comments
Loading comments…