Skip to main content

Designing the Use Case Layer — Command / Query Separation and DTOs

What is the use case layer

In the previous chapter you learned about the layer structure. This chapter explains the detailed design of the application layer (use case layer).

The use case layer has the following responsibilities:

  1. Coordinating domain objects: executing the business flow
  2. Managing transactions: controlling the consistency boundary
  3. Converting input/output: bridging the outside world and the domain

Command / Query separation (CQS principle)

Design use cases by separating writes (Command) and reads (Query).

The difference between CQS and CQRS

What is CQS (Command Query Separation)?

A design principle proposed by Bertrand Meyer that refers to separation at the method level.

  • Command: changes state but returns no value (void)
  • Query: returns a value but does not change state (no side effects)

Principle: "Asking a question should not change the answer."

What is CQRS (Command Query Responsibility Segregation)?

An architectural pattern proposed by Greg Young that refers to separation at the model level.

  • Write model (Command Model): optimized for state changes
  • Read model (Query Model): optimized for data retrieval

Characteristics:

  • May use separate data stores for writes and reads
  • Often applied to complex systems
  • A more advanced separation strategy

This book's position: This book separates writes and reads at the UseCase level based on the CQS principle. We do not go as far as full CQRS, but by clearly separating UseCases, we make the intent of the code explicit.

TypePurposeReturn valueExample
CommandChanges statevoid or an identifierCreateOrderUseCase, ConfirmOrderUseCase
QueryRetrieves dataDTOGetOrderUseCase, ListOrdersUseCase

The Command pattern

The design intent of the Command pattern

The Command pattern is a design pattern that encapsulates a "request" as an object. In the use case layer, we use Commands for the following purposes.

Why use Command objects?

1. Improved type safety

// NG: many primitive-type arguments (easy to get the order wrong)
$useCase->execute('Tokyo', 'Shibuya', '1-1-1', $items);

// OK: make the intent clear with a Command object
$command = new CreateOrderCommand(
prefecture: 'Tokyo',
city: 'Shibuya',
street: '1-1-1',
items: $items,
);
$useCase->execute($command);

2. Clearer validation

  • The validity of the data is guaranteed at the point the Command object is created
  • The UseCase is guaranteed to receive valid data

3. Ease of refactoring

  • Adding or removing arguments is completed by changing only the Command class
  • The UseCase signature does not change (fewer breaking changes)

4. Ease of testing

  • Preparing test data is clear (just create a Command)
  • Factory methods make it easy to create Commands for tests

Designing the Command class

A Command represents the input data to a use case.

// app/Application/UseCase/Order/CreateOrderCommand.php

// final class: this class cannot be inherited (prevents unintended extension)
final class CreateOrderCommand
{
/**
* Document the array structure with PHPDoc (used by IDE completion and PHPStan type checking)
* @param array<array{productId: int, quantity: int, unitPrice: int}> $items
*/
public function __construct(
// PHP 8.0+: constructor promotion
// declares the argument and defines the property at the same time
// readonly: cannot be changed once set (PHP 8.1+)
public readonly string $prefecture,
public readonly string $city,
public readonly string $street,
public readonly array $items,
) {}

/**
* Factory method that creates a Command from an array
* Useful when converting HTTP request data (an array) into a Command
*/
public static function fromArray(array $data): self
{
return new self(
$data['shipping']['prefecture'],
$data['shipping']['city'],
$data['shipping']['street'],
$data['items'],
);
}
}

// app/Application/UseCase/Order/ConfirmOrderCommand.php
final class ConfirmOrderCommand
{
public function __construct(
public readonly int $orderId,
) {}
}

// app/Application/UseCase/Order/CancelOrderCommand.php
final class CancelOrderCommand
{
public function __construct(
public readonly int $orderId,
public readonly string $reason,
) {}
}

The UseCase that handles a Command

// app/Application/UseCase/Order/CreateOrderUseCase.php

final class CreateOrderUseCase
{
public function __construct(
// Dependency injection (DI): Laravel's ServiceContainer automatically injects the implementation class
// Depending on the interface keeps it independent of implementation details (Eloquent, etc.)
private readonly OrderRepositoryInterface $orderRepository,
) {}

/**
* Executes the order creation use case
* @return OrderId the ID of the created order (creation operations commonly return an identifier)
*/
public function execute(CreateOrderCommand $command): OrderId
{
// Step 1: issue a new order ID
// Let the repository generate the next ID (DB sequence, UUID generation, etc.; the implementation is hidden)
$orderId = $this->orderRepository->nextIdentity();

// Step 2: create the shipping address value object
// Convert the Command's data (primitive types) into a domain value object
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street,
);

// Step 3: create a new order entity
// Use the factory method to create an order in its initial state (DRAFT)
$order = Order::create($orderId, $shippingAddress);

// Step 4: add items to the order
// The UseCase only "coordinates" domain objects
// Business rules (e.g. items cannot be added after confirmation) are judged inside Order.addItem()
foreach ($command->items as $item) {
$order->addItem(
$this->orderRepository->nextLineIdentity(),
new ProductId($item['productId']),
$item['quantity'],
new Money($item['unitPrice'], 'JPY'),
);
}

// Step 5: persist
// Transaction handling happens inside the repository (only one aggregate is updated, so it is self-contained in the Repository)
$this->orderRepository->save($order);

return $orderId;
}
}

// app/Application/UseCase/Order/ConfirmOrderUseCase.php

final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly InventoryRepositoryInterface $inventoryRepository,
) {}

/**
* Use case for confirming an order
* Because it updates multiple aggregates (Order + Inventory), the transaction is managed inside the UseCase
*
* @throws OrderNotFoundException when the order is not found
* @throws InsufficientStockException when stock is insufficient
*/
public function execute(ConfirmOrderCommand $command): void
{
// DB::transaction(): manage the transaction inside the UseCase because multiple aggregates are updated
// If an exception occurs inside the closure, it is automatically rolled back
DB::transaction(function () use ($command) {
// use ($command): PHP syntax for referencing a variable from outside the closure

// Step 1: retrieve the order
// Use the null coalescing operator (??) and a throw expression (PHP 8.0+) for a concise null check
$order = $this->orderRepository->findById(new OrderId($command->orderId))
?? throw new OrderNotFoundException("Order ID {$command->orderId} not found");

// Step 2: check and reserve stock (update the Inventory aggregate)
// Update both the Order aggregate and the Inventory aggregate in the same transaction
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId())
?? throw new InsufficientStockException('Inventory not found');

// Decrease stock (business rules are judged inside the Inventory entity)
$inventory->decrease($line->quantity());
$this->inventoryRepository->save($inventory);
}

// Step 3: confirm the order (update the Order aggregate)
// Business rules (e.g. cannot confirm if there are no line items) are judged inside Order.confirm()
$order->confirm();
$this->orderRepository->save($order);
});
// Only reached when the transaction succeeds
}
}

The Query pattern

Designing the DTO

A Query returns a DTO in the format the presentation layer needs.

What is a DTO (Data Transfer Object)?

A DTO is a dedicated object for carrying data between layers. It has no business logic; it purely holds data.

Why a DTO is needed:

1. Keeping layers independent

// NG: return the domain entity directly
public function execute(int $orderId): Order
{
return $this->orderRepository->findById(new OrderId($orderId));
}

// Problems:
// - The presentation layer depends on the domain entity
// - Changes to the entity's internal structure affect the API response
// - All of the entity's methods are exposed externally
// OK: convert to a DTO and return it
public function execute(int $orderId): OrderDto
{
$order = $this->orderRepository->findById(new OrderId($orderId));
return OrderDto::fromEntity($order);
}

// Benefits:
// - The boundary between domain and presentation is clear
// - Only the data needed for the API response is exposed
// - Changes to the entity are less likely to affect the API

2. The optimal format for an API response

// The DTO is optimized "for display"
final class OrderDto
{
public function __construct(
public readonly int $id, // primitive type
public readonly string $status, // Enum converted to a string
public readonly string $shippingAddress, // value object converted to a string
public readonly int $totalAmount, // Money converted to an int
public readonly string $createdAt, // DateTime converted to a formatted string
) {}
}

// The entity is optimized "for the domain"
final class Order
{
public function __construct(
private OrderId $id, // value object
private OrderStatus $status, // Enum
private ShippingAddress $shippingAddress, // value object
private Money $totalAmount, // value object
private DateTimeImmutable $createdAt, // DateTime
) {}
}

3. Performance optimization

  • Use a lightweight DTO (OrderListItemDto) for list views
  • Use a complete DTO (OrderDto) for detail views
  • Retrieve and convert only the data you need

4. API versioning

// DTO for v1
final class OrderDtoV1 { /* ... */ }

// DTO for v2 (fields added)
final class OrderDtoV2 { /* ... */ }

// The domain entity is unchanged
// Only the DTO conversion logic changes

Implementing the DTO

A Query returns a DTO in the format the presentation layer needs.

// app/Application/UseCase/Order/OrderDto.php
final class OrderDto
{
/**
* @param OrderLineDto[] $orderLines
*/
public function __construct(
public readonly int $id,
public readonly string $status,
public readonly string $shippingAddress,
public readonly array $orderLines,
public readonly int $totalAmount,
public readonly string $createdAt,
) {}

public static function fromEntity(Order $order): self
{
return new self(
$order->id()->value(),
$order->status()->value,
$order->shippingAddress()->fullAddress(),
array_map(
fn($line) => OrderLineDto::fromEntity($line),
$order->orderLines()
),
$order->totalAmount()->amount(),
$order->createdAt()->format('Y-m-d H:i:s'),
);
}
}

// app/Application/UseCase/Order/OrderLineDto.php
final class OrderLineDto
{
public function __construct(
public readonly int $id,
public readonly int $productId,
public readonly int $quantity,
public readonly int $unitPrice,
public readonly int $subtotal,
) {}

public static function fromEntity(OrderLine $line): self
{
return new self(
$line->id()->value(),
$line->productId()->value(),
$line->quantity(),
$line->unitPrice()->amount(),
$line->subtotal()->amount(),
);
}
}

// app/Application/UseCase/Order/OrderListItemDto.php
// Lightweight DTO for list views
final class OrderListItemDto
{
public function __construct(
public readonly int $id,
public readonly string $status,
public readonly int $totalAmount,
public readonly string $createdAt,
) {}
}

The UseCase that handles a Query

// app/Application/UseCase/Order/GetOrderUseCase.php
final class GetOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
) {}

/**
* @throws OrderNotFoundException
*/
public function execute(int $orderId): OrderDto
{
$order = $this->orderRepository->findById(new OrderId($orderId))
?? throw new OrderNotFoundException("Order ID {$orderId} not found");

return OrderDto::fromEntity($order);
}
}

// app/Application/UseCase/Order/ListOrdersUseCase.php
final class ListOrdersUseCase
{
public function __construct(
private readonly OrderQueryServiceInterface $orderQueryService,
) {}

/**
* @return OrderListItemDto[]
*/
public function execute(ListOrdersQuery $query): array
{
return $this->orderQueryService->findAll(
status: $query->status,
limit: $query->limit,
offset: $query->offset,
);
}
}

// app/Application/UseCase/Order/ListOrdersQuery.php
final class ListOrdersQuery
{
public function __construct(
public readonly ?string $status = null,
public readonly int $limit = 20,
public readonly int $offset = 0,
) {}
}

A dedicated Query service

For complex searches and list retrieval, it is efficient to provide a dedicated Query service.

Place the Query service interface in the application layer

The Query service returns display DTOs (OrderListItemDto). Since this DTO is an application-layer type, placing the interface in the domain layer would create a "domain layer → application layer" dependency, which violates the Dependency Inversion Principle (the domain does not know about the outer layers) from Chapter 10. Reads (the read side of CQRS) are an application concern, so place the interface in the application layer and put only the implementation in the infrastructure layer.

// app/Application/Query/Order/OrderQueryServiceInterface.php
interface OrderQueryServiceInterface
{
/**
* @return OrderListItemDto[]
*/
public function findAll(?string $status, int $limit, int $offset): array;

public function countByStatus(string $status): int;
}

// app/Infrastructure/QueryService/EloquentOrderQueryService.php
final class EloquentOrderQueryService implements OrderQueryServiceInterface
{
/**
* @return OrderListItemDto[]
*/
public function findAll(?string $status, int $limit, int $offset): array
{
$query = OrderModel::query();

if ($status !== null) {
$query->where('status', $status);
}

return $query
->orderByDesc('created_at')
->offset($offset)
->limit($limit)
// Retrieve only the columns you need (avoid SELECT * to improve performance)
->select('id', 'status', 'total_amount', 'created_at')
->get()
->map(fn($model) => new OrderListItemDto(
$model->id,
$model->status,
$model->total_amount,
$model->created_at->format('Y-m-d H:i:s'),
))
->toArray();
}

public function countByStatus(string $status): int
{
return OrderModel::where('status', $status)->count();
}
}
Why use a dedicated Query service?

For list retrieval and searches, you often do not need to reconstruct the whole aggregate. Using a dedicated Query service enables performance improvements (retrieving only the columns you need), avoiding the N+1 problem, and handling complex JOIN queries.

Use case design guidelines

1. Single responsibility

A single UseCase is responsible for only one operation.

// NG: multiple operations in one UseCase
final class OrderUseCase
{
public function create(...) { }
public function confirm(...) { }
public function cancel(...) { }
}

// OK: split UseCases by operation
final class CreateOrderUseCase { }
final class ConfirmOrderUseCase { }
final class CancelOrderUseCase { }

2. Input always goes through a Command/Query

Do not receive primitive types directly; use dedicated objects.

// NG: receive primitive types directly
public function execute(string $prefecture, string $city, array $items): OrderId

// OK: receive a Command
public function execute(CreateOrderCommand $command): OrderId

3. Do not write domain logic in the UseCase

The UseCase only coordinates domain objects.

Why should you not write business logic in the UseCase?

1. It leads to duplicated business rules

// Bad example: the same rule is scattered across multiple UseCases
class ConfirmOrderUseCase {
public function execute($command): void {
// Rule: only DRAFT orders can be confirmed
if ($order->status() !== OrderStatus::DRAFT) {
throw new DomainException('Cannot confirm');
}
// ...
}
}

class BulkConfirmOrdersUseCase {
public function execute($command): void {
// The same rule written again (duplication!)
if ($order->status() !== OrderStatus::DRAFT) {
throw new DomainException('Cannot confirm');
}
// ...
}
}

2. It makes testing difficult

  • Business-rule tests get mixed into UseCase tests
  • Testing a simple business rule requires a DB and a repository
  • Test execution becomes slow

3. It makes business rules hard to discover

  • To answer "what are the conditions for confirming an order?", you must inspect multiple UseCases
  • If they are consolidated in the entity, you only need to look in one place

4. It reduces the expressive power of domain knowledge

// Bad example: procedural description (only "what to do")
if ($order->status() !== OrderStatus::DRAFT) {
throw new DomainException('Cannot confirm');
}
$order->setStatus(OrderStatus::CONFIRMED);

// Good example: expressed in domain language ("what is intended")
$order->confirm(); // ← the business intent is clear

Good design: separation of responsibilities

// NG: business logic in the UseCase
public function execute(ConfirmOrderCommand $command): void
{
$order = $this->orderRepository->findById(...);

// Business logic leaks into the UseCase
if ($order->status() !== OrderStatus::DRAFT) {
throw new DomainException('Cannot confirm');
}
if (count($order->orderLines()) === 0) {
throw new DomainException('Line items are empty');
}

$order->setStatus(OrderStatus::CONFIRMED);
$this->orderRepository->save($order);
}

// OK: business logic lives in the entity
public function execute(ConfirmOrderCommand $command): void
{
$order = $this->orderRepository->findById(...);

// The Order entity holds the business rules
$order->confirm(); // ← business rules are inside the entity

$this->orderRepository->save($order);
}
// Domain layer: Order.php
final class Order
{
public function confirm(): void
{
// Consolidate business rules inside the entity
if ($this->status !== OrderStatus::DRAFT) {
throw new DomainException('Only draft orders can be confirmed');
}
if (count($this->orderLines) === 0) {
throw new DomainException('Cannot confirm an order with no line items');
}

$this->status = OrderStatus::CONFIRMED;
$this->confirmedAt = new DateTimeImmutable();
}
}

UseCase responsibilities

  • Retrieving domain objects
  • Calling methods on domain objects (coordination)
  • Managing transactions
  • Instructing persistence

Entity responsibilities

  • Judging business rules
  • Changing state
  • Protecting invariants

4. Designing return values

Operation typeReturn valueReason
CreationIdentifier (ID)To identify the created resource
UpdatevoidTo guarantee idempotency
DeletionvoidTo guarantee idempotency
RetrievalDTOIn the format the presentation layer needs
What is idempotency?

The property that running the same operation any number of times produces the same result. The reason update and delete operations return no value (void) is so that if an error occurs midway and the client retries, the operation can be safely re-executed. If you include state-change information in the return value, the client cannot decide "which result should I trust."

Directory structure

app/Application/
├── UseCase/
│ └── Order/
│ ├── Create/
│ │ ├── CreateOrderCommand.php
│ │ └── CreateOrderUseCase.php
│ ├── Confirm/
│ │ ├── ConfirmOrderCommand.php
│ │ └── ConfirmOrderUseCase.php
│ ├── Cancel/
│ │ ├── CancelOrderCommand.php
│ │ └── CancelOrderUseCase.php
│ ├── Get/
│ │ ├── GetOrderUseCase.php
│ │ └── OrderDto.php
│ └── List/
│ ├── ListOrdersQuery.php
│ ├── ListOrdersUseCase.php
│ └── OrderListItemDto.php

└── Listener/
├── SendOrderConfirmationEmail.php
└── DecreaseInventoryOnOrderConfirmed.php

Summary

PointDescription
UseCase responsibilitiesCoordinating domain objects, managing transactions
Command/Query separationClearly separate writes and reads
Using DTOsReturn data in a format suited to the presentation layer
Dedicated Query serviceUse for reads that need performance
Design guidelinesSingle responsibility, hold no domain logic

In the next chapter, we will take a detailed look at designing the presentation layer.