Skip to main content

In Practice: Implementing the Order System — An Example That Cuts Across All Layers

The overall picture of the implementation

In this chapter, we integrate what we have learned so far and implement the order system.

[Features to implement]
1. Create an order (POST /orders)
2. Confirm an order (POST /orders/{id}/confirm)

[Related aggregates]
- Order aggregate (Order, OrderLine, ShippingAddress)
- Inventory aggregate (for stock checks)

Directory structure

app/
├── Domain/
│ ├── Order/
│ │ ├── Order.php
│ │ ├── OrderId.php
│ │ ├── Exception/
│ │ │ └── InvalidOrderStateException.php
│ │ ├── OrderLine.php
│ │ ├── OrderLineId.php
│ │ ├── OrderStatus.php
│ │ ├── ProductId.php
│ │ ├── ShippingAddress.php
│ │ └── OrderRepositoryInterface.php
│ └── Shared/
│ └── Money.php

├── Application/
│ └── UseCase/
│ └── Order/
│ ├── CreateOrderUseCase.php
│ ├── CreateOrderCommand.php
│ ├── ConfirmOrderUseCase.php
│ └── ConfirmOrderCommand.php

├── Infrastructure/
│ ├── Eloquent/
│ │ ├── OrderModel.php
│ │ └── OrderLineModel.php
│ ├── Repository/
│ │ └── EloquentOrderRepository.php
│ └── Provider/
│ └── RepositoryServiceProvider.php

└── Http/
├── Controllers/
│ └── OrderController.php
└── Requests/
└── CreateOrderRequest.php

The flow of data

Implementing each layer

The presentation layer

The presentation layer's responsibility is receiving the HTTP request and returning the response. It holds no business logic and delegates processing to the UseCase.

// app/Http/Controllers/OrderController.php
final class OrderController extends Controller
{
/**
* Constructor injection
*
* Laravel's service container automatically instantiates the UseCase and passes it in.
* This makes it easier to swap in mock objects during testing.
*/
public function __construct(
private readonly CreateOrderUseCase $createOrderUseCase,
private readonly ConfirmOrderUseCase $confirmOrderUseCase,
) {}

/**
* Create an order
*
* Controller responsibilities:
* 1. Validation (delegated to FormRequest)
* 2. Convert the HTTP request to a Command object
* 3. Run the UseCase
* 4. Convert the result to an HTTP response
*
* What the Controller "does not" do:
* - Business logic (the UseCase's responsibility)
* - Database operations (the Repository's responsibility)
* - Creating domain objects (the Domain's responsibility)
*/
public function store(CreateOrderRequest $request): JsonResponse
{
// Already validated by FormRequest
// toCommand() converts the HTTP request to a Command object
$orderId = $this->createOrderUseCase->execute($request->toCommand());

// Convert the UseCase's result to an HTTP response
// Returning the 201 Created status code indicates resource creation
return response()->json(['orderId' => $orderId->value()], 201);
}

/**
* Confirm an order
*
* For simple cases, you can create the Command directly without using a FormRequest
*/
public function confirm(int $id): JsonResponse
{
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));
return response()->json(['message' => 'Order confirmed']);
}
}

// app/Http/Requests/CreateOrderRequest.php
final class CreateOrderRequest extends FormRequest
{
/**
* Validation rules
*
* What you define here is only "validity as an HTTP request."
* Business rules (e.g. stock checks, order limits) are verified in the domain layer.
*
* Separation of responsibilities:
* - FormRequest: type/format validation at the HTTP layer
* - Domain: business-rule validation
*/
public function rules(): array
{
return [
// Validation of the address information
'shipping.prefecture' => ['required', 'string', 'max:255'],
'shipping.city' => ['required', 'string', 'max:255'],
'shipping.street' => ['required', 'string', 'max:255'],

// Validation of the order lines
'items' => ['required', 'array', 'min:1'], // at least one item is required
'items.*.productId' => ['required', 'integer', 'min:1'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unitPrice' => ['required', 'integer', 'min:0'], // assumed to be sent in yen
];
}

/**
* Convert the HTTP request to a Command object
*
* With this conversion:
* 1. The UseCase layer does not need to know HTTP details (such as $_POST)
* 2. The same UseCase can be run from the CLI or a queue
* 3. Testing becomes easy (you can create the Command object directly)
*/
public function toCommand(): CreateOrderCommand
{
return new CreateOrderCommand(
$this->input('shipping.prefecture'),
$this->input('shipping.city'),
$this->input('shipping.street'),
$this->input('items'),
);
}
}
A common mistake: writing business logic directly in the Controller
// Bad example
public function store(CreateOrderRequest $request): JsonResponse
{
// The Controller manipulates domain objects directly
$order = new Order();
$order->status = 'draft';

foreach ($request->items as $item) {
$orderLine = new OrderLine();
$orderLine->product_id = $item['productId'];
$orderLine->quantity = $item['quantity'];
$order->orderLines()->save($orderLine);
}

// Business rules scattered in the Controller
if ($order->totalAmount() > 1000000) {
throw new Exception('The order total exceeds the limit');
}

return response()->json($order);
}

// Good example
public function store(CreateOrderRequest $request): JsonResponse
{
// Delegate processing to the UseCase and keep the Controller thin
$orderId = $this->createOrderUseCase->execute($request->toCommand());
return response()->json(['orderId' => $orderId->value()], 201);
}

Keep the Controller layer thin and always place business logic in the UseCase layer or below.

The application layer

The application layer's responsibility is coordinating the use case (workflow). It combines domain objects to realize a single business scenario.

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

/**
* Order creation command
*
* Benefits of a Command object:
* 1. A data structure independent of the HTTP request
* 2. The same UseCase can be run from the CLI or a queue
* 3. Improved type safety (static analysis like PHPStan works)
*
* By defining it as an immutable object, we prevent unintended changes.
*/
final class CreateOrderCommand
{
/**
* @param string $prefecture the shipping prefecture
* @param string $city the shipping city
* @param string $street the shipping street address
* @param array<array{productId: int, quantity: int, unitPrice: int}> $items the order lines
*/
public function __construct(
public readonly string $prefecture,
public readonly string $city,
public readonly string $street,
public readonly array $items,
) {}
}

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

/**
* Order creation use case
*
* What this UseCase does:
* 1. Issue a new order ID
* 2. Turn the shipping address into a value object
* 3. Create the order entity
* 4. Add each item as an order line
* 5. Persist the order
*
* What this UseCase "does not" do:
* - Validate business rules (the domain layer's responsibility)
* - Operate the database directly (the infrastructure layer's responsibility)
* - Build the HTTP response (the presentation layer's responsibility)
*/
final class CreateOrderUseCase
{
/**
* Depends on the Repository interface
*
* By depending on the interface rather than the concrete class
* (EloquentOrderRepository):
* - You can swap in a mock for testing
* - It is resilient to implementation changes (Eloquent to Doctrine, etc.)
* - The domain layer does not depend on external technology
*/
public function __construct(
private readonly OrderRepositoryInterface $orderRepository
) {}

/**
* Create an order
*
* @param CreateOrderCommand $command the command passed from the HTTP layer
* @return OrderId the generated order ID
* @throws InvalidOrderStateException on a domain-rule violation
*/
public function execute(CreateOrderCommand $command): OrderId
{
// Step 1: issue a new order ID
// By delegating the ID-issuing logic to the Repository,
// you can hide the implementation, such as DB auto-increment or UUID
$orderId = $this->orderRepository->nextIdentity();

// Step 2: convert the shipping address to a value object
// Treat it as a meaningful type, not a primitive string
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street
);

// Step 3: create the order entity
// Using a factory method guarantees that it is created
// in the correct initial state (DRAFT)
$order = Order::create($orderId, $shippingAddress);

// Step 4: add each item as an order line
foreach ($command->items as $item) {
// The order line ID also needs to be issued
$lineId = $this->orderRepository->nextLineIdentity();

// The addItem() method validates business rules
// e.g. "items cannot be added to a confirmed order"
$order->addItem(
$lineId,
new ProductId($item['productId']), // a value object, not a primitive
$item['quantity'],
new Money($item['unitPrice'], 'JPY'), // amounts are handled together with the currency
);
}

// Step 5: persist the order
// Transaction management happens inside save() (see Chapter 15 for details)
$this->orderRepository->save($order);

// Step 6: return the generated order ID
// It is converted to an HTTP response in the Controller layer
return $orderId;
}
}
A detailed explanation of the data flow

The flow of data transformation when creating an order:

By using the appropriate data type at each layer, you achieve type safety and clarify business rules.

A common mistake: writing DB logic directly in the UseCase
// Bad example: the UseCase depends directly on Eloquent
final class CreateOrderUseCase
{
public function execute(CreateOrderCommand $command): OrderId
{
// Manipulating the Eloquent model directly
$order = new OrderModel();
$order->status = 'draft';
$order->shipping_prefecture = $command->prefecture;
$order->save(); // depends on Eloquent

foreach ($command->items as $item) {
$orderLine = new OrderLineModel();
$orderLine->order_id = $order->id;
$orderLine->product_id = $item['productId'];
$orderLine->save(); // depends on Eloquent
}

return new OrderId($order->id);
}
}

// Good example: depend on the Repository interface
final class CreateOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository
) {}

public function execute(CreateOrderCommand $command): OrderId
{
$orderId = $this->orderRepository->nextIdentity();
$order = Order::create($orderId, ...); // use the domain entity
// ...
$this->orderRepository->save($order); // depend on the interface
return $orderId;
}
}

The UseCase should not know the persistence details. Handle it abstractly through the Repository interface.

The domain layer

The domain layer's responsibility is expressing and protecting business rules. It does not depend on the framework or the database, and handles only pure business logic.

// app/Domain/Order/Order.php

/**
* The root of the order aggregate
*
* An aggregate is a "boundary within which consistency must be guaranteed."
* The Order aggregate guarantees the consistency of the order itself and all its order lines (OrderLine).
*
* Aggregate rules:
* 1. Do not let code outside the aggregate access its internal entities (OrderLine) directly
* 2. All operations go through the root entity (Order)
* 3. Always uphold the invariants
*/
final class Order
{
/** @var OrderLine[] the collection of order lines */
private array $orderLines;

/**
* Make the constructor private so it cannot be newed directly from outside
*
* Why make it private?
* - To prevent objects from being created in an invalid state
* - By forcing the use of factory methods (create, reconstruct),
* correct initialization is enforced
*/
private function __construct(
private readonly OrderId $id, // the identifier is immutable (cannot be changed thanks to readonly)
private OrderStatus $status, // the status is changeable
private readonly ShippingAddress $shippingAddress, // the shipping address is immutable
array $orderLines,
) {
$this->orderLines = $orderLines;
}

/**
* Factory method that creates a new order
*
* Invariants at creation:
* - The status is always DRAFT
* - The order lines are an empty array
*
* @param OrderId $id the order ID (already issued via the Repository)
* @param ShippingAddress $shippingAddress the shipping address
* @return self the order entity
*/
public static function create(OrderId $id, ShippingAddress $shippingAddress): self
{
// Always start with the DRAFT status
// This guarantees the rule "an order right after creation is always in draft state"
return new self($id, OrderStatus::DRAFT, $shippingAddress, []);
}

/**
* Factory method that restores from the DB
*
* The difference from create():
* - create(): new creation as business logic (always DRAFT)
* - reconstruct(): restoration from persisted data (any status)
*
* Intended to be called only from the Repository layer.
*/
public static function reconstruct(
OrderId $id,
OrderStatus $status,
ShippingAddress $shippingAddress,
array $orderLines
): self {
return new self($id, $status, $shippingAddress, $orderLines);
}

/**
* Add an item to the order
*
* Business rules:
* 1. Items can only be added while in draft state
* 2. Cannot add after confirmation or cancellation
*
* By validating business rules inside this method,
* we prevent an invalid state.
*
* @throws InvalidOrderStateException if called outside the draft state
*/
public function addItem(
OrderLineId $lineId,
ProductId $productId,
int $quantity,
Money $unitPrice
): void {
// Validate the business rule
// By doing this validation inside the entity,
// we prevent the rule from leaking outside (such as into the UseCase)
if (!$this->status->isDraft()) {
throw new InvalidOrderStateException('Items can only be added while in draft state');
}

// Add the order line
// OrderLine is treated as a value object (immutable)
$this->orderLines[] = new OrderLine($lineId, $productId, $quantity, $unitPrice);
}

/**
* Confirm the order
*
* Business rules:
* 1. Can only be confirmed from the draft state
* 2. At least one order line is required
*
* State transitions happen only inside the entity;
* the status cannot be changed directly from outside.
*
* @throws InvalidOrderStateException on a business-rule violation
*/
public function confirm(): void
{
// Rule 1: check whether the state allows confirmation
if (!$this->status->canBeConfirmed()) {
throw new InvalidOrderStateException('This order cannot be confirmed');
}

// Rule 2: check that the order lines are not empty
if (empty($this->orderLines)) {
throw new InvalidOrderStateException('Cannot confirm an order with no line items');
}

// Once all rules pass, change the status
$this->status = OrderStatus::CONFIRMED;

// If you publish a domain event, record it here
// $this->recordEvent(new OrderConfirmed($this->id));
}

/**
* Cancel the order
*
* Business rules:
* - A shipped order cannot be cancelled, etc.
*/
public function cancel(): void
{
if (!$this->status->canBeCancelled()) {
throw new InvalidOrderStateException('This order cannot be cancelled');
}
$this->status = OrderStatus::CANCELLED;
}

/**
* Calculate the total amount
*
* By keeping the calculation logic inside the domain entity,
* you can consolidate the business rule (how the total is calculated) in one place.
*
* @return Money the order's total amount
*/
public function totalAmount(): Money
{
$total = new Money(0, 'JPY');
foreach ($this->orderLines as $line) {
// Add Money objects together
// Treating them as value objects prevents currency mismatches
$total = $total->add($line->subtotal());
}
return $total;
}

// Getters
// Using readonly guarantees they cannot be changed from outside
public function id(): OrderId { return $this->id; }
public function status(): OrderStatus { return $this->status; }
public function shippingAddress(): ShippingAddress { return $this->shippingAddress; }

/**
* Get the order lines
*
* Returning the array as-is risks it being modified from outside,
* so ideally you would return a copy or wrap it in a ReadOnlyCollection.
* For simplicity, this implementation returns the array as-is.
*/
public function orderLines(): array { return $this->orderLines; }
}
Best practices for designing domain entities
  1. Make the constructor private

    • Allow creation only via factory methods (create, reconstruct)
    • Prevent an invalid initial state
  2. Keep business rules inside the entity

    • Rules like "items cannot be added to a confirmed order" are validated inside addItem()
    • Do not write rules outside the entity (such as in the UseCase)
  3. Always uphold invariants

    • No matter what operation is performed, the entity always stays in a valid state
    • e.g. "an order with no line items cannot be confirmed"
  4. Keep calculation logic inside the entity too

    • A calculation like totalAmount() is part of the domain knowledge
    • Calculating it in the UseCase layer scatters the logic
  5. Leverage value objects

    • Use meaningful types such as Money, OrderStatus, and ProductId
    • Avoid the direct use of primitive types (int, string)
A common mistake: exposing setters
// Bad example: there is a public setter
final class Order
{
public function setStatus(OrderStatus $status): void
{
$this->status = $status; // can be changed while ignoring business rules
}
}

// Usage: business rules can be ignored
$order->setStatus(OrderStatus::CONFIRMED); // can be confirmed even with no order lines

// Good example: expose only methods that express intent
final class Order
{
public function confirm(): void
{
// Validate the business rule inside the method
if (empty($this->orderLines)) {
throw new InvalidOrderStateException('Cannot confirm an order with no line items');
}
$this->status = OrderStatus::CONFIRMED;
}
}

// Usage: the business rule is always upheld
$order->confirm(); // an exception is thrown if there are no order lines

Exposing setters makes it possible to change state while bypassing business rules. Expose only methods that express intent (confirm, cancel, etc.).

The infrastructure layer

The infrastructure layer's responsibility is hiding the persistence details. It implements the Repository interface defined in the domain layer and handles the conversion between domain entities and Eloquent models.

// app/Infrastructure/Repository/EloquentOrderRepository.php

/**
* An implementation of OrderRepository using Eloquent
*
* What this layer does:
* 1. Convert between domain entities and Eloquent models
* 2. Manage transactions
* 3. Database-specific operations (eager loading, etc.)
*
* What this layer "does not" do:
* - Validate business rules (the domain layer's responsibility)
* - Control the workflow (the application layer's responsibility)
*/
final class EloquentOrderRepository implements OrderRepositoryInterface
{
/**
* Find an order by ID
*
* @param OrderId $id the order ID
* @return Order|null the found order (null if it does not exist)
*/
public function findById(OrderId $id): ?Order
{
// with() for eager loading → avoids the N+1 problem (see Chapter 13 for details)
// Retrieve the order (orders) and the order lines (order_lines) at once
$model = OrderModel::with('orderLines')->find($id->value());

// If the Eloquent model exists, convert it to a domain entity; otherwise null
return $model ? $this->toEntity($model) : null;
}

/**
* Save an order
*
* This method's responsibilities:
* 1. Convert the domain entity to Eloquent models
* 2. Save the order itself and the order lines within a transaction
* 3. Properly handle deleted order lines as well
*
* Points about transaction management:
* - Since this saves a single aggregate, manage the transaction inside the Repository
* - The order and its lines are saved together (consistency guarantee)
* - If it fails midway, roll everything back
*/
public function save(Order $order): void
{
// Why wrap it in a transaction:
// to keep the order itself (orders) and the order lines (order_lines) consistent
DB::transaction(function () use ($order) {
// Step 1: save the order itself
// Using updateOrCreate() lets you handle creation and update uniformly
$orderModel = OrderModel::updateOrCreate(
// Search condition: does an order with this ID already exist?
['id' => $order->id()->value()],
// Data to save: map the domain entity's state to columns
[
'status' => $order->status()->value, // Enum → string
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
]
);

// Step 2: save the order lines (including deletions)
$this->saveOrderLines($orderModel, $order->orderLines());
});
}

/**
* Generate the next order ID
*
* Encapsulates the ID-issuing strategy:
* - Currently the "max value + 1" approach (a simplified version for learning in this book)
* - Even if you change to UUID in the future, you only change this implementation
* - The domain layer and the application layer need no changes
*
* ⚠️ For production, see "On issuing IDs in production" below
*/
public function nextIdentity(): OrderId
{
$maxId = OrderModel::max('id') ?? 0;
return new OrderId($maxId + 1);
}

/**
* Generate the next order line ID
*
* Order lines also have independent IDs, so they need to be issued separately
*/
public function nextLineIdentity(): OrderLineId
{
$maxId = DB::table('order_lines')->max('id') ?? 0;
return new OrderLineId($maxId + 1);
}

/**
* Convert from an Eloquent model to a domain entity
*
* With this conversion:
* - The domain layer does not need to know about Eloquent
* - Changes to the table structure do not affect the domain layer
* - Business logic and data structure can be separated
*
* @param OrderModel $model the Eloquent model
* @return Order the domain entity
*/
private function toEntity(OrderModel $model): Order
{
// Convert the collection of order lines
// Eloquent Collection → array of domain entities
$orderLines = $model->orderLines->map(fn($line) => new OrderLine(
new OrderLineId($line->id), // primitive → value object
new ProductId($line->product_id),
$line->quantity,
new Money($line->unit_price, 'JPY'), // amount → Money object
))->toArray();

// Use reconstruct() to restore from the DB
// Why use reconstruct() instead of create():
// - Data read from the DB can have any status
// - create() assumes "always DRAFT," so it is inappropriate
return Order::reconstruct(
new OrderId($model->id),
// string → Enum. Throws a ValueError on an invalid value to fail fast (see Chapter 13)
OrderStatus::from($model->status),
new ShippingAddress(
$model->shipping_prefecture,
$model->shipping_city,
$model->shipping_street
),
$orderLines,
);
}

/**
* Save the order lines (including additions, updates, and deletions)
*
* Why this processing is complex:
* - It must handle not just additions but also deleted lines
* - e.g. if you delete one item while editing an order, it should be deleted from the DB too
*
* Algorithm:
* 1. Get the list of order line IDs the current entity holds
* 2. Delete lines that exist in the DB but not in the entity
* 3. Save all the lines the entity holds (updateOrCreate)
*
* @param OrderModel $orderModel the parent order
* @param array $orderLines the array of order lines to save
*/
private function saveOrderLines(OrderModel $orderModel, array $orderLines): void
{
// The list of currently valid order line IDs
$currentLineIds = array_map(fn($line) => $line->id()->value(), $orderLines);

// Delete the removed order lines from the DB
// e.g. if there were 3 before editing and 2 after, remove the 1 that was deleted
$orderModel->orderLines()->whereNotIn('id', $currentLineIds)->delete();

// Save all order lines (create or update)
foreach ($orderLines as $line) {
$orderModel->orderLines()->updateOrCreate(
// Search condition: does a line with this ID exist?
['id' => $line->id()->value()],
// Data to save: domain entity → table columns
[
'product_id' => $line->productId()->value(),
'quantity' => $line->quantity(),
'unit_price' => $line->unitPrice()->amount(), // Money → int
]
);
}
}
}
On issuing IDs in production

This book's nextIdentity() / nextLineIdentity() use the MAX(id) + 1 approach, but this can cause ID collisions when multiple processes issue IDs at the same time. In production, we recommend one of the following.

  • DB Auto Increment: the DB issues the ID on INSERT (Laravel's $table->id() is sufficient)
  • UUID v7: a UUID that can be sorted chronologically (Laravel 11's Str::uuid7() is available)
  • Snowflake ID: a unique ID for distributed systems

This book uses it as an example to teach the design point that "the ID-issuing method is hidden in the repository." In real operation, swap it out for a thread-safe ID-issuing method.

Best practices for Repository implementation
  1. Hide the conversion logic

    • Make conversion methods like toEntity() / toModel() private
    • Do not leak the table structure to the domain layer
  2. Optimize performance with eager loading

    • Avoid the N+1 problem with with('orderLines')
    • The domain layer does not need to worry about performance optimization
  3. Put transactions in the right place

    • Saving a single aggregate: transaction inside the Repository
    • Saving multiple aggregates: transaction inside the UseCase (see Chapter 15)
  4. Encapsulate the ID-issuing strategy

    • Hide the issuing method in nextIdentity()
    • Easy to change to UUID or Snowflake in the future
  5. Unify creation and update with updateOrCreate

    • Handle both creation and update with the same code
    • Avoid logic duplication
A common mistake: returning the domain entity with toArray()
// Bad example: returning an array
public function findById(OrderId $id): ?array
{
$model = OrderModel::with('orderLines')->find($id->value());
return $model ? $model->toArray() : null;
}

// Problems:
// 1. The return value is an array, so it is not type-safe
// 2. Business logic (such as confirm()) cannot be used
// 3. It has no behavior as a domain model

// Good example: return the domain entity
public function findById(OrderId $id): ?Order
{
$model = OrderModel::with('orderLines')->find($id->value());
return $model ? $this->toEntity($model) : null;
}

// Benefits:
// 1. Type-safe (the Order type is guaranteed)
// 2. Business logic is usable ($order->confirm(), etc.)
// 3. It can be treated as a domain model

Always make the Repository return a domain entity.

Test examples

Tests for each layer have different purposes and approaches.

Domain-layer tests (no DB, fast)

Tests for domain logic can run without a database. This lets you write fast, reliable tests.

// tests/Unit/Domain/Order/OrderTest.php

/**
* Business-rule test: items cannot be added to a confirmed order
*
* The purpose of this test:
* - Verify that the domain entity correctly protects the business rule
* - Verify that an invalid state transition is prevented
*
* Why not use a DB:
* - Testing business logic does not require persistence
* - Test execution is fast
* - The test is stable (does not depend on DB state)
*/
public function test_cannot_add_item_to_confirmed_order(): void
{
// Arrange: prepare the test data
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);
$order->addItem(new OrderLineId(1), new ProductId(1), 2, new Money(1000, 'JPY'));
$order->confirm(); // confirm the order

// Act & Assert: verify that adding an item after confirmation throws an exception
$this->expectException(InvalidOrderStateException::class);
$this->expectExceptionMessage('Items can only be added while in draft state');
$order->addItem(new OrderLineId(2), new ProductId(2), 1, new Money(500, 'JPY'));
}

/**
* Business-rule test: an order with no line items cannot be confirmed
*/
public function test_cannot_confirm_order_with_empty_lines(): void
{
// Arrange: create an order (do not add items)
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);

// Act & Assert: trying to confirm with no items throws an exception
$this->expectException(InvalidOrderStateException::class);
$this->expectExceptionMessage('no line items');
$order->confirm();
}

/**
* Calculation-logic test: the total amount is calculated correctly
*/
public function test_total_amount_is_calculated_correctly(): void
{
// Arrange
$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);
$order->addItem(new OrderLineId(1), new ProductId(1), 2, new Money(1000, 'JPY')); // 2,000 yen
$order->addItem(new OrderLineId(2), new ProductId(2), 3, new Money(500, 'JPY')); // 1,500 yen

// Act
$totalAmount = $order->totalAmount();

// Assert: total of 3,500 yen
$this->assertEquals(3500, $totalAmount->amount());
}

UseCase-layer tests (leveraging mocks)

In UseCase tests, you mock the Repository to verify the correctness of the workflow.

// tests/Unit/Application/UseCase/Order/CreateOrderUseCaseTest.php

public function test_order_is_created_correctly(): void
{
// Arrange: create the Repository mock
$orderRepository = $this->createMock(OrderRepositoryInterface::class);

// When nextIdentity() is called, return OrderId(1)
$orderRepository->expects($this->once())
->method('nextIdentity')
->willReturn(new OrderId(1));

// nextLineIdentity() is called twice (two items)
$orderRepository->expects($this->exactly(2))
->method('nextLineIdentity')
->willReturnOnConsecutiveCalls(
new OrderLineId(1),
new OrderLineId(2)
);

// Expect save() to be called once
$orderRepository->expects($this->once())
->method('save')
->with($this->callback(function (Order $order) {
// Verify the state of the order being saved
$this->assertEquals(OrderStatus::DRAFT, $order->status());
$this->assertCount(2, $order->orderLines());
return true;
}));

// Create the UseCase
$useCase = new CreateOrderUseCase($orderRepository);

// Act: run the command
$command = new CreateOrderCommand(
prefecture: 'Tokyo',
city: 'Shibuya',
street: '1-1-1',
items: [
['productId' => 1, 'quantity' => 2, 'unitPrice' => 1000],
['productId' => 2, 'quantity' => 1, 'unitPrice' => 500],
]
);
$orderId = $useCase->execute($command);

// Assert: verify the returned order ID
$this->assertEquals(1, $orderId->value());
}

Repository-layer tests (integration tests using a DB)

The Repository layer is tested against a real DB to verify its integration with the database.

// tests/Integration/Infrastructure/Repository/EloquentOrderRepositoryTest.php

use Illuminate\Foundation\Testing\RefreshDatabase;

class EloquentOrderRepositoryTest extends TestCase
{
use RefreshDatabase; // reset the DB for each test

public function test_can_save_and_retrieve_order(): void
{
// Arrange
$repository = new EloquentOrderRepository();

$order = Order::create(
new OrderId(1),
new ShippingAddress('Tokyo', 'Shibuya', '1-1-1')
);
$order->addItem(
new OrderLineId(1),
new ProductId(100),
2,
new Money(1000, 'JPY')
);

// Act: save
$repository->save($order);

// Assert: retrieve and verify
$fetchedOrder = $repository->findById(new OrderId(1));
$this->assertNotNull($fetchedOrder);
$this->assertEquals(OrderStatus::DRAFT, $fetchedOrder->status());
$this->assertCount(1, $fetchedOrder->orderLines());
}

public function test_order_line_deletion_is_reflected(): void
{
// Arrange: save an order with 2 items
$repository = new EloquentOrderRepository();
$order = Order::create(new OrderId(1), new ShippingAddress('Tokyo', 'Shibuya', '1-1-1'));
$order->addItem(new OrderLineId(1), new ProductId(100), 2, new Money(1000, 'JPY'));
$order->addItem(new OrderLineId(2), new ProductId(200), 1, new Money(500, 'JPY'));
$repository->save($order);

// Act: re-fetch the order and delete one item
$fetchedOrder = $repository->findById(new OrderId(1));
$remainingLine = $fetchedOrder->orderLines()[0]; // keep only the first one
$modifiedOrder = Order::reconstruct(
$fetchedOrder->id(),
$fetchedOrder->status(),
$fetchedOrder->shippingAddress(),
[$remainingLine] // keep only one
);
$repository->save($modifiedOrder);

// Assert: verify that there is now one item
$refetchedOrder = $repository->findById(new OrderId(1));
$this->assertCount(1, $refetchedOrder->orderLines());
}
}
Summary of the testing strategy
Test targetUses DBMocksTest purpose
Domain layerNoNoThe correctness of business rules
UseCase layerNoYesThe correctness of the workflow
Repository layerYesNoThe correctness of persistence
Controller layerYesYes (partly)The correctness of the HTTP response

The principles of the test pyramid:

  • Write the most domain-layer tests (fast and stable)
  • Write a moderate number of UseCase-layer tests
  • Keep Repository-layer tests to the necessary minimum (slow and unstable)

See Chapter 17 "Testing Strategy" for details.

Summary

In this chapter, we took a detailed look at each layer's responsibilities and the data flow through the implementation of the order system.

Key points:

  1. Clearly separate each layer's responsibilities

    • Controller: only HTTP request/response conversion
    • UseCase: only coordinating the workflow
    • Domain: only expressing and protecting business rules
    • Repository: only hiding the persistence details
  2. Understand the flow of data transformation

    • HTTP request → Command → value object → domain entity → Eloquent model
  3. Consolidate business rules in the domain layer

    • Validate rules inside the entity's methods
    • Do not allow invalid state changes from outside
  4. Write tests according to each layer's responsibilities

    • Test the domain layer fast without a DB
    • Test the Repository layer against a real DB

In the next chapter, we will present the book's conclusion and resources for deepening your learning further.