Domain Services in DDD: When and How to Use Them

Domain Services in DDD: When and How to Use Them

By

7 min read··

clean-architectureddddotnet

A domain service expresses business logic that does not naturally belong to one entity or value object. Use it for rules such as pricing across several domain concepts or transferring between accounts. Keep loading, transactions, and saving in an application service, and prefer an entity method whenever one entity owns the rule.

When Does a Rule Need a Domain Service?

Most business logic belongs on entities and value objects. But sometimes an operation involves multiple aggregates or doesn't naturally fit on any single entity.

That's where Domain Services come in.

A Domain Service is a stateless class in the Domain layer that encapsulates business logic that:

  • Spans multiple aggregates
  • Requires external data (pricing rules, exchange rates)
  • Represents a domain concept that isn't an entity or value object

Example: Pricing Calculation

An order may own its price calculation, but a pricing policy reused across orders, quotes, and subscriptions may deserve a separate service. This example combines a promotion and a loyalty discount, in that order. Money, DiscountCode, and LoyaltyTier are existing domain types; their operations validate currencies, discount bounds, and rounding:

public sealed class PricingService
{
    public Money CalculateTotal(
        IReadOnlyCollection<OrderLineItem> items,
        DiscountCode? discountCode,
        LoyaltyTier loyaltyTier,
        Currency currency)
    {
        var subtotal = items.Aggregate(
            Money.Zero(currency),
            (sum, item) => sum + item.TotalPrice);

        if (discountCode is not null)
        {
            subtotal = discountCode.Apply(subtotal);
        }

        var loyaltyDiscount = loyaltyTier.GetDiscountPercentage();
        if (loyaltyDiscount > 0)
        {
            subtotal = subtotal.ApplyPercentageDiscount(loyaltyDiscount);
        }

        return subtotal;
    }
}

The PricingService operates on domain objects (Money, DiscountCode, LoyaltyTier) but doesn't belong to any single aggregate.

Example: Transferring Between Accounts

A money transfer involves two aggregates - the source and destination accounts:

The service below assumes Money is validated and Account.CanDeposit checks every condition that could reject a deposit, including currency and balance limits. Withdraw returns a Result; Deposit cannot reject the operation after that precheck while these in-memory account objects remain unchanged:

public sealed class MoneyTransferService
{
    public Result Transfer(
        Account source,
        Account destination,
        Money amount)
    {
        if (source.Id == destination.Id)
        {
            return Result.Failure(TransferErrors.SameAccount);
        }

        if (amount.Amount <= 0 || !destination.CanDeposit(amount))
        {
            return Result.Failure(TransferErrors.InvalidTransfer);
        }

        var withdrawResult = source.Withdraw(amount);
        if (withdrawResult.IsFailure)
        {
            return withdrawResult;
        }

        destination.Deposit(amount);

        return Result.Success();
    }
}

The transfer coordinates a rule spanning both accounts. Prechecking the destination prevents a rejected deposit from leaving the source debited in memory. The application must persist both changes atomically in one database and handle optimistic concurrency conflicts; a domain service does not provide transaction isolation. Transfers across separate stores require a different workflow, such as a reservation and a saga.

Example: Uniqueness Validation

Checking if an email is unique requires querying the database - an entity can't do that:

public sealed class CustomerUniquenessChecker
{
    private readonly ICustomerRepository _customerRepository;

    public CustomerUniquenessChecker(ICustomerRepository customerRepository)
    {
        _customerRepository = customerRepository;
    }

    public async Task<bool> IsEmailUniqueAsync(
        Email email,
        CancellationToken cancellationToken = default)
    {
        var existingCustomer = await _customerRepository
            .GetByEmailAsync(email, cancellationToken);

        return existingCustomer is null;
    }
}

Depending on a repository interface defined in the domain does not break the dependency rule. The concern is mixing persistence orchestration into the entity's behavior. This checker uses a domain-defined ICustomerRepository; the application can call it before attempting registration.

A precheck is advisory: two requests can both see an email as available. Enforce uniqueness with a unique database index on a consistently normalized email, and translate a duplicate-key error into the application's conflict result. For email changes, exclude the current customer from the lookup.

Domain Service vs Application Service

This is a common source of confusion. Here's the difference:

  • Layer: a Domain Service lives in the Domain layer; an Application Service lives in the Application layer
  • Contains: a Domain Service contains business logic; an Application Service contains use case orchestration
  • Dependencies: a Domain Service depends only on domain objects and repository interfaces; an Application Service also depends on Domain Services, repositories, and infrastructure abstractions
  • Knows about: a Domain Service knows domain concepts only; an Application Service knows DTOs, commands, queries, and external contracts
  • State: domain services do not own mutable business state; application services may track a use case's progress

The Application Service vs Domain Service guide expands this distinction with a code-review decision test.

The PricingService above contains the pricing decision. An application service loads the customer, builds order lines from trusted catalog prices, calls CalculateTotal, creates the order, and saves it. Never accept a caller-supplied total as the authoritative price.

Here is the equivalent orchestration around the transfer service, using your application's repository, command, and result types:

public sealed class TransferMoneyCommandHandler(
    IAccountRepository accountRepository,
    MoneyTransferService transferService,
    IUnitOfWork unitOfWork)
{
    public async Task<Result> Handle(TransferMoneyCommand command, CancellationToken ct)
    {
        var source = await accountRepository.GetByIdAsync(command.SourceId, ct);
        var destination = await accountRepository.GetByIdAsync(command.DestinationId, ct);
        if (source is null || destination is null)
            return Result.Failure(TransferErrors.AccountNotFound);

        var result = transferService.Transfer(source, destination, command.Amount);
        if (result.IsFailure)
            return result;

        await unitOfWork.SaveChangesAsync(ct);
        return Result.Success();
    }
}

The Application Service coordinates. The Domain Service calculates.

The application service coordinates a use case by loading and saving through the repository and delegating the business decision to a domain service, which calculates over entities and value objects

Passing Policies Into an Aggregate

An aggregate can receive a domain policy through a method parameter when it needs help making a decision. For a pure policy, that keeps the entity's operation synchronous and easy to exercise with plain objects. Passing a service parameter alone is ordinary dependency injection; it does not necessarily constitute double dispatch.

If the policy needs I/O, one option is to fetch the information asynchronously in the application service and pass the result into the domain operation. That keeps I/O out of entity methods, at the cost of trusting the caller to supply relevant, sufficiently fresh information. An asynchronous domain-facing interface is another valid design; the dependency and the business rule matter more than the presence of Task.

Rules for Domain Services

  1. Keep them stateless. Do not store mutable business state across calls. Injected dependencies are fine.

  2. Name them after domain concepts. PricingService, MoneyTransferService, AvailabilityChecker - not OrderHelper or OrderUtils.

  3. Don't use them for everything. If the logic belongs on an entity, put it on the entity. Domain Services are for logic that does not fit a single entity or value object.

  4. Inject only domain interfaces. A Domain Service can depend on repository interfaces (defined in the Domain layer) but not on infrastructure implementations.

  5. Keep them in the Domain layer. They're domain concepts, not application orchestration.

When Not to Use a Domain Service

Don't create a Domain Service when:

  • The logic belongs on an entity - order.AddLineItem(...) doesn't need a service
  • The logic is pure orchestration - loading entities and calling save is a use case, not domain logic
  • You're hiding an anemic domain model - if your entities are data bags and all logic is in services, you need richer entities, not more services
  • The operation is technical, not business - logging, caching, and sending HTTP requests are infrastructure concerns

Registration

Register these services in Program.cs. A scoped lifetime works when a service depends on a scoped repository; a pure service can also be transient or singleton:

builder.Services.AddScoped<PricingService>();
builder.Services.AddScoped<MoneyTransferService>();
builder.Services.AddScoped<CustomerUniquenessChecker>();

If a domain service has no dependencies (like PricingService), you can skip DI entirely and instantiate it inside the handler or the aggregate. Registering it is still useful for consistency and testability.

Testing Domain Services

Pure domain services can be tested without infrastructure. Using your domain's account and money factories, an xUnit test for the same-account rule looks like this:

[Fact]
public void Transfer_fails_when_source_and_destination_are_the_same()
{
    var account = Account.Create(new CustomerId(Guid.NewGuid()), Currency.Usd);
    var service = new MoneyTransferService();

    var result = service.Transfer(account, account, Money.Create(100, Currency.Usd));

    Assert.True(result.IsFailure);
    Assert.Equal(TransferErrors.SameAccount, result.Error);
}

Also test a rejected destination, insufficient funds, currency mismatch, and successful balance changes. The uniqueness checker needs a repository substitute or integration test; that dependency does not change the rule's domain meaning. Verify database atomicity and concurrency handling separately from the service's in-memory tests.

Summary

Domain Services fill the gap between entities and application services. They handle business logic that spans multiple aggregates or requires data an entity doesn't have.

Use them sparingly. Put business logic on entities and value objects when those types own the rule, and use a domain service for decisions that do not fit there.

Thanks for reading.

And stay awesome!


Frequently Asked Questions

What is a domain service in DDD?

A domain service is a stateless class in the domain layer that holds business logic which does not naturally belong to a single entity or value object, such as operations spanning multiple aggregates or calculations requiring external data.

What is the difference between a domain service and an application service?

A domain service performs business logic and knows only domain concepts. An application service orchestrates a use case: it loads aggregates, calls domain services and entities, and persists changes. The application service coordinates; the domain service calculates.

Can a domain service access a repository?

It can depend on repository interfaces defined in the domain layer, for example to check uniqueness. It must not depend on infrastructure implementations or technical concerns like HTTP clients and caching.

Should domain services be stateless?

A domain service should not own mutable business state across calls. It can hold injected dependencies, while the entities and value objects passed to its methods carry the business state.

When should you avoid creating a domain service?

When the logic belongs on an entity, when the code is pure orchestration, or when services are hiding an anemic domain model. If all your logic lives in services, you need richer entities, not more services.

  • Aggregate Design in DDD - Rules, Boundaries, and Consistency

    Aggregates are the most important tactical pattern in Domain-Driven Design. They define consistency boundaries, enforce invariants, and protect your domain model. Here are the rules and practical guidance for designing aggregates in .NET.

  • Aggregate Root in DDD: Rules and Implementation

    The Aggregate Root is the gatekeeper of consistency in Domain-Driven Design. Here are the rules for designing aggregate roots and implementing them in C#.

  • The Always-Valid Domain Model

    An always-valid domain model enforces business invariants at construction and on every state change. Private constructors, validated value objects, and guarded methods reduce repeated validation while keeping persistence and concurrency checks explicit.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.