Domain Services: Stateless Domain Operations
Domain Services encapsulate domain logic that doesn't naturally belong to a single entity or value object. They're stateless operations that work with multiple domain objects.
TL;DR
| Concept | Definition |
|---|---|
| Domain Service | Stateless operation with domain logic |
| When to Use | Logic spans multiple aggregates or doesn't fit in one entity |
| Characteristics | Named with domain verbs, no state, uses domain language |
| Location | Domain layer, alongside entities |
When to Use Domain Services
Use Domain Service When:
- Operation spans multiple aggregates
- Logic doesn't belong to any single entity
- Operation is a significant domain concept
- Stateless behavior is natural
Don't Use Domain Service When:
- Logic belongs to an entity - Put it there
- It's just orchestration - Use application service
- It's infrastructure - Use infrastructure service
Examples
Example 1: Transfer Money (Spans Aggregates)
// ❌ Doesn't belong to either account
public class Account
{
public void TransferTo(Account destination, Money amount)
{
this.Withdraw(amount);
destination.Deposit(amount); // Modifying another aggregate!
}
}
// ✅ Domain service for cross-aggregate operation
public class MoneyTransferService
{
public TransferResult Transfer(
Account source,
Account destination,
Money amount)
{
// Domain logic: validation, rules
if (source.Currency != destination.Currency)
throw new CurrencyMismatchException();
if (!source.CanWithdraw(amount))
return TransferResult.InsufficientFunds();
// Coordinate the operation
source.Withdraw(amount);
destination.Deposit(amount);
return TransferResult.Success(
new Transfer(source.Id, destination.Id, amount));
}
}
Example 2: Calculate Shipping (Complex Domain Logic)
public interface IShippingCalculator
{
ShippingOptions CalculateOptions(
Order order,
Address destination,
ShippingPreferences preferences);
}
public class ShippingCalculator : IShippingCalculator
{
private readonly IReadOnlyList<IShippingProvider> _providers;
private readonly IHolidayCalendar _holidayCalendar;
public ShippingOptions CalculateOptions(
Order order,
Address destination,
ShippingPreferences preferences)
{
var weight = order.CalculateTotalWeight();
var dimensions = order.CalculatePackageDimensions();
var options = new List<ShippingOption>();
foreach (var provider in _providers)
{
if (!provider.CanShipTo(destination))
continue;
var rates = provider.GetRates(weight, dimensions, destination);
var deliveryDates = CalculateDeliveryDates(
provider, destination, _holidayCalendar);
options.AddRange(rates.Select(r => new ShippingOption(
provider.Name,
r.Service,
r.Cost,
deliveryDates[r.Service]
)));
}
return new ShippingOptions(
options.OrderBy(o => o.Cost).ToList(),
preferences.PreferredCarrier
);
}
}
Example 3: Pricing Rules (Domain Policy)
public interface IPricingService
{
Money CalculatePrice(
Product product,
Customer customer,
int quantity,
DateTime date);
}
public class PricingService : IPricingService
{
private readonly IReadOnlyList<IPricingRule> _rules;
public Money CalculatePrice(
Product product,
Customer customer,
int quantity,
DateTime date)
{
var context = new PricingContext(product, customer, quantity, date);
var basePrice = product.BasePrice;
// Apply rules in order
foreach (var rule in _rules.OrderBy(r => r.Priority))
{
if (rule.AppliesTo(context))
{
basePrice = rule.Apply(basePrice, context);
}
}
return basePrice;
}
}
// Individual pricing rules
public class VolumeDiscountRule : IPricingRule
{
public int Priority => 1;
public bool AppliesTo(PricingContext context) => context.Quantity >= 10;
public Money Apply(Money price, PricingContext context)
{
var discount = context.Quantity switch
{
>= 100 => 0.20m,
>= 50 => 0.15m,
>= 10 => 0.10m,
_ => 0m
};
return price.MultiplyBy(1 - discount);
}
}
public class LoyaltyDiscountRule : IPricingRule
{
public int Priority => 2;
public bool AppliesTo(PricingContext context) =>
context.Customer.LoyaltyTier >= LoyaltyTier.Gold;
public Money Apply(Money price, PricingContext context)
{
var discount = context.Customer.LoyaltyTier switch
{
LoyaltyTier.Platinum => 0.10m,
LoyaltyTier.Gold => 0.05m,
_ => 0m
};
return price.MultiplyBy(1 - discount);
}
}
Example 4: Uniqueness Check (Requires Repository)
public interface IEmailUniquenessChecker
{
Task<bool> IsEmailUnique(EmailAddress email, CustomerId? excludeCustomerId = null);
}
public class EmailUniquenessChecker : IEmailUniquenessChecker
{
private readonly ICustomerRepository _customerRepository;
public async Task<bool> IsEmailUnique(
EmailAddress email,
CustomerId? excludeCustomerId = null)
{
var existingCustomer = await _customerRepository.GetByEmail(email);
if (existingCustomer == null)
return true;
// Allow if it's the same customer (updating their own email)
return excludeCustomerId != null &&
existingCustomer.Id == excludeCustomerId;
}
}
// Usage in application service
public class CustomerApplicationService
{
private readonly IEmailUniquenessChecker _emailChecker;
public async Task RegisterCustomer(RegisterCustomerCommand command)
{
if (!await _emailChecker.IsEmailUnique(command.Email))
throw new EmailAlreadyExistsException(command.Email);
var customer = Customer.Register(command.Email, command.Name);
await _customerRepository.Save(customer);
}
}
Domain Service vs Other Services
Comparison
| Aspect | Domain Service | Application Service | Infrastructure Service |
|---|---|---|---|
| Contains | Domain logic | Orchestration | Technical concerns |
| State | Stateless | Stateless | May have state |
| Dependencies | Domain objects | Domain + Infra services | External systems |
| Location | Domain layer | Application layer | Infrastructure layer |
| Example | PricingService | OrderApplicationService | EmailService |
Implementation Patterns
Pattern 1: Interface + Implementation
// Interface in domain layer
namespace Domain.Shipping
{
public interface IShippingCostCalculator
{
Money Calculate(Order order, Address destination);
}
}
// Implementation in domain layer (if no external dependencies)
namespace Domain.Shipping
{
public class ShippingCostCalculator : IShippingCostCalculator
{
public Money Calculate(Order order, Address destination)
{
var weight = order.TotalWeight;
var zone = DetermineZone(destination);
return CalculateByZone(weight, zone);
}
}
}
Pattern 2: Static Method (Simple Cases)
// For simple, pure functions
public static class TaxCalculator
{
public static Money CalculateTax(Money amount, Address address)
{
var rate = GetTaxRate(address.State);
return amount.MultiplyBy(rate);
}
private static decimal GetTaxRate(string state) => state switch
{
"CA" => 0.0725m,
"NY" => 0.08m,
"OR" => 0m, // No sales tax
_ => 0.05m // Default
};
}
Pattern 3: Domain Service with Dependencies
public class InventoryAllocationService
{
private readonly IInventoryRepository _inventoryRepository;
private readonly IWarehouseSelector _warehouseSelector;
public InventoryAllocationService(
IInventoryRepository inventoryRepository,
IWarehouseSelector warehouseSelector)
{
_inventoryRepository = inventoryRepository;
_warehouseSelector = warehouseSelector;
}
public async Task<AllocationResult> AllocateForOrder(Order order)
{
var allocations = new List<Allocation>();
foreach (var line in order.Lines)
{
var warehouse = await _warehouseSelector.SelectBest(
line.ProductId,
order.ShippingAddress);
var inventory = await _inventoryRepository.GetByWarehouse(
line.ProductId,
warehouse.Id);
if (!inventory.CanAllocate(line.Quantity))
return AllocationResult.InsufficientStock(line.ProductId);
inventory.Allocate(line.Quantity, order.Id);
allocations.Add(new Allocation(warehouse.Id, line.ProductId, line.Quantity));
}
return AllocationResult.Success(allocations);
}
}
Common Mistakes
Mistake 1: Anemic Domain Model (Too Many Services)
// ❌ Logic that belongs in entity is in service
public class OrderService
{
public void AddLineToOrder(Order order, Product product, int quantity)
{
var line = new OrderLine(product.Id, quantity, product.Price);
order.Lines.Add(line);
order.Total = order.Lines.Sum(l => l.Price * l.Quantity);
}
}
// ✅ Logic belongs in entity
public class Order
{
public void AddLine(ProductId productId, int quantity, Money price)
{
var line = new OrderLine(productId, quantity, price);
_lines.Add(line);
RecalculateTotal();
}
}
Mistake 2: Domain Service with State
// ❌ Stateful domain service
public class PricingService
{
private Customer _currentCustomer; // State!
private List<Product> _cart; // State!
public void SetCustomer(Customer customer)
{
_currentCustomer = customer;
}
public Money CalculateTotal()
{
// Uses stored state
}
}
// ✅ Stateless domain service
public class PricingService
{
public Money CalculateTotal(Customer customer, IEnumerable<CartItem> items)
{
// All inputs passed as parameters
}
}
Mistake 3: Infrastructure in Domain Service
// ❌ Domain service calling external API
public class CurrencyConversionService
{
private readonly HttpClient _httpClient;
public async Task<Money> Convert(Money amount, Currency targetCurrency)
{
var rate = await _httpClient.GetAsync($"/rates/{amount.Currency}/{targetCurrency}");
// ...
}
}
// ✅ Use interface, implement in infrastructure
public interface ICurrencyConverter
{
Task<Money> Convert(Money amount, Currency targetCurrency);
}
// Domain service uses interface
public class InternationalPricingService
{
private readonly ICurrencyConverter _currencyConverter;
public async Task<Money> GetLocalPrice(Product product, Currency localCurrency)
{
return await _currencyConverter.Convert(product.Price, localCurrency);
}
}
// Infrastructure implements interface
public class ExchangeRateApiCurrencyConverter : ICurrencyConverter
{
private readonly HttpClient _httpClient;
// Implementation with HTTP calls
}
Staff+ Interview Questions
Q: How do you decide between putting logic in an entity vs domain service?
A: I ask:
- Does it operate on a single entity's state? → Entity
- Does it span multiple aggregates? → Domain service
- Is it a significant domain concept? → Domain service
- Is it stateless and uses multiple domain objects? → Domain service
Default to entity. Use domain service when logic genuinely doesn't fit.
Q: What's the difference between domain service and application service?
A:
- Domain service: Contains domain logic, uses domain language, in domain layer
- Application service: Orchestrates use cases, handles transactions, coordinates services
// Domain service - domain logic
public class PricingService
{
public Money CalculatePrice(Product p, Customer c) { /* domain rules */ }
}
// Application service - orchestration
public class OrderApplicationService
{
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
var customer = await _customerRepo.GetById(cmd.CustomerId);
var price = _pricingService.CalculatePrice(product, customer);
var order = Order.Create(customer.Id, price);
await _orderRepo.Save(order);
}
}
Q: Can domain services depend on repositories?
A: Yes, when the domain logic requires querying (like uniqueness checks). But be careful:
- Keep it focused on domain logic
- Don't turn it into an application service
- Consider if the logic should be in an application service instead
Quick Reference Card
┌─────────────────────────────────────────────────────────┐
│ DOMAIN SERVICE QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ WHEN TO USE │
│ • Operation spans multiple aggregates │
│ • Logic doesn't fit in single entity │
│ • Significant domain concept │
│ • Stateless behavior │
│ │
│ CHARACTERISTICS │
│ • Stateless │
│ • Named with domain verbs │
│ • Uses domain language │
│ • Lives in domain layer │
│ │
│ EXAMPLES │
│ • MoneyTransferService │
│ • PricingService │
│ • ShippingCalculator │
│ • EmailUniquenessChecker │
│ │
│ AVOID │
│ • Logic that belongs in entity │
│ • Stateful services │
│ • Infrastructure concerns │
│ │
└─────────────────────────────────────────────────────────┘
Next Steps
- Application Services - Orchestrating domain services
- Aggregates - Where entity logic lives
- Testing DDD Code - Testing domain services