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, or that would be unnatural to place in a particular entity
Consider a domain service only when all 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."
First, here is the minimal shape of the Customer aggregate used in this section. An order does not hold its customer (Chapter 14), so the customer is looked up from the repository by order ID.
// app/Domain/Customer/Customer.php
final class Customer
{
public function __construct(
private readonly UserId $id,
private readonly EmailAddress $email, // The recipient of the order confirmation mail (Chapter 9)
private readonly bool $isGoldMember,
) {}
public function id(): UserId { return $this->id; }
public function email(): EmailAddress { return $this->email; }
public function isGoldMember(): bool { return $this->isGoldMember; }
}
// app/Domain/Customer/CustomerRepositoryInterface.php
interface CustomerRepositoryInterface
{
// Follows orders.user_id to return the customer of an order
public function findByOrderId(OrderId $orderId): ?Customer;
}
// This book does not show an implementation of this interface; provide one in your own project
// app/Domain/Customer/Exception/CustomerNotFoundException.php
final class CustomerNotFoundException extends DomainException
{
public function __construct(OrderId $orderId)
{
parent::__construct(
message: "No customer found for order ID {$orderId->value()}",
errorCode: 'CUSTOMER_NOT_FOUND',
);
}
}
// 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()->isGreaterThanOrEqual(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. The Inventory aggregate first appears here, so its minimal shape comes first. The check for sufficient stock lives inside decrease(); callers do not repeat it.
// app/Domain/Inventory/Inventory.php
use App\Domain\Inventory\Exception\InsufficientStockException;
final class Inventory
{
public function __construct(
private readonly ProductId $productId,
private int $stock,
) {}
public function productId(): ProductId { return $this->productId; }
public function stock(): int { return $this->stock; }
/**
* Decrease the stock
* This method owns the check for insufficient stock (callers do not repeat it)
*/
public function decrease(int $quantity): void
{
if ($this->stock < $quantity) {
throw new InsufficientStockException($this->productId, $quantity, $this->stock);
}
$this->stock -= $quantity;
}
}
// app/Domain/Inventory/InventoryRepositoryInterface.php
interface InventoryRepositoryInterface
{
public function findByProductId(ProductId $productId): ?Inventory;
/**
* @param ProductId[] $productIds
* @return array<int, Inventory> keyed by the product ID value
*/
public function findByProductIds(array $productIds): array;
// Retrieves with a pessimistic lock (Chapter 15)
public function findByProductIdForUpdate(ProductId $productId): ?Inventory;
public function save(Inventory $inventory): void;
}
// app/Infrastructure/Eloquent/InventoryModel.php
final class InventoryModel extends Model
{
use HasFactory;
protected $table = 'inventories';
protected $fillable = ['product_id', 'stock'];
}
// app/Domain/Inventory/Exception/InventoryNotFoundException.php
final class InventoryNotFoundException extends DomainException
{
public function __construct(ProductId $productId)
{
parent::__construct(
message: "No stock information found for product ID {$productId->value()}",
errorCode: 'INVENTORY_NOT_FOUND',
);
}
}
// app/Domain/Order/StockAllocationService.php
use App\Domain\Inventory\Exception\InventoryNotFoundException;
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
*
* @param array<int, Inventory> $inventories keyed by the product ID value
* @throws InventoryNotFoundException|InsufficientStockException
*/
public function allocate(Order $order, array $inventories): void
{
foreach ($order->orderLines() as $line) {
// Retrieve the stock entity
$inventory = $inventories[$line->productId()->value()] ?? null;
// If the stock information does not exist, it is a business-rule violation
if ($inventory === null) {
throw new InventoryNotFoundException($line->productId());
}
// The entity performs the check and the decrement (it throws when stock is short)
$inventory->decrease($line->quantity());
}
}
}
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
{
// Build the value object once and use it for both the duplicate check and creation
$email = EmailAddress::fromString($command->email);
// The duplicate check is the use case's responsibility
if ($this->userRepository->existsByEmail($email)) {
throw new DuplicateEmailException('This email address is already in use');
}
$user = User::create(
$this->userRepository->nextIdentity(),
$email,
UserName::fromString($command->name)
);
$this->userRepository->save($user);
return $user->id();
}
}
// app/Domain/User/Exception/DuplicateEmailException.php
// The message-only shape (the same as InvalidOrderStateException in Chapter 16)
final class DuplicateEmailException extends DomainException
{
public function __construct(string $message)
{
parent::__construct(message: $message, errorCode: 'DUPLICATE_EMAIL');
}
}
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) |
| 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->totalAmount()
->add($shippingFee)
->subtract($discount);
}
}
Use case: the workflow of order processing
// Application layer: coordinates the flow of processing
use App\Domain\Order\Exception\OrderNotFoundException;
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(ConfirmOrderCommand $command): void
{
// Transaction management (the application layer's responsibility)
DB::transaction(function () use ($command) {
// 1. Retrieve data (integration with the infrastructure layer)
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// The order does not hold its customer, so look the customer up by order ID
$customer = $this->customerRepository->findByOrderId($orderId)
?? throw new CustomerNotFoundException($orderId);
// 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
// Do not store the amounts on Order; totalAmount() is computed on demand, so storing them would create a second source of truth
// (the calculated values are used outside this processing, e.g. for billing or display)
$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"
An implementation example using a domain service
Calling a domain service from a use case
The ConfirmOrderUseCase shown here is a different implementation from the one in the previous section, "A comparison with concrete examples." It includes the inventory check, and the domain services it depends on differ as well.
The use case, as the "coordinator of the workflow," combines domain services appropriately.
// app/Application/UseCase/Order/ConfirmOrderUseCase.php
use App\Domain\Order\Exception\OrderNotFoundException;
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(ConfirmOrderCommand $command): void
{
// Transaction management (the application layer's responsibility)
DB::transaction(function () use ($command) {
// 1. Retrieve the order (integration with the infrastructure layer)
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// 2. Calculate the shipping fee with a domain service (business logic)
// Do not store the amount on Order; the result is used outside this processing
$shippingFee = $this->shippingFeeService->calculate($order);
// 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 |
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.