Domain Services are stateless operations that don’t naturally belong to entities or value objects, but are still part of the domain model.
⚠️ Warning: Avoid generic services named after entities (OrderService, CustomerService, ProductService). These are code smells that often indicate an anemic domain model. Domain services should have specific, verb-based names describing what they do.
When to Use Domain Services
Default Rule: Behavior belongs on entities and value objects. Only use domain services when the operation truly doesn’t belong to any single aggregate.
Operations Spanning Multiple Aggregates
When behavior involves multiple aggregates but doesn’t belong to any single one.
// Not right on Order or Customer
public class PricingService
{
public Money CalculatePrice(Order order, Customer customer, List<Promotion> promotions)
{
var basePrice = order.CalculateSubtotal();
var customerDiscount = customer.GetLoyaltyDiscount();
var promotionalDiscount = promotions.GetBestDiscount(order);
return basePrice.Apply(customerDiscount).Apply(promotionalDiscount);
}
}Domain Operations Without Natural Home
Operations that are clearly domain logic but don’t fit on an entity or value object.
Money transfer is the classic case: it belongs to neither Account, so a domain
service is the right home for the decision. But the service must still respect
the aggregate boundary — one aggregate per transaction. So it debits the source
and lets a separate transaction handle the credit, driven by an event. (Mutating
and saving both accounts in one call is the Multi-Aggregate Transactions anti-pattern.)
public class TransferMoneyService
{
// The operation doesn't belong on either Account, and it must not
// modify both aggregates in one transaction. A service has no event
// list of its own, so the source aggregate records the fact: TransferOut
// collects TransferInitiated, which the dispatcher fires after the commit
// so a saga can credit the destination in its own transaction.
public void Transfer(Account from, AccountId to, Money amount)
{
from.TransferOut(amount, to);
}
}Calculations or Algorithms
Complex domain calculations that would bloat entities.
public class ShippingCostCalculator
{
public Money Calculate(Address destination, Weight packageWeight, ShippingMethod method)
{
// Complex shipping cost algorithm
var distance = _distanceService.Calculate(destination);
var baseRate = method.GetBaseRate();
var weightMultiplier = CalculateWeightMultiplier(packageWeight);
return baseRate.MultiplyBy(distance).MultiplyBy(weightMultiplier);
}
}Characteristics
Stateless
- No internal state
- Pure functions of inputs
- Can be safely shared
// Good: Stateless
public class OrderValidationService
{
public ValidationResult Validate(Order order)
{
// Uses only inputs, no instance state
var result = new ValidationResult();
if (!order.Lines.Any())
result.AddError("Order must have at least one line");
if (order.Total <= Money.Zero)
result.AddError("Order total must be positive");
return result;
}
}
// Bad: Stateful
public class OrderValidationService
{
private ValidationResult _lastResult; // State! Bad!
public ValidationResult Validate(Order order)
{
_lastResult = new ValidationResult();
// ...
}
}Part of Domain Layer
- Express domain concepts
- Use ubiquitous language
- Not infrastructure services
// Domain Service: Business logic
public class RiskAssessmentService
{
public RiskLevel AssessRisk(Customer customer, Order order)
{
// Business rules about risk
if (customer.IsNew && order.Total > Money.Of(1000))
return RiskLevel.High;
return RiskLevel.Low;
}
}
// Application Service: Orchestration (NOT domain service)
public class PlaceOrderApplicationService
{
public void PlaceOrder(PlaceOrderCommand command)
{
var order = _orderRepository.GetById(command.OrderId);
order.Submit();
_orderRepository.Save(order);
_emailService.SendConfirmation(order); // Infrastructure
}
}Named with Verbs
- Describe what they do
- TransferMoney, CalculatePrice, AssessRisk
- Not OrderService, CustomerService (too generic)
Domain Service vs. Other Services
Domain Service
// Lives in Domain layer
// Contains business logic
// Works with domain objects
public class LoanApprovalService
{
public ApprovalResult Approve(LoanApplication application, CreditReport report)
{
// Business rules for loan approval
if (report.Score < 600)
return ApprovalResult.Denied("Credit score too low");
if (application.Amount > report.MaxLoanAmount)
return ApprovalResult.Denied("Amount exceeds limit");
return ApprovalResult.Approved();
}
}Application Service
// Lives in Application layer
// Orchestrates use cases
// Thin coordinator
public class LoanApplicationService
{
private readonly ILoanRepository _loanRepository;
private readonly LoanApprovalService _approvalService; // Domain service
private readonly INotificationService _notifications; // Infrastructure
public void ProcessLoanApplication(Guid applicationId)
{
var application = _loanRepository.GetById(applicationId);
var creditReport = _creditService.GetReport(application.ApplicantId);
var result = _approvalService.Approve(application, creditReport);
if (result.IsApproved)
{
application.Approve();
_notifications.SendApproval(application);
}
else
{
application.Deny(result.Reason);
_notifications.SendDenial(application);
}
_loanRepository.Save(application);
}
}Infrastructure Service
// Lives in Infrastructure layer
// Technical concerns
// External integrations
public class EmailService : IEmailService
{
private readonly SmtpClient _smtpClient;
public void SendEmail(EmailAddress to, string subject, string body)
{
// SMTP, email server, etc.
var message = new MailMessage();
message.To.Add(to.Value);
message.Subject = subject;
message.Body = body;
_smtpClient.Send(message);
}
}Examples
Currency Conversion
public class CurrencyConversionService
{
private readonly IExchangeRateProvider _exchangeRates;
public Money Convert(Money source, Currency targetCurrency)
{
if (source.Currency == targetCurrency)
return source;
var rate = _exchangeRates.GetRate(source.Currency, targetCurrency);
var convertedAmount = source.Amount * rate;
return new Money(convertedAmount, targetCurrency);
}
}Authorization
public class AuthorizationService
{
public bool CanApprove(User user, PurchaseOrder order)
{
// Business rules for approval authority
if (user.Role == Role.Manager && order.Total <= Money.Of(10000))
return true;
if (user.Role == Role.Director && order.Total <= Money.Of(50000))
return true;
if (user.Role == Role.CEO)
return true;
return false;
}
}Complex Validation
public class ProductCompatibilityService
{
public bool AreCompatible(Product product1, Product product2)
{
// Complex business rules about product compatibility
if (product1.Category != product2.Category)
return false;
if (product1.RequiredComponents.Intersect(product2.IncompatibleComponents).Any())
return false;
return true;
}
}Best Practices
Keep Logic in Entities When Possible
// Prefer: Logic in entity
public class Order
{
public void ApplyDiscount(Discount discount)
{
if (!discount.AppliesTo(this))
throw new InvalidOperationException("Discount not applicable");
_appliedDiscounts.Add(discount);
}
}
// Use service when: Involves multiple aggregates
public class DiscountValidationService
{
public bool CanCombine(Discount discount1, Discount discount2)
{
// Business rules about combining discounts
return !discount1.IsExclusive && !discount2.IsExclusive;
}
}Interface for Testing
public interface IShippingCostCalculator
{
Money Calculate(Address destination, Weight weight);
}
public class ShippingCostCalculator : IShippingCostCalculator
{
public Money Calculate(Address destination, Weight weight)
{
// Implementation
}
}
// Easy to mock in tests
var mockCalculator = new Mock<IShippingCostCalculator>();
mockCalculator.Setup(c => c.Calculate(It.IsAny<Address>(), It.IsAny<Weight>()))
.Returns(Money.Of(10));Inject Dependencies
public class PricingService
{
private readonly ICurrencyConversionService _currencyService;
private readonly IPromotionRepository _promotions;
public PricingService(
ICurrencyConversionService currencyService,
IPromotionRepository promotions)
{
_currencyService = currencyService;
_promotions = promotions;
}
public Money CalculatePrice(Order order, Currency targetCurrency)
{
var price = order.CalculateSubtotal();
var converted = _currencyService.Convert(price, targetCurrency);
var promotions = _promotions.GetActiveForOrder(order);
return ApplyPromotions(converted, promotions);
}
}Anti-Patterns
Generic Domain Services (OrderService, CustomerService, etc.)
The Problem: Services named after domain entities (OrderService, CustomerService, ProductService) are a code smell. They become dumping grounds for logic that should live elsewhere.
// BAD: Generic "OrderService" - anti-pattern!
public class OrderService
{
// This should be on Order entity
public void AddItem(Order order, Product product, int quantity)
{
order.Lines.Add(new OrderLine(...));
}
// This should be on Order entity
public Money CalculateTotal(Order order)
{
return order.Lines.Sum(l => l.Price * l.Quantity);
}
// This is application layer concern
public void CreateOrder(CreateOrderCommand command)
{
var order = new Order(...);
_repository.Save(order);
_emailService.SendConfirmation(order);
}
// This might be legitimate domain service, but wrong name
public void TransferOrder(Order from, Customer to)
{
// Cross-aggregate operation
}
}The Fix: Put behavior on entities, use specific domain services, move orchestration to application layer.
// GOOD: Logic on entity
public class Order
{
public void AddItem(Product product, int quantity)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot modify submitted order");
_lines.Add(new OrderLine(product, quantity));
}
public Money CalculateTotal()
{
return _lines.Sum(l => l.Subtotal);
}
}
// GOOD: Specific domain service with verb-based name
public class OrderTransferService
{
public void TransferOwnership(Order order, Customer newOwner)
{
// Cross-aggregate business logic
if (!newOwner.CanAcceptOrder(order))
throw new InvalidOperationException("Customer cannot accept order");
// ChangeOwner records OrderOwnershipTransferred on the aggregate;
// it's dispatched after the transaction commits.
order.ChangeOwner(newOwner.Id);
}
}
// GOOD: Application layer orchestration (NOT domain service)
public class PlaceOrderHandler
{
public void Handle(PlaceOrderCommand command)
{
var order = _orderRepository.GetById(command.OrderId);
order.Submit(); // Entity method
_orderRepository.Save(order);
_emailService.SendConfirmation(order); // Infrastructure
}
}Anemic Domain Model Enabler
// Bad: Service doing entity's job
public class AccountService
{
public void Withdraw(Account account, decimal amount)
{
if (account.Balance < amount)
throw new Exception("Insufficient funds");
account.Balance -= amount; // Direct property manipulation!
}
}
// Good: Rich entity
public class Account : AggregateRoot<AccountId>
{
public void Withdraw(Money amount)
{
if (Balance < amount)
throw new InsufficientFundsException();
Balance = Balance.Subtract(amount);
AddDomainEvent(new MoneyWithdrawn(Id, amount));
}
}God Service
// Bad: Generic service with everything
public class OrderService // Name itself is a code smell!
{
public void CreateOrder() { }
public void AddItem() { }
public void RemoveItem() { }
public void ApplyDiscount() { }
public void CalculateShipping() { }
public void ProcessPayment() { }
public void SendConfirmation() { }
// ... 50 more methods that belong in different places
}
// Good: Focused, specific services with verb-based names
public class PricingCalculator { } // Domain service
public class ShippingCostCalculator { } // Domain service
public class OrderOwnershipTransferService { } // Domain serviceRelated Concepts
- Entities - Where most behavior should live
- Value Objects - Can also contain behavior
- Aggregates - Domain services often coordinate between them
- Domain Driven Design - Overall pattern context