Skip to content

// Domain-Driven Design · 09

Domain Service

Rule spans two objectsleads toStateless domain service

Domain logic that fits no single entity, in a stateless service.

How it fits together

Domain Service structureApplicationDomain«client»Transfer endpointPOST /transfersAccount repositoryaccounts A and BApplication serviceload, call, commitno business rulesFundsTransferdomain service, statelesssame currencylimit 5,000 EUR/dayAccount AentityDebit(100 EUR)Account BentityCredit(100 EUR)1 POST2 load A, B3 Transfer()4 Debit5 Credit6 commit
uses / callscreates· numbered steps replay in order
  • Transfer endpoint uses / calls Application service (1 POST)
  • Application service uses / calls Account repository (2 load A, B)
  • Application service uses / calls FundsTransfer (3 Transfer())
  • FundsTransfer uses / calls Account A (4 Debit)
  • FundsTransfer uses / calls Account B (5 Credit)
  • Application service creates Account repository (6 commit)

Spot it

  • Account.Transfer reaches into another account
  • Business rules buried in controllers
  • An entity method needs data it does not own

It costs

Too many domain services drain behaviour from entities into an anemic model.

In code

public sealed class FundsTransfer{    public void Transfer(Account from, Account to, decimal amount, decimal sentToday)    {        if (from.Currency != to.Currency)            throw new DomainException("Accounts must share a currency.");        if (sentToday + amount > 5_000m)            throw new DomainException("Daily transfer limit exceeded.");        from.Debit(amount); // Account guards its own overdraft rule        to.Credit(amount);    }}​// Application service: orchestrates the use case, holds no rules.public sealed class TransferMoneyHandler(    IAccountRepository accounts,    IUnitOfWork unitOfWork,    FundsTransfer funds){    public async Task HandleAsync(TransferMoney command, CancellationToken ct)    {        var from = await accounts.GetAsync(command.FromId, ct);        var to = await accounts.GetAsync(command.ToId, ct);        var sentToday = await accounts.SentTodayAsync(command.FromId, ct);        funds.Transfer(from, to, command.Amount, sentToday);        await unitOfWork.CommitAsync(ct);    }}
Go deeperThe full explanation and what to ask for · 1 min readExpand

Some domain operations belong to no single entity: moving money between two accounts, pricing an order with a customer's discount tier, checking that a username is unique. Forcing them onto one entity makes it reach into others. A domain service is a stateless operation, named in the ubiquitous language, that takes domain objects and applies the rule.

An application service is different: it implements a use case. It loads aggregates from repositories, calls entities and domain services, commits the transaction, publishes events and deals with authorisation and I/O. It holds no business rules; delete it and the rules still live in the domain.

Put behaviour on entities and value objects first; reach for a domain service only when no single object owns the rule. Changing two accounts in one transaction breaks the one-aggregate-per-transaction guideline. That can be a deliberate choice in a simple system; larger systems often model the transfer as its own aggregate and settle it with events.

Ask for it

  • domain service
  • application service
  • stateless
  • use case orchestration
  • anemic domain model
  • transaction boundary

Move the transfer rules out of TransfersController into a stateless FundsTransfer domain service (same currency, daily limit 5,000 EUR, no overdraft), and add an application service that loads both accounts, calls it, commits once and maps a rule violation to 422.