What are Domain Services and when should you use them?

7 minadvancedDDDdomain-servicesarchitecture

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:

  1. Operations involving multiple aggregates
  2. Complex business logic that doesn't fit in entities
  3. Domain calculations that require external data
  4. 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:

  1. Keep them stateless: Domain services should not maintain state
  2. Use sparingly: Prefer methods on entities when possible
  3. Single responsibility: Each service should have one clear purpose
  4. Pure business logic: Don't mix infrastructure concerns
  5. 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:

  1. Simple operations: Use entity methods instead
  2. Infrastructure concerns: Use application services
  3. Cross-cutting concerns: Use application services or middleware
  4. 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:

  1. Domain Services contain business logic that doesn't belong to entities or value objects
  2. Use them sparingly - prefer entity methods when possible
  3. Keep them stateless and focused on business logic
  4. Don't mix infrastructure concerns - keep them pure
  5. Test them thoroughly as they contain important business rules

Related Resources