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:
- Coordinating domain objects: executing the business flow
- Managing transactions: controlling the consistency boundary
- 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
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."
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.
| Type | Purpose | Return value | Example |
|---|---|---|---|
| Command | Changes state | void or an identifier | CreateOrderUseCase, ConfirmOrderUseCase |
| Query | Retrieves data | DTO | GetOrderUseCase, 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.
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.
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.
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();
}
}
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.
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 type | Return value | Reason |
|---|---|---|
| Creation | Identifier (ID) | To identify the created resource |
| Update | void | To guarantee idempotency |
| Deletion | void | To guarantee idempotency |
| Retrieval | DTO | In the format the presentation layer needs |
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
| Point | Description |
|---|---|
| UseCase responsibilities | Coordinating domain objects, managing transactions |
| Command/Query separation | Clearly separate writes and reads |
| Using DTOs | Return data in a format suited to the presentation layer |
| Dedicated Query service | Use for reads that need performance |
| Design guidelines | Single responsibility, hold no domain logic |
In the next chapter, we will take a detailed look at designing the presentation layer.