Domain Services — Business Logic That Does Not Belong to an Entity
What is a domain service?
In the previous chapter you learned about aggregates. This chapter covers the "domain service," which handles business logic that does not belong to an entity or a value object.
A domain service has the following characteristics.
- It holds no state (stateless)
- It expresses a domain operation
- It often handles processing that spans multiple aggregates
When to use a domain service
Criteria for a domain service
A domain service is the place to put "business logic that is a domain-layer concept but does not naturally belong to an entity or a value object."
Use a domain service when all of the following conditions apply.
- It is pure domain logic (no infrastructure dependency)
- It is processing that spans multiple entities/value objects
- It is processing that would be unnatural to place in a particular entity
Consider a domain service only when all three of these hold.
Conversely, the cases where you should not use a domain service are as follows.
- Processing that belongs to a single entity → place it in an entity method
- Repository or DB access is needed → place it in a use case (the application layer)
- A call to an external API is needed → place it in a use case (the application layer)
- Simple coordination with no business logic → place it in a use case (the application layer)
Use the following flowchart to decide where to put business logic.
| Placement | Condition | Example |
|---|---|---|
| Entity | Processing contained within a single entity | Order.confirm(), Order.cancel() |
| Domain service | Spans multiple entities but needs no infrastructure | Price calculation, applying a discount |
| Use case | Needs the infrastructure layer (DB, external API, etc.) | Creating an order, checking stock |
A concrete example: discount calculation
Let's consider discount calculation. This processing is a calculation that spans two aggregates: the "order" and the "customer."
// Bad: placed in the Order entity
class Order
{
public function applyDiscount(Customer $customer): void
{
// Problems:
// 1. Order would need to know the details of another aggregate, the "customer's rank"
// 2. Order would depend on Customer (the independence of the aggregates is lost)
// 3. This goes beyond Order's responsibility
if ($customer->isGoldMember()) {
$this->discount = $this->totalAmount()->percentage(10);
}
}
}
// Good: placed in a domain service
final class DiscountCalculationService
{
/**
* Calculate the discount amount applied to an order
*
* Business rule:
* - A 10% discount for gold members on orders of 10,000 yen or more
*/
public function calculateDiscount(Order $order, Customer $customer): Money
{
// Reference multiple aggregates (Order, Customer) for the calculation
// This processing is the domain concept of "discount calculation," belonging to neither Order nor Customer
if ($customer->isGoldMember() && $order->totalAmount()->isGreaterThan(new Money(10000, 'JPY'))) {
return $order->totalAmount()->percentage(10);
}
return new Money(0, 'JPY');
}
}
Why is a domain service appropriate?
- It is pure calculation logic (no DB or external API access)
- It needs information from both the Order aggregate and the Customer aggregate
- It would be unnatural to place it in either entity
Examples of implementing a domain service
Example 1: a stock allocation service
The processing of allocating stock when an order is confirmed spans the Order aggregate and the Inventory aggregate.
// app/Domain/Order/StockAllocationService.php
final class StockAllocationService
{
/**
* Allocate stock for an order
*
* Why this is a domain service:
* 1. Processing that spans the Order aggregate and the Inventory aggregate
* 2. Pure business logic (no infrastructure dependency)
* 3. The domain operation of "allocation," belonging to neither Order nor Inventory
*
* @throws InsufficientStockException
*/
public function allocate(Order $order, InventoryCollection $inventories): AllocationResult
{
$allocations = [];
foreach ($order->orderLines() as $line) {
// Retrieve the stock entity
$inventory = $inventories->findByProductId($line->productId());
// If the stock information does not exist, it is a business-rule violation
if ($inventory === null) {
throw new InsufficientStockException(
"No stock information found for product ID {$line->productId()->value()}"
);
}
// Check whether there is enough stock (a business rule)
if (!$inventory->canAllocate($line->quantity())) {
throw new InsufficientStockException(
"Insufficient stock for product ID {$line->productId()->value()}"
);
}
// Instruct the stock entity to allocate
// The entity itself performs the actual state change
$inventory->allocate($line->quantity());
$allocations[] = new Allocation($line->productId(), $line->quantity());
}
return new AllocationResult($allocations);
}
}
Example 2: a shipping-fee calculation service
Shipping-fee calculation is done based on the shipping address and the order contents.
// app/Domain/Shipping/ShippingFeeCalculationService.php
final class ShippingFeeCalculationService
{
private const BASE_FEE = 500;
private const FREE_SHIPPING_THRESHOLD = 5000;
/**
* Calculate the shipping fee for an order
*
* Business rules:
* - Free shipping for orders of 5,000 yen or more
* - An extra 500 yen for Hokkaido and Okinawa
*
* Why this is a domain service:
* 1. Calculation logic that uses both the order amount and the shipping address
* 2. Pure calculation (no infrastructure dependency)
* 3. Placing it in Order would make it know too much about the details of "shipping"
*/
public function calculate(Order $order): Money
{
// Free shipping above a certain amount (a business rule)
if ($order->totalAmount()->isGreaterThanOrEqual(new Money(self::FREE_SHIPPING_THRESHOLD, 'JPY'))) {
return new Money(0, 'JPY');
}
// Calculate the regional shipping difference
$regionFee = $this->calculateRegionFee($order->shippingAddress());
return (new Money(self::BASE_FEE, 'JPY'))->add($regionFee);
}
/**
* Calculate the extra charge by shipping region
*/
private function calculateRegionFee(ShippingAddress $address): Money
{
// Extra charge for remote islands and distant regions (a business rule)
return match ($address->prefecture()) {
'Hokkaido', 'Okinawa' => new Money(500, 'JPY'),
default => new Money(0, 'JPY'),
};
}
}
Example 3: a duplicate-check service
The email-address duplicate check at user registration needs to use a repository, so it is done in a use case rather than a domain service.
// Bad: using a repository in a domain service
final class UserRegistrationService // This is not a domain service; it is the responsibility of an application service (use case)
{
public function __construct(
private readonly UserRepositoryInterface $userRepository // Infrastructure dependency!
) {}
}
// Good: done in a use case
final class RegisterUserUseCase
{
public function __construct(
private readonly UserRepositoryInterface $userRepository
) {}
public function execute(RegisterUserCommand $command): UserId
{
// The duplicate check is the use case's responsibility
if ($this->userRepository->existsByEmail($command->email)) {
throw new DuplicateEmailException('This email address is already in use');
}
$user = User::create(
$this->userRepository->nextIdentity(),
new EmailAddress($command->email),
new UserName($command->name)
);
$this->userRepository->save($user);
return $user->id();
}
}
Domain service vs use case (application service)
These two are the most easily confused concepts, but there is a clear difference.
The main differences
| Aspect | Domain service | Use case (application service) |
|---|---|---|
| Layer | Domain layer | Application layer |
| Responsibility | Executing business logic | Coordinating the workflow |
| Infrastructure dependency | None | Yes (repository, external API, etc.) |
| Transaction | Does not handle | Handles |
| DB access | Does not | Does (via the repository) |
| State management | Stateless | Stateless |
| Testing | Can be tested like a pure function | Needs mocks |
The difference in roles
- Focuses on What: expresses the business rule itself
- "Calculate the discount," "calculate the shipping fee," "allocate stock"
- Written in words that a domain expert can understand
- Knows nothing about infrastructure details
- Focuses on How: assembles the flow of processing
- "Confirm the order," "register the user," "search for products"
- Combines domain objects and domain services to realize the processing
- Bridges the infrastructure layer (DB, external API)
A comparison with concrete examples
Domain service: price calculation
// Domain layer: pure business logic
final class OrderPriceCalculationService
{
/**
* Calculate the final price of an order
*
* Business rule:
* - Product subtotal + shipping fee - discount = final price
*/
public function calculateFinalPrice(
Order $order,
Money $shippingFee,
Money $discount
): Money {
return $order->subtotal()
->add($shippingFee)
->subtract($discount);
}
}
Use case: the workflow of order processing
// Application layer: coordinates the flow of processing
final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository, // Infrastructure dependency
private readonly CustomerRepositoryInterface $customerRepository, // Infrastructure dependency
private readonly ShippingFeeCalculationService $shippingFeeService, // Domain service
private readonly DiscountCalculationService $discountService, // Domain service
private readonly OrderPriceCalculationService $priceService, // Domain service
) {}
public function execute(OrderId $orderId): void
{
// Transaction management (the application layer's responsibility)
DB::transaction(function () use ($orderId) {
// 1. Retrieve data (integration with the infrastructure layer)
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException();
$customer = $this->customerRepository->findById($order->customerId())
?? throw new CustomerNotFoundException();
// 2. Execute business logic using domain services
$shippingFee = $this->shippingFeeService->calculate($order);
$discount = $this->discountService->calculateDiscount($order, $customer);
$finalPrice = $this->priceService->calculateFinalPrice($order, $shippingFee, $discount);
// 3. Update the entity's state
$order->setShippingFee($shippingFee);
$order->setDiscount($discount);
$order->setFinalPrice($finalPrice);
$order->confirm(); // The entity's business logic
// 4. Persist (integration with the infrastructure layer)
$this->orderRepository->save($order);
});
}
}
As you can see from this example:
- Domain service: the calculation logic itself (a pure function)
- Use case: coordinating the workflow of "retrieve → calculate → update → save"
These two are easily confused, but there is a clear difference.
| Characteristic | Domain service | Use case |
|---|---|---|
| Layer | Domain layer | Application layer |
| Infrastructure dependency | None | Yes (repositories, etc.) |
| Transaction | Does not handle | Handles |
| Responsibility | Domain logic | Coordinating the workflow |
An implementation example using a domain service
Calling a domain service from a use case
The use case, as the "coordinator of the workflow," combines domain services appropriately.
// app/Application/UseCase/Order/ConfirmOrderUseCase.php
final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository, // Infrastructure layer
private readonly InventoryRepositoryInterface $inventoryRepository, // Infrastructure layer
private readonly StockAllocationService $stockAllocationService, // Domain service
private readonly ShippingFeeCalculationService $shippingFeeService, // Domain service
) {}
/**
* Confirm an order
*
* Workflow:
* 1. Retrieve the order data
* 2. Calculate the shipping fee (domain service)
* 3. Allocate stock (domain service)
* 4. Confirm the order
* 5. Persist the changes
*/
public function execute(OrderId $orderId): void
{
// Transaction management (the application layer's responsibility)
DB::transaction(function () use ($orderId) {
// 1. Retrieve the order (integration with the infrastructure layer)
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException();
// 2. Calculate the shipping fee with a domain service (business logic)
$shippingFee = $this->shippingFeeService->calculate($order);
$order->setShippingFee($shippingFee);
// 3. Retrieve the stock data (integration with the infrastructure layer)
$productIds = array_map(fn($line) => $line->productId(), $order->orderLines());
$inventories = $this->inventoryRepository->findByProductIds($productIds);
// 4. Allocate stock with a domain service (business logic)
$this->stockAllocationService->allocate($order, $inventories);
// 5. Confirm the order (the entity's business logic)
$order->confirm();
// 6. Persist the changes (integration with the infrastructure layer)
$this->orderRepository->save($order);
foreach ($inventories as $inventory) {
$this->inventoryRepository->save($inventory);
}
});
}
}
As you can see from this code:
- Use case: coordinates data retrieval, calling domain services, and persistence
- Domain service: pure business logic (shipping-fee calculation, stock allocation)
- Entity: its own state management and business rules (confirming the order)
Design guidelines for domain services
| Guideline | Description |
|---|---|
| Hold no state | A domain service should be stateless |
| Do not depend on infrastructure | Do not use repositories and the like directly |
| Use the domain language | Express method names with business terms |
| Single responsibility | One service focuses on one responsibility |
| Do not overuse | First consider whether you can place it in an entity |
If you move all logic into domain services, your entities become mere containers of data (an anemic domain model). First place logic in entities, and consider a domain service only when that is not appropriate.
Summary
Key points
| Point | Description |
|---|---|
| The definition of a domain service | Expresses business logic that does not naturally belong to an entity or a value object |
| When to use it | Only for calculations/operations that span multiple aggregates and have no infrastructure dependency |
| The difference from a use case | Domain service = business logic, use case = coordinating the workflow |
| Anti-pattern | Placing everything in domain services leads to an anemic domain model |
The decision flow (recap)
- Does it belong to a single entity? → YES: an entity method
- Does it need infrastructure (DB, external API)? → YES: a use case (the application layer)
- Pure logic that spans multiple entities? → YES: a domain service
Next steps
A domain service is a powerful tool for expressing pure business logic, but overusing it backfires. First consider whether you can place the logic in an entity, and use a domain service only when that is unnatural.
In the next chapter, we learn about "domain events," which enable loosely coupled coordination between aggregates.