Skip to main content

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.

  1. It holds no state (stateless)
  2. It expresses a domain operation
  3. 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.

Cases where you should use a domain service
  1. It is pure domain logic (no infrastructure dependency)
  2. It is processing that spans multiple entities/value objects
  3. 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.

Cases where you should not use a domain service
  • 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.

PlacementConditionExample
EntityProcessing contained within a single entityOrder.confirm(), Order.cancel()
Domain serviceSpans multiple entities but needs no infrastructurePrice calculation, applying a discount
Use caseNeeds 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?

  1. It is pure calculation logic (no DB or external API access)
  2. It needs information from both the Order aggregate and the Customer aggregate
  3. 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

AspectDomain serviceUse case (application service)
LayerDomain layerApplication layer
ResponsibilityExecuting business logicCoordinating the workflow
Infrastructure dependencyNoneYes (repository, external API, etc.)
TransactionDoes not handleHandles
DB accessDoes notDoes (via the repository)
State managementStatelessStateless
TestingCan be tested like a pure functionNeeds mocks

The difference in roles

The role of a domain service
  • 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
The role of a use case
  • 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.

CharacteristicDomain serviceUse case
LayerDomain layerApplication layer
Infrastructure dependencyNoneYes (repositories, etc.)
TransactionDoes not handleHandles
ResponsibilityDomain logicCoordinating 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

GuidelineDescription
Hold no stateA domain service should be stateless
Do not depend on infrastructureDo not use repositories and the like directly
Use the domain languageExpress method names with business terms
Single responsibilityOne service focuses on one responsibility
Do not overuseFirst consider whether you can place it in an entity
Anti-pattern: the anemic domain model

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

PointDescription
The definition of a domain serviceExpresses business logic that does not naturally belong to an entity or a value object
When to use itOnly for calculations/operations that span multiple aggregates and have no infrastructure dependency
The difference from a use caseDomain service = business logic, use case = coordinating the workflow
Anti-patternPlacing 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.