~/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.

Krzysztof Słomka4 min readupdated

series · Domain-Driven Design (DDD) Series8 / 11

  1. 01What Domain-Driven Design Is (DDD series part 1)
  2. 02Benefits of using DDD (DDD series part 2)
  3. 03Who should consider using DDD? (DDD series part 3)
  4. 04Domain Model (DDD series part 4)
  5. 05Bounded Context (DDD series part 5)
  6. 06Value object (DDD series part 6)
  7. 07Entity (DDD series part 7)
  8. 08Services (DDD series part 8)
  9. 09Mastering Transactions: The Power of Aggregates in DDD
  10. 10Domain Events in DDD (DDD series part 10)
  11. 11Alternatives to Domain-Driven Design, and When to Use Them

next in series →

Domain-Driven Design series: the Domain Service building block

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.

OperationNeedsBelongs on
Cancel an orderOrder's own stateOrder::cancel()
Check availability before adding to cartInventoryService's stock dataInventoryService::checkAvailability()
Confirm an order against a credit limitCustomer and Order togetherA domain service
Calculate a line item's taxThe line item's own price and categoryThe line item (entity or value object)

Previous parts#

Entity (DDD series part 7)

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