Loading learning content...
Understanding what a Domain Service is and when to use one is only half the battle. The other half is how to design Domain Services that are clean, maintainable, and true to DDD principles.
Poorly designed Domain Services can become dumping grounds for miscellaneous logic, grow into unmanageable God Services, or leak infrastructure concerns into the domain. Well-designed Domain Services, on the other hand, become clear expressions of domain operations that enhance rather than obscure the domain model.
This page provides practical, actionable guidelines for designing Domain Services that stand the test of time.
By the end of this page, you will have a comprehensive toolkit of design guidelines: naming conventions, interface design patterns, implementation strategies, error handling approaches, and common anti-patterns to avoid.
Names communicate intent. A well-named Domain Service immediately conveys its purpose to developers, domain experts, and future maintainers. A poorly named one obscures understanding and invites scope creep.
Rule 1: Name After the Domain Operation
The service name should directly reflect the domain operation it encapsulates. Use terminology from the Ubiquitous Language.
OrderService — Too vague, what does it do?OrderHelper — 'Helper' is a code smellOrderUtils — Screams procedural codeOrderManager — 'Manager' of what?OrderProcessor — Vague, could mean anythingOrderFulfillmentService — Clear domain operationOrderPricingCalculator — Specific functionOrderShippingEstimator — Focused purposeOrderReturnPolicy — Domain conceptOrderInventoryAllocator — Actionable operationRule 2: Use Domain Verbs
Domain Services often encapsulate actions—verbs from the Ubiquitous Language. Incorporate these verbs into names:
| Domain Verb | Service Name | Primary Method |
|---|---|---|
| Transfer | FundsTransferService | transfer(source, dest, amount) |
| Match | OrderMatchingEngine | match(order, orderBook) |
| Calculate | PremiumCalculator | calculatePremium(policy, risk) |
| Validate | MedicalClaimValidator | validate(claim) |
| Allocate | InventoryAllocator | allocate(order, warehouses) |
| Assess | RiskAssessmentService | assessRisk(applicant) |
| Underwrite | PolicyUnderwriter | underwrite(application) |
Rule 3: Avoid Generic Suffixes
Certain suffixes are so overused they've lost meaning:
Show the service name to a domain expert without explanation. Can they guess what it does? If they need technical context to understand it, the name needs work. 'PolicyUnderwriter' is immediately understandable to insurance professionals. 'PolicyProcessor' is not.
The interface is the contract of your Domain Service—it's what other parts of the domain see and depend on. Interface design profoundly affects usability, testability, and evolvability.
Principle 1: Small, Focused Interfaces
Follow the Interface Segregation Principle: clients shouldn't depend on methods they don't use. A Domain Service with too many methods should likely be split.
123456789101112131415161718192021222324252627282930313233
// ❌ TOO BROAD: One service doing too many thingsinterface PaymentService { processPayment(order: Order, method: PaymentMethod): PaymentResult; refundPayment(paymentId: PaymentId, amount: Money): RefundResult; calculateFees(amount: Money, method: PaymentMethod): Money; validatePaymentMethod(method: PaymentMethod): ValidationResult; subscribeToCard(customer: Customer, card: CardInfo): SubscriptionId; cancelSubscription(subscriptionId: SubscriptionId): void; generateInvoice(payments: Payment[]): Invoice; reconcilePayments(date: Date): ReconciliationReport;} // ✅ FOCUSED: Split into cohesive servicesinterface PaymentProcessor { processPayment(order: Order, method: PaymentMethod): PaymentResult; refundPayment(paymentId: PaymentId, amount: Money): RefundResult;} interface PaymentFeeCalculator { calculateFees(amount: Money, method: PaymentMethod): Money; calculateMerchantDiscount(merchant: Merchant, volume: Money): Money;} interface SubscriptionService { subscribe(customer: Customer, card: CardInfo): SubscriptionId; cancel(subscriptionId: SubscriptionId): void; renew(subscriptionId: SubscriptionId): RenewalResult;} interface PaymentReconciliation { reconcile(date: Date): ReconciliationReport; identifyDiscrepancies(report: ReconciliationReport): Discrepancy[];}Principle 2: Use Domain Types, Not Primitives
Parameters and return types should be domain objects, not primitive types. This prevents primitive obsession and makes the interface self-documenting:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// ❌ PRIMITIVE OBSESSION: String, number, booleaninterface FundsTransferService { transfer( sourceAccountNumber: string, // Primitive! destAccountNumber: string, // Primitive! amount: number, // Primitive! currencyCode: string, // Primitive! reference: string // Primitive! ): { success: boolean; // Primitive! transactionId: string; // Primitive! fees: number; // Primitive! };} // ✅ DOMAIN TYPES: Self-documenting, validatedinterface FundsTransferService { transfer( source: AccountId, destination: AccountId, amount: Money, // Encapsulates amount + currency reference: TransferReference ): TransferResult;} // Domain types enforce invariantsclass AccountId { private constructor(private readonly value: string) { if (!AccountId.isValid(value)) { throw new InvalidAccountIdError(value); } } static from(value: string): AccountId { /* validation */ }} class Money { private constructor( private readonly amount: Decimal, private readonly currency: Currency ) { if (amount.isNegative()) { throw new NegativeAmountError(); } } // Rich operations...} class TransferResult { readonly transferId: TransferId; readonly status: TransferStatus; readonly executedAt: Timestamp; readonly fees: Money; readonly exchangeRateApplied: ExchangeRate | null;}Principle 3: Return Rich Results
Don't just return success/failure. Return a rich result object that tells the caller everything they need to know:
12345678910111213141516171819202122232425262728293031323334
// ❌ POOR: Throwing exceptions for all failuresinterface OrderMatchingService { matchOrder(order: Order, book: OrderBook): Trade[]; // Throws if order can't be matched... but why? Partial match? No liquidity?} // ✅ RICH: Return captures full outcomeinterface OrderMatchingService { matchOrder(order: Order, book: OrderBook): MatchingResult;} // The result tells the complete storyclass MatchingResult { readonly incomingOrderId: OrderId; readonly matchType: 'FULL' | 'PARTIAL' | 'NONE'; readonly executedTrades: readonly Trade[]; readonly remainingQuantity: Quantity; readonly averageExecutionPrice: Price | null; // Computed properties get isFullyMatched(): boolean { return this.matchType === 'FULL'; } get fillPercentage(): Percentage { /* calculation */ }} // Compare trading outcomesclass Trade { readonly tradeId: TradeId; readonly buyOrderId: OrderId; readonly sellOrderId: OrderId; readonly quantity: Quantity; readonly price: Price; readonly executedAt: Timestamp; readonly tradeType: 'MAKER' | 'TAKER';}Use exceptions for unexpected situations that indicate programming errors or infrastructure failures. Use rich result types for expected business outcomes—even negative ones. 'No match found' is not an error; it's a valid business outcome that the result should capture.
How you structure the implementation of a Domain Service affects its maintainability and extensibility.
Pattern 1: Strategy Composition
When a Domain Service has variations in its algorithm, use the Strategy pattern to inject different behaviors:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Domain Service with pluggable strategiesinterface PricingStrategy { calculatePrice(product: Product, customer: Customer): Money;} // Different strategies for different rulesclass StandardPricingStrategy implements PricingStrategy { calculatePrice(product: Product, customer: Customer): Money { return product.basePrice; }} class MembershipPricingStrategy implements PricingStrategy { constructor(private membershipDiscounts: MembershipDiscountTable) {} calculatePrice(product: Product, customer: Customer): Money { const discount = this.membershipDiscounts.getFor(customer.tier); return product.basePrice.multiply(1 - discount.percentage); }} class SeasonalPricingStrategy implements PricingStrategy { constructor( private baseStrategy: PricingStrategy, private seasonalRules: SeasonalPricingRules ) {} calculatePrice(product: Product, customer: Customer): Money { const basePrice = this.baseStrategy.calculatePrice(product, customer); const adjustment = this.seasonalRules.getAdjustmentFor(product.category); return basePrice.multiply(adjustment); }} // Domain Service composes strategiesclass OrderPricingService { constructor(private pricingStrategy: PricingStrategy) {} calculateOrderTotal(order: Order): Money { return order.items.reduce((total, item) => { const price = this.pricingStrategy.calculatePrice( item.product, order.customer ); return total.add(price.multiply(item.quantity)); }, Money.zero(order.currency)); }}Pattern 2: Policy Objects
Encapsulate complex business rules as policy objects that the Domain Service consults:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Policies encapsulate business rulesinterface TransferPolicy { canTransfer(source: Account, destination: Account, amount: Money): boolean; getViolations(source: Account, destination: Account, amount: Money): PolicyViolation[];} // Different policies for different contextsclass DailyLimitTransferPolicy implements TransferPolicy { constructor(private readonly limits: AccountLimits) {} canTransfer(source: Account, dest: Account, amount: Money): boolean { const dailyTotal = source.getTodaysTransferTotal(); return dailyTotal.add(amount).isLessThanOrEqual( this.limits.getDailyLimit(source.type) ); } getViolations(source: Account, dest: Account, amount: Money): PolicyViolation[] { if (this.canTransfer(source, dest, amount)) return []; return [new DailyLimitExceeded(source.id, amount)]; }} class AntiMoneyLaunderingPolicy implements TransferPolicy { constructor(private amlRules: AMLRulesEngine) {} canTransfer(source: Account, dest: Account, amount: Money): boolean { return !this.amlRules.isSuspicious(source, dest, amount); }} // Composite policy combining multiple checksclass CompositeTransferPolicy implements TransferPolicy { constructor(private readonly policies: TransferPolicy[]) {} canTransfer(source: Account, dest: Account, amount: Money): boolean { return this.policies.every(p => p.canTransfer(source, dest, amount)); } getViolations(source: Account, dest: Account, amount: Money): PolicyViolation[] { return this.policies.flatMap(p => p.getViolations(source, dest, amount)); }} // Domain Service uses policiesclass FundsTransferService { constructor(private readonly policy: TransferPolicy) {} transfer(source: Account, dest: Account, amount: Money): TransferResult { const violations = this.policy.getViolations(source, dest, amount); if (violations.length > 0) { return TransferResult.rejected(violations); } // Proceed with transfer... }}Pattern 3: Specification Pattern for Queries
When the Domain Service needs to express complex queries or filters, use specifications:
12345678910111213141516171819202122232425262728293031323334353637383940414243
// Specification for matching criteriainterface OrderSpecification { isSatisfiedBy(order: Order): boolean; and(other: OrderSpecification): OrderSpecification; or(other: OrderSpecification): OrderSpecification; not(): OrderSpecification;} class HighValueOrder implements OrderSpecification { constructor(private threshold: Money) {} isSatisfiedBy(order: Order): boolean { return order.total.isGreaterThan(this.threshold); } // and/or/not implementations...} class PendingOrder implements OrderSpecification { isSatisfiedBy(order: Order): boolean { return order.status === OrderStatus.PENDING; }} class OlderThan implements OrderSpecification { constructor(private duration: Duration) {} isSatisfiedBy(order: Order): boolean { return order.createdAt.isBefore(Timestamp.now().minus(this.duration)); }} // Domain Service using specificationsclass OrderProcessingService { constructor(private orderRepository: OrderRepository) {} findOrdersRequiringAttention(): Order[] { const spec = new HighValueOrder(Money.of(1000, 'USD')) .and(new PendingOrder()) .and(new OlderThan(Duration.hours(24))); return this.orderRepository.findMatching(spec); }}Error handling in Domain Services requires careful thought. These services contain business logic, and how they report problems affects the entire system's usability and debuggability.
Principle 1: Domain Exceptions for Domain Violations
When business rules are violated, throw domain-specific exceptions that communicate the problem in domain terms:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// ❌ POOR: Generic exceptionsclass FundsTransferService { transfer(source: Account, dest: Account, amount: Money): void { if (source.balance < amount) { throw new Error("Not enough money"); // Vague! } if (dest.isClosed()) { throw new Error("Cannot transfer"); // Why not? } }} // ✅ GOOD: Rich domain exceptionsabstract class DomainException extends Error { abstract readonly code: string; // Machine-readable abstract readonly userMessage: string; // Human-readable} class InsufficientFundsException extends DomainException { readonly code = 'TRANSFER.INSUFFICIENT_FUNDS'; readonly userMessage: string; constructor( readonly accountId: AccountId, readonly availableBalance: Money, readonly requestedAmount: Money ) { super(`Insufficient funds in account ${accountId}`); this.userMessage = `Account has ${availableBalance} but ${requestedAmount} was requested`; } get shortfall(): Money { return this.requestedAmount.subtract(this.availableBalance); }} class DestinationAccountClosedException extends DomainException { readonly code = 'TRANSFER.DESTINATION_CLOSED'; readonly userMessage = 'Cannot transfer to a closed account'; constructor( readonly accountId: AccountId, readonly closedAt: Timestamp ) { super(`Cannot transfer to closed account ${accountId}`); }} class TransferLimitExceededException extends DomainException { readonly code = 'TRANSFER.LIMIT_EXCEEDED'; constructor( readonly limitType: 'DAILY' | 'SINGLE' | 'MONTHLY', readonly limit: Money, readonly requestedAmount: Money ) { super(`${limitType} transfer limit exceeded`); } get userMessage(): string { return `This transfer exceeds your ${this.limitType.toLowerCase()} limit of ${this.limit}`; }}Principle 2: Result Objects for Expected Failures
Not all failures are exceptional. When a negative outcome is a normal business scenario, use result objects instead of exceptions:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// For operations where 'failure' is expected and normal // Generic Result typetype Result<T, E> = | { success: true; value: T } | { success: false; error: E }; // Domain-specific resulttype UnderwritingResult = | { decision: 'APPROVED'; policy: ApprovedPolicy } | { decision: 'DECLINED'; reasons: DeclineReason[] } | { decision: 'REFER'; issues: ReferralIssue[] }; class PolicyUnderwritingService { underwrite(application: PolicyApplication): UnderwritingResult { const riskScore = this.assessRisk(application); // 'Declined' is not an exception - it's a normal business outcome if (riskScore > this.declineThreshold) { return { decision: 'DECLINED', reasons: this.determineDeclineReasons(application, riskScore) }; } if (riskScore > this.autoApproveThreshold) { return { decision: 'REFER', issues: this.determineReferralIssues(application, riskScore) }; } return { decision: 'APPROVED', policy: this.createPolicy(application) }; }} // Clean handling at call siteconst result = underwritingService.underwrite(application); switch (result.decision) { case 'APPROVED': await notifyApproval(result.policy); break; case 'DECLINED': await notifyDecline(result.reasons); break; case 'REFER': await createManualReviewTask(result.issues); break;}Use Exceptions when: The caller can't reasonably recover locally; the situation indicates a programming error; the error is truly unexpected. Use Result Objects when: Multiple outcomes are valid business results; the caller needs to handle different outcomes differently; 'failure' is actually a normal business scenario.
Knowing what NOT to do is as important as knowing what to do. These anti-patterns frequently appear in codebases and should be actively avoided.
Anti-Pattern 1: The God Service
A service that does everything related to a domain concept, violating the Single Responsibility Principle:
123456789101112131415161718192021222324
// ❌ GOD SERVICE: Does way too muchclass OrderService { calculatePrice(order: Order): Money { /* ... */ } validateOrder(order: Order): ValidationResult { /* ... */ } checkInventory(order: Order): InventoryStatus { /* ... */ } reserveInventory(order: Order): ReservationResult { /* ... */ } calculateShipping(order: Order): ShippingQuote { /* ... */ } applyPromotions(order: Order): PromotionResult { /* ... */ } processPayment(order: Order): PaymentResult { /* ... */ } sendConfirmation(order: Order): void { /* ... */ } generateInvoice(order: Order): Invoice { /* ... */ } scheduleDelivery(order: Order): DeliverySchedule { /* ... */ } handleReturn(order: Order): ReturnResult { /* ... */ } calculateRefund(order: Order): RefundAmount { /* ... */ } // ... 20 more methods} // ✅ SPLIT INTO FOCUSED SERVICESclass OrderPricingService { calculateTotal(order: Order): Money; }class OrderValidationService { validate(order: Order): ValidationResult; }class InventoryAllocationService { allocate(order: Order): AllocationResult; }class ShippingCalculator { calculateCost(order: Order, dest: Address): Money; }class PromotionEngine { applyApplicable(order: Order): PromotionDetails; }class RefundCalculator { calculateRefund(order: Order, items: OrderItem[]): Money; }Anti-Pattern 2: The Transaction Script Disguise
Procedural code masquerading as a Domain Service, typically featuring entity state manipulation:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// ❌ TRANSACTION SCRIPT: Procedural code in OO clothingclass OrderDomainService { placeOrder(order: Order): void { // Directly manipulating entity state from outside if (order.status !== 'DRAFT') { throw new Error("Invalid state"); } order.status = 'PENDING'; // ❌ Direct mutation from outside! for (const item of order.items) { item.reservedAt = new Date(); // ❌ Direct mutation! item.status = 'RESERVED'; // ❌ Direct mutation! } order.submittedAt = new Date(); // ❌ Direct mutation! order.orderNumber = this.generateOrderNumber(); // ❌ Direct mutation! }} // ✅ RICH DOMAIN: Entity protects its own stateclass Order { private status: OrderStatus = OrderStatus.DRAFT; submit(): SubmissionResult { if (this.status !== OrderStatus.DRAFT) { throw new InvalidOrderStateError(this.id, this.status, 'submit'); } // Entity controls its own state transitions this.status = OrderStatus.PENDING; this.submittedAt = Timestamp.now(); this.orderNumber = OrderNumber.generate(); for (const item of this.items) { item.markReserved(); } this.addDomainEvent(new OrderSubmitted(this.id, this.submittedAt)); return SubmissionResult.success(this.orderNumber); }} // Domain Service only for cross-entity operationsclass InventoryAllocationService { allocateForOrder(order: Order): AllocationResult { // Logic that genuinely spans Order and Inventory aggregates }}Anti-Pattern 3: Infrastructure Leakage
Domain Services that know about technical infrastructure:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// ❌ INFRASTRUCTURE LEAKAGEclass PricingDomainService { constructor( private redis: RedisClient, // ❌ Infrastructure! private http: HttpClient, // ❌ Infrastructure! private database: DbConnection // ❌ Infrastructure! ) {} async calculatePrice(product: Product): Promise<Money> { // Direct database query in domain! const priceData = await this.database.query( 'SELECT base_price, discount FROM prices WHERE product_id = $1', [product.id.value] ); // ❌ SQL in domain! // HTTP call in domain! const taxRate = await this.http.get( `https://tax-api.com/rate/${product.category}` ); // ❌ HTTP in domain! // Cache access in domain! await this.redis.set(`price:${product.id}`, calculatedPrice); // ❌ Redis in domain! return calculatedPrice; }} // ✅ CLEAN DOMAIN: Uses abstractionsinterface PriceDataProvider { getPriceData(productId: ProductId): PriceData;} interface TaxRateProvider { getTaxRate(category: ProductCategory): TaxRate;} class PricingDomainService { constructor( private priceDataProvider: PriceDataProvider, // Domain interface private taxRateProvider: TaxRateProvider // Domain interface ) {} calculatePrice(product: Product): Money { // Pure domain logic - no infrastructure knowledge const priceData = this.priceDataProvider.getPriceData(product.id); const taxRate = this.taxRateProvider.getTaxRate(product.category); return priceData.basePrice .minus(priceData.discount) .plus(priceData.basePrice.multiply(taxRate.percentage)); }}Look at the imports at the top of your Domain Service file. If you see HTTP clients, database drivers, ORM imports, cache clients, or message queue libraries—you have infrastructure leakage. Domain Services should import only from the domain layer.
Use this checklist when designing or reviewing Domain Services:
| Aspect | Do | Don't |
|---|---|---|
| Naming | Use domain verbs: TransferService, Calculator, Matcher | Generic names: Manager, Helper, Processor |
| Interface | Small, focused, domain types | Large, catch-all, primitives everywhere |
| Dependencies | Domain interfaces, other domain services | HTTP clients, databases, caches |
| State | Stateless, thread-safe by design | Instance fields that change between calls |
| Errors | Rich domain exceptions, result types | Generic exceptions, cryptic codes |
| Scope | Single domain operation or cohesive set | Everything related to a concept |
We've equipped you with practical guidelines for designing Domain Services that are clean, maintainable, and true to DDD principles.
Conclusion:
Domain Services are a powerful DDD building block for encapsulating business logic that doesn't naturally belong to entities. When designed correctly, they express domain operations clearly, remain testable and maintainable, and work harmoniously with the rest of your domain model.
Master these guidelines, and your Domain Services will become assets rather than liabilities—clear expressions of your domain that enhance understanding and support change.
You've completed the Domain Services module! You now understand when to use Domain Services, their defining characteristics, how they differ from Application Services, and how to design them effectively. Apply these principles to create domain models that clearly express business operations.