What are Domain Services and when should you use them?
Quick Answer
Domain Services hold stateless domain logic that doesn't naturally belong to a single entity or value object — typically operations spanning multiple aggregates or rules expressed in the ubiquitous language. They contain business logic (unlike application services, which orchestrate use cases and infrastructure). Use one when forcing the behavior into an entity would distort the model; keep it focused on the domain, not on technical concerns.
Detailed Answer
Domain Services are stateless services that contain business logic that doesn't naturally belong to entities or value objects. They represent operations that involve multiple domain objects or complex business rules that span across different aggregates.
When to Use Domain Services:
- Operations involving multiple aggregates
- Complex business logic that doesn't fit in entities
- Domain calculations that require external data
- Business rules that span multiple domain objects
Example: Order Pricing Service
public interface IDomainService
{
}
public class OrderPricingService : IDomainService
{
private readonly ICustomerRepository _customerRepository;
private readonly IProductRepository _productRepository;
public OrderPricingService(ICustomerRepository customerRepository, IProductRepository productRepository)
{
_customerRepository = customerRepository;
_productRepository = productRepository;
}
public Money CalculateOrderTotal(Order order)
{
var baseTotal = order.Items.Sum(item => item.Total);
var customer = _customerRepository.GetById(order.CustomerId);
// Apply customer-specific discount
var customerDiscount = CalculateCustomerDiscount(customer, baseTotal);
// Apply bulk order discount
var bulkDiscount = CalculateBulkDiscount(order.Items.Count(), baseTotal);
// Apply seasonal discount
var seasonalDiscount = CalculateSeasonalDiscount(baseTotal);
var totalDiscount = customerDiscount + bulkDiscount + seasonalDiscount;
return baseTotal - totalDiscount;
}
private Money CalculateCustomerDiscount(Customer customer, Money baseTotal)
{
var discountPercentage = customer.Status switch
{
CustomerStatus.VIP => 15m,
CustomerStatus.Premium => 10m,
CustomerStatus.Regular => 5m,
_ => 0m
};
return baseTotal * (discountPercentage / 100);
}
private Money CalculateBulkDiscount(int itemCount, Money baseTotal)
{
var discountPercentage = itemCount switch
{
>= 20 => 10m,
>= 10 => 5m,
_ => 0m
};
return baseTotal * (discountPercentage / 100);
}
private Money CalculateSeasonalDiscount(Money baseTotal)
{
var currentMonth = DateTime.Now.Month;
var isHolidaySeason = currentMonth == 12 || currentMonth == 1; // December or January
return isHolidaySeason ? baseTotal * 0.05m : new Money(0, baseTotal.Currency);
}
}
Example: Order Validation Service
public class OrderValidationService : IDomainService
{
private readonly IInventoryService _inventoryService;
private readonly ICustomerService _customerService;
public OrderValidationService(IInventoryService inventoryService, ICustomerService customerService)
{
_inventoryService = inventoryService;
_customerService = customerService;
}
public ValidationResult ValidateOrder(Order order)
{
var errors = new List<string>();
// Validate customer
var customerValidation = ValidateCustomer(order.CustomerId);
errors.AddRange(customerValidation.Errors);
// Validate inventory
var inventoryValidation = ValidateInventory(order.Items);
errors.AddRange(inventoryValidation.Errors);
// Validate business rules
var businessRuleValidation = ValidateBusinessRules(order);
errors.AddRange(businessRuleValidation.Errors);
return new ValidationResult(errors);
}
private ValidationResult ValidateCustomer(CustomerId customerId)
{
var customer = _customerService.GetCustomer(customerId);
var errors = new List<string>();
if (customer == null)
errors.Add("Customer not found");
else if (customer.Status == CustomerStatus.Suspended)
errors.Add("Customer account is suspended");
else if (customer.Status == CustomerStatus.Closed)
errors.Add("Customer account is closed");
return new ValidationResult(errors);
}
private ValidationResult ValidateInventory(IEnumerable<OrderItem> items)
{
var errors = new List<string>();
foreach (var item in items)
{
var availableQuantity = _inventoryService.GetAvailableQuantity(item.ProductId);
if (availableQuantity < item.Quantity.Value)
{
errors.Add($"Insufficient inventory for product {item.ProductId}. Available: {availableQuantity}, Requested: {item.Quantity.Value}");
}
}
return new ValidationResult(errors);
}
private ValidationResult ValidateBusinessRules(Order order)
{
var errors = new List<string>();
// Business rule: Minimum order amount
if (order.Total.Amount < 10)
errors.Add("Minimum order amount is $10");
// Business rule: Maximum items per order
if (order.Items.Count() > 50)
errors.Add("Maximum 50 items per order");
// Business rule: No orders on weekends for certain products
if (IsWeekend() && HasRestrictedProducts(order.Items))
errors.Add("Orders with restricted products cannot be placed on weekends");
return new ValidationResult(errors);
}
private bool IsWeekend()
{
var dayOfWeek = DateTime.Now.DayOfWeek;
return dayOfWeek == DayOfWeek.Saturday || dayOfWeek == DayOfWeek.Sunday;
}
private bool HasRestrictedProducts(IEnumerable<OrderItem> items)
{
var restrictedProductIds = new[] { "ALCOHOL", "TOBACCO" };
return items.Any(item => restrictedProductIds.Contains(item.ProductId.Value));
}
}
public class ValidationResult
{
public bool IsValid => !Errors.Any();
public List<string> Errors { get; }
public ValidationResult(List<string> errors)
{
Errors = errors ?? new List<string>();
}
}
Example: Shipping Cost Calculation Service
public class ShippingCostCalculationService : IDomainService
{
private readonly IShippingRateRepository _shippingRateRepository;
private readonly IAddressValidationService _addressValidationService;
public ShippingCostCalculationService(
IShippingRateRepository shippingRateRepository,
IAddressValidationService addressValidationService)
{
_shippingRateRepository = shippingRateRepository;
_addressValidationService = addressValidationService;
}
public Money CalculateShippingCost(Order order, Address shippingAddress)
{
// Validate address
if (!_addressValidationService.IsValidAddress(shippingAddress))
throw new InvalidOperationException("Invalid shipping address");
// Get shipping rates
var shippingRates = _shippingRateRepository.GetRatesForAddress(shippingAddress);
// Calculate package weight and dimensions
var packageInfo = CalculatePackageInfo(order.Items);
// Find best shipping option
var bestRate = FindBestShippingRate(shippingRates, packageInfo);
return bestRate.Cost;
}
private PackageInfo CalculatePackageInfo(IEnumerable<OrderItem> items)
{
var totalWeight = items.Sum(item => item.Product.Weight * item.Quantity.Value);
var totalVolume = items.Sum(item => item.Product.Volume * item.Quantity.Value);
return new PackageInfo(totalWeight, totalVolume);
}
private ShippingRate FindBestShippingRate(IEnumerable<ShippingRate> rates, PackageInfo packageInfo)
{
var applicableRates = rates.Where(rate =>
rate.MaxWeight >= packageInfo.Weight &&
rate.MaxVolume >= packageInfo.Volume);
return applicableRates.OrderBy(rate => rate.Cost.Amount).First();
}
}
public class PackageInfo
{
public decimal Weight { get; }
public decimal Volume { get; }
public PackageInfo(decimal weight, decimal volume)
{
Weight = weight;
Volume = volume;
}
}
Domain Service vs Application Service:
// Domain Service - contains business logic
public class OrderPricingService : IDomainService
{
public Money CalculateOrderTotal(Order order)
{
// Pure business logic
var baseTotal = order.Items.Sum(item => item.Total);
var discount = CalculateDiscount(order);
return baseTotal - discount;
}
private Money CalculateDiscount(Order order)
{
// Complex business rules for discount calculation
// This is domain logic, not application logic
}
}
// Application Service - orchestrates domain operations
public class OrderApplicationService
{
private readonly IOrderRepository _orderRepository;
private readonly OrderPricingService _pricingService;
private readonly IUnitOfWork _unitOfWork;
public async Task<OrderId> CreateOrderAsync(CreateOrderCommand command)
{
// Application logic - orchestration
var order = new Order(command.CustomerId);
foreach (var item in command.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
}
// Use domain service for business logic
var total = _pricingService.CalculateOrderTotal(order);
order.SetTotal(total);
await _orderRepository.SaveAsync(order);
await _unitOfWork.CommitAsync();
return order.Id;
}
}
Best Practices for Domain Services:
- Keep them stateless: Domain services should not maintain state
- Use sparingly: Prefer methods on entities when possible
- Single responsibility: Each service should have one clear purpose
- Pure business logic: Don't mix infrastructure concerns
- Testable: Should be easy to unit test
// ✅ Good: Stateless domain service
public class TaxCalculationService : IDomainService
{
public Money CalculateTax(Order order, Address shippingAddress)
{
// Pure business logic for tax calculation
var taxRate = GetTaxRateForAddress(shippingAddress);
return order.Total * taxRate;
}
private decimal GetTaxRateForAddress(Address address)
{
// Business logic for determining tax rate
return address.State switch
{
"CA" => 0.0875m,
"NY" => 0.08m,
"TX" => 0.0625m,
_ => 0.05m
};
}
}
// ❌ Bad: Domain service with infrastructure concerns
public class BadTaxCalculationService : IDomainService
{
private readonly HttpClient _httpClient; // Infrastructure concern
public async Task<Money> CalculateTaxAsync(Order order, Address address)
{
// This mixes domain logic with infrastructure
var response = await _httpClient.GetAsync($"https://tax-api.com/rate/{address.State}");
var taxRate = await response.Content.ReadAsStringAsync();
return order.Total * decimal.Parse(taxRate);
}
}
When NOT to Use Domain Services:
- Simple operations: Use entity methods instead
- Infrastructure concerns: Use application services
- Cross-cutting concerns: Use application services or middleware
- Data access: Use repositories
// ❌ Don't use domain service for simple operations
public class BadOrderService : IDomainService
{
public void UpdateOrderStatus(Order order, OrderStatus status)
{
order.UpdateStatus(status); // This should be a method on Order entity
}
}
// ✅ Use entity method instead
public class Order : AggregateRoot<OrderId>
{
public void UpdateStatus(OrderStatus status)
{
// Business logic for status update
if (Status == OrderStatus.Shipped && status == OrderStatus.Draft)
throw new InvalidOperationException("Cannot revert shipped order to draft");
Status = status;
}
}
Key Takeaways:
- Domain Services contain business logic that doesn't belong to entities or value objects
- Use them sparingly - prefer entity methods when possible
- Keep them stateless and focused on business logic
- Don't mix infrastructure concerns - keep them pure
- Test them thoroughly as they contain important business rules