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.
There is one deliberate departure from the principle. Under strict CQS a Command returns no value, but creation operations have to return the newly assigned identifier — otherwise the client cannot identify what was created. This book therefore allows creation Commands to return an identifier.
| 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, $userId);
// OK: make the intent clear with a Command object
$command = new CreateOrderCommand(
prefecture: 'Tokyo',
city: 'Shibuya',
street: '1-1-1',
items: $items,
userId: $userId,
);
$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,
// The owner of the order. orders.user_id is NOT NULL, so it is required (Chapter 14)
public readonly int $userId,
) {}
/**
* Factory method that creates a Command from an array
* Useful when converting HTTP request data (an array) into a Command
*
* Only the owner comes in as an argument rather than through the array. It is
* taken from the authenticated user, so mixing it into $data would give it two sources.
*/
public static function fromArray(array $data, int $userId): self
{
return new self(
$data['shipping']['prefecture'],
$data['shipping']['city'],
$data['shipping']['street'],
$data['items'],
$userId,
);
}
}
// 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 (this is a new order, so use create())
// Order does not hold the orderer (Chapter 18), so the owner is passed as an argument (Chapter 13)
// Transaction handling happens inside the repository (only one aggregate is updated, so it is self-contained in the Repository)
$this->orderRepository->create($order, new UserId($command->userId));
return $orderId;
}
}
// app/Application/UseCase/Order/ConfirmOrderUseCase.php
use App\Domain\Inventory\Exception\InventoryNotFoundException;
use App\Domain\Order\Exception\OrderNotFoundException;
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 InventoryNotFoundException when no inventory record exists
* @throws InsufficientStockException when stock is insufficient
* @throws InvalidOrderStateException when the order cannot be confirmed
*/
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
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// 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 InventoryNotFoundException($line->productId());
// 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 array $orderLines, // converted to an array of OrderLineDto
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" (types only, excerpted from the Order in Chapter 6)
// Instances are created through the create() / reconstruct() static factories; the constructor is private
final class Order
{
private array $orderLines; // array of OrderLine
private function __construct(
private readonly OrderId $id, // value object
private OrderStatus $status, // Enum
private readonly ShippingAddress $shippingAddress, // value object
private readonly 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
// 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
use App\Domain\Order\Exception\OrderNotFoundException;
final class GetOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
) {}
/**
* @throws OrderNotFoundException
*/
public function execute(int $orderId): OrderDto
{
$id = new OrderId($orderId);
$order = $this->orderRepository->findById($id)
?? throw new OrderNotFoundException($id);
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();
}
}
The binding that connects the interface to its implementation goes in a service provider, just as it does for repositories. Without it the container cannot resolve the interface, and injecting ListOrdersUseCase fails with Target [OrderQueryServiceInterface] is not instantiable. The registration point is the RepositoryServiceProvider in Chapter 13.
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
That said, a retrieval operation that takes nothing but a single identifier may keep the primitive type. GetOrderUseCase::execute(int $orderId) in this chapter is one such case. Introduce a Command/Query once there are two or more arguments, or once arguments of the same type but different meaning appear side by side. Update operations always go through a Command, no matter how few arguments they take. That way the signature stays put when a field is added later, and the call site keeps a name for every value it passes.
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
use App\Domain\Order\Exception\InvalidOrderStateException;
final class Order
{
public function confirm(): void
{
// Consolidate business rules inside the entity
if (!$this->status->canBeConfirmed()) {
throw new InvalidOrderStateException('Only draft orders can be confirmed');
}
if (count($this->orderLines) === 0) {
throw new InvalidOrderStateException('Cannot confirm an order with no line items');
}
$this->status = OrderStatus::CONFIRMED;
}
}
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 | The CQS principle (a Command returns no value) |
| Deletion | void | The CQS principle (a Command returns no value) |
| Retrieval | DTO | In the format the presentation layer needs |
The CQS principle from the beginning of this chapter is the reason on its own. If an operation that changes state also returns a value, callers can end up writing code that "runs the update in order to read the return value," and the boundary between operations with and without side effects breaks down. Creation is the only exception, because without the newly assigned identifier the caller cannot identify what was created.
Note that returning void is a separate matter from idempotency (the property that running the same operation any number of times produces the same result). This book's Order::confirm() throws an exception when re-run on an already confirmed order, so it returns void yet is not idempotent. If you need a design that survives HTTP retries, idempotency has to be designed separately — for example by returning without doing anything when the state is already the target state.
Directory structure
app/Application/
├── UseCase/
│ └── Order/
│ ├── CreateOrderCommand.php
│ ├── CreateOrderUseCase.php
│ ├── ConfirmOrderCommand.php
│ ├── ConfirmOrderUseCase.php
│ ├── CancelOrderCommand.php
│ ├── CancelOrderUseCase.php
│ ├── GetOrderUseCase.php
│ ├── ListOrdersQuery.php
│ ├── ListOrdersUseCase.php
│ ├── OrderDto.php
│ ├── OrderLineDto.php
│ └── OrderListItemDto.php
│
├── Query/
│ └── Order/
│ └── OrderQueryServiceInterface.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.