Skip to main content

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

ConceptDefinition
Domain ServiceStateless operation with domain logic
When to UseLogic spans multiple aggregates or doesn't fit in one entity
CharacteristicsNamed with domain verbs, no state, uses domain language
LocationDomain layer, alongside entities

When to Use Domain Services

Use Domain Service When:

  1. Operation spans multiple aggregates
  2. Logic doesn't belong to any single entity
  3. Operation is a significant domain concept
  4. Stateless behavior is natural

Don't Use Domain Service When:

  1. Logic belongs to an entity - Put it there
  2. It's just orchestration - Use application service
  3. 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

AspectDomain ServiceApplication ServiceInfrastructure Service
ContainsDomain logicOrchestrationTechnical concerns
StateStatelessStatelessMay have state
DependenciesDomain objectsDomain + Infra servicesExternal systems
LocationDomain layerApplication layerInfrastructure layer
ExamplePricingServiceOrderApplicationServiceEmailService

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:

  1. Does it operate on a single entity's state? → Entity
  2. Does it span multiple aggregates? → Domain service
  3. Is it a significant domain concept? → Domain service
  4. 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