~/blog/services-ddd-series
Services (DDD series part 8)
Part 8 of the DDD series: what belongs in a domain service, what belongs on an entity or a value object, and how to tell which one you are looking at.
series · Domain-Driven Design (DDD) Series8 / 11
- 01What Domain-Driven Design Is (DDD series part 1)
- 02Benefits of using DDD (DDD series part 2)
- 03Who should consider using DDD? (DDD series part 3)
- 04Domain Model (DDD series part 4)
- 05Bounded Context (DDD series part 5)
- 06Value object (DDD series part 6)
- 07Entity (DDD series part 7)
- 08Services (DDD series part 8)
- 09Mastering Transactions: The Power of Aggregates in DDD
- 10Domain Events in DDD (DDD series part 10)
- 11Alternatives to Domain-Driven Design, and When to Use Them

Photo by Tarun Dhiman on Unsplash
By this point in the series you have entities that own an identity and a lifecycle, and value objects that are defined entirely by their attributes. Most behaviour fits on one or the other. A domain service exists for the behaviour that does not, and the hard part of using one well is resisting the urge to reach for it first.
This part covers what a domain service actually is, the test for whether a piece of logic belongs on an entity or in a service, why a service being stateless is the point rather than a detail, and the failure mode this pattern invites if you are not careful: a service layer that quietly absorbs every rule in the system because routing logic there is always the path of least resistance.
What makes something a service, not an entity method#
A domain service holds behaviour that does not naturally belong to a single entity or value object, usually because it needs more than one of them, or because the operation does not fit the identity of either. It has no identity of its own and holds no state between calls: the same inputs produce the same outputs regardless of when or how many times it runs.
// BAD: PricingService mutates nothing of its own, but it reaches past Order
// and pokes at CustomerTier directly instead of asking Order to decide,
// which scatters the discount rule across two places that must now agree.
final class PricingService
{
public function applyDiscount(Order $order, CustomerTier $tier): float
{
return $order->total() * (1 - $tier->discountRate());
}
}// GOOD: the service's job shrinks to "ask the right objects and combine the
// answer." Order and ShippingZone each own the rule that is theirs; the
// service owns none of the rules, only the sequencing between them.
final class CheckoutPricingService
{
public function totalDue(Order $order, ShippingZone $zone): Money
{
return $order->subtotal()
->plus($zone->shippingCostFor($order->weight()))
->minus($order->appliedDiscount());
}
}The difference is where the rule lives. In the first version, the discount rate calculation is the rule, and it sits in the service because that was the easiest place to put it. In the second, every individual rule, the subtotal, the shipping cost, the discount, stays on the object that owns the data behind it. The service's only job is combining three already-correct answers, which is what makes it safe to be stateless: there is nothing left for it to get wrong on its own.
The test I actually use#
Ask where the operation's inputs live. If every piece of state it needs lives on one object, the
operation belongs on that object as a method, not in a separate service: order.cancel(), not
OrderService.cancel(order). If the operation genuinely needs two or more aggregates that have no
business knowing about each other directly, for example checking a Customer's credit limit against
an Order total before confirming the order, that coordination is a domain service's job, because
putting it on either aggregate would make that aggregate depend on the other one's internals.
final class CreditCheckService
{
public function canAfford(Customer $customer, Order $order): bool
{
return $customer->availableCredit()->isGreaterThanOrEqualTo($order->total());
}
}Customer should not know about Order, and Order should not know about a customer's credit
limit. The service is the object that is allowed to know about both, which is also exactly why it
gets dangerous if you stop applying the first test.
Where this goes wrong#
The anti-pattern DDD literature calls the "anemic domain model" starts here almost every time. A
service is created for one genuine cross-aggregate operation, and every subsequent rule gets added
to it because it is already there and routing logic into an existing service is less friction than
putting a method on the right entity. A few months later OrderService has thirty methods, half of
which belong on Order itself, and the entities underneath it are bags of getters and setters with
no behaviour left to justify calling them a domain model.
The guard against this is the same test, applied every time, not just at the service's birth: before adding a method to a service, check whether its inputs actually live on more than one aggregate. If they don't, the method belongs on the entity, and routing it through the service anyway is the anemic model forming one method at a time.
| Operation | Needs | Belongs on |
|---|---|---|
| Cancel an order | Order's own state | Order::cancel() |
| Check availability before adding to cart | InventoryService's stock data | InventoryService::checkAvailability() |
| Confirm an order against a credit limit | Customer and Order together | A domain service |
| Calculate a line item's tax | The line item's own price and category | The line item (entity or value object) |
Previous parts#
Value object (DDD series part 6)
Bounded Context (DDD series part 5)
Domain Model (DDD series part 4)
Who should consider using DDD? (DDD series part 3)
Benefits of using DDD (DDD series part 2)
Unlocking the Power of Domain Driven Design in Software Development (DDD series part 1)
$ related posts
· 19 min
What This Architecture Cost, Measured Rather Than ArguedNEW
What hexagonal architecture costs in Symfony 8: ports, layers and buses counted from the repository, and the thresholds below which it is not worth it. Part 6.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 6#ddd#hexagonal-architecture#symfony#php#software-architecture#cqrs#refactoring19 min
· 17 min
Event Sourcing Behind the Port You Already Have
Event sourcing in Symfony 8: an event-sourced repository swapped in by one DI alias, proven by the same contract test, and what the alias did not cover. Part 5.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 5#ddd#hexagonal-architecture#symfony#php#event-sourcing#postgresql#cqrs17 min
· 19 min
Domain Events Are Not Integration Events
Domain events vs integration events in Symfony 8: recorded by the aggregate, released after commit, versioned into a contract, consumed by a worker. Part 4.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 4#ddd#hexagonal-architecture#symfony#php#messenger#rabbitmq#domain-events19 min