はじめに
「Clean Architectureを導入してみた」という記事は多いですが、「なぜそう設計したか」の理由まで丁寧に書かれたものは意外と少ないと感じています。
この記事では、イベント予約システムをLaravelでClean Architectureの4層分離を「厳格に」守りながら実装した経験をもとに、設計判断の理由を丁寧に解説します。
コードが動くことより、「この構造にした根拠」を伝えることを優先します。
対象読者
- LaravelでMVCの限界を感じ始めた中級エンジニア
- Clean Architectureを読んだが、実装への落とし込み方で迷っている人
- 「なんとなくUseCase層を作った」から「確信を持って設計できる」レベルに上がりたい人
作ったもの
イベント予約システムです。主な機能は以下の3つです。
- イベントの作成(定員・料金・開催日を設定)
- イベントへの申込(重複防止・定員管理・モック決済)
- 申込のキャンセル
シンプルなドメインですが、Clean Architectureを学ぶには複雑さがちょうどよく、決済という外部サービスの抽象化まで含められる点が気に入っています。
ディレクトリ構成
app/
├── Domain/ # ビジネスの核心。Laravelを知らない
│ ├── Event/
│ │ ├── Event.php ← Aggregate Root
│ │ ├── EventId.php
│ │ ├── EventStatus.php
│ │ ├── Capacity.php ← Value Object
│ │ └── EventRepositoryInterface.php ← ★ここがポイント
│ ├── Registration/
│ │ ├── Registration.php ← Entity
│ │ ├── RegistrationId.php
│ │ └── RegistrationStatus.php
│ ├── Shared/
│ │ └── Money.php ← Value Object
│ └── User/
│ ├── UserId.php ← Value Object
│ └── UserName.php
│
├── Application/ # ユースケース。Domainを組み合わせるだけ
│ ├── Service/
│ │ └── PaymentServiceInterface.php ← ★Application層に置く理由がある
│ └── UseCase/
│ ├── CreateEvent/
│ ├── RegisterForEvent/
│ └── CancelRegistration/
│
├── Infrastructure/ # 技術的な実装の詳細
│ ├── Persistence/InMemory/
│ │ └── InMemoryEventRepository.php
│ └── Payment/
│ └── MockPaymentGateway.php
│
└── Presentation/ # HTTPやCLIの入口
├── Http/Controllers/Api/
└── Console/Commands/
Clean Architectureの4層をLaravelで実現するとどうなるか
各層の責務と「越えてはいけない境界線」
Clean Architectureを一言で言うと、「内側の層が外側の層を知らない」 という構造です。
[ Presentation ] → [ Application ] → [ Domain ]
↑ ↑
└─── [ Infrastructure ] ───┘
この矢印は「依存の方向」です。外側の層は内側を知っていますが、内側の層は外側を知りません。Domainは自分より外側にある何かを、一切importしません。
この原則を「厳格に守る」と何が起きるか。最も顕著な変化は、Domain層のコードが完全にテスタブルになることです。Laravelを起動せずに、PHPのオブジェクトとしてそのまま単体テストを書けます。
LaravelのデフォルトはClean Architectureに素直に従わない
Laravelを使うと、自然と app/ 以下にすべて置きたくなります。本来Clean Architectureは技術フレームワークに依存しない設計のため、src/ にドメインコードを置いてLaravelはフレームワークとして外側に配置するアーキテクチャもあります。
今回は 「Laravelのディレクトリ規約を維持しながら、層の分離を概念として守る」 という現実的な方針を選びました。app/ 以下に置きつつも、namespaceで層を明示的に分けています。
これは多くの現場チームが採用できる、実用的なトレードオフだと考えています。
Domain層:Laravelを知らない純粋なビジネスロジック
Domain層の設計品質を確認する最もシンプルな方法は、useステートメントを見ることです。
// app/Domain/Event/Event.php
use App\Domain\Exception\AlreadyRegisteredException;
use App\Domain\Exception\CapacityExceededException;
use App\Domain\Exception\EventAlreadyStartedException;
use App\Domain\Exception\EventNotPublishedException;
use App\Domain\Exception\RegistrationCannotBeCancelledException;
use App\Domain\Exception\RegistrationNotFoundException;
use App\Domain\Registration\Registration;
use App\Domain\Registration\RegistrationId;
use App\Domain\Shared\Money;
use App\Domain\User\UserId;
use App\Domain\User\UserName;
use DateTimeImmutable;
App\Domain\* と DateTimeImmutable のみです。Illuminate\ は一行もありません。これがDomain層の純粋さの証明です。
Aggregate Root(Event)が担う責務
Event クラスはDDDにおける Aggregate Root(集約ルート)です。Registration(申込)の生成・変更は、必ず Event を経由しなければなりません。直接 Registration を生成してDBに保存するような操作は許可しません。
なぜこの制約が必要か。「定員を超えた申込」「重複した申込」「開催済みイベントへの申込」といった不正な状態は、Event と Registration の組み合わせでしか検出できないからです。個々のオブジェクトがバラバラに生存していると、この種の整合性ルールを守る場所がなくなります。
// app/Domain/Event/Event.php
public function register(UserId $userId, UserName $userName): Registration
{
$this->ensureCanRegister($userId); // ← ここでまとめてルールを検証
$registrationId = RegistrationId::generate();
$registration = Registration::create(
$registrationId,
$this->id,
$userId,
$userName
);
$this->registrations[$registrationId->value()] = $registration;
return $registration;
}
private function ensureCanRegister(UserId $userId): void
{
if (!$this->status->canAcceptRegistrations()) {
throw new EventNotPublishedException($this->id); // 非公開
}
$this->ensureNotStarted(); // 開催済み
if ($this->isRegisteredUser($userId)) {
throw new AlreadyRegisteredException($this->id, $userId); // 重複
}
if (!$this->hasAvailableCapacity()) {
throw new CapacityExceededException($this->id); // 定員超過
}
}
ensureCanRegister() が申込前の全チェックを一箇所に集約しています。UseCaseはこの複雑なルールを知りません。$event->register($userId, $userName) と呼ぶだけで、ビジネスルールが保証されます。
create() と reconstruct() ── 2種類のファクトリが必要な理由
Event クラスには __construct が private であり、2つのstaticファクトリメソッドがあります。
// app/Domain/Event/Event.php
// 【新規作成用】状態はDraftに固定される
public static function create(
EventId $id,
string $title,
Money $price,
Capacity $capacity,
DateTimeImmutable $eventDate
): self {
return new self($id, $title, $price, $capacity, $eventDate, EventStatus::Draft);
}
// 【永続化データからの復元用】任意の状態を受け入れる
public static function reconstruct(
EventId $id,
string $title,
Money $price,
Capacity $capacity,
DateTimeImmutable $eventDate,
EventStatus $status,
array $registrations = []
): self {
$event = new self($id, $title, $price, $capacity, $eventDate, $status);
foreach ($registrations as $registration) {
$event->registrations[$registration->id()->value()] = $registration;
}
return $event;
}
create() は「新しいイベントを作る」という操作です。作りたての状態は Draft 以外ありえないため、status を引数に取りません。
reconstruct() はRepositoryがDBから復元するために使います。DBに Published で保存されたデータを復元するとき、Draft に戻すわけにはいきません。あらゆる状態を受け入れる必要があります。
private __construct と組み合わせることで、「イベントの生成経路は必ずこの2メソッドを通る」という保証を型レベルで実現しています。コンストラクタを公開した瞬間、「Draftでもなく、Publishedでもない、不正なstatusを持つEvent」が誰でも作れてしまいます。
ValueObjectは「型付きの制約」である
ValueObjectを導入する理由を「フレームワークに言われたから」で済ませると、設計の本質を見失います。ValueObjectの本当の役割は 「不正な値がシステムに存在できないことを、型として表現すること」 です。
Money:金額と通貨の整合性を保証する
// app/Domain/Shared/Money.php
final readonly class Money
{
public static function of(int $amount, string $currency = 'JPY'): self
{
if ($amount < 0) {
throw new InvalidArgumentException('Money amount cannot be negative');
}
$currency = strtoupper(trim($currency));
if (strlen($currency) !== 3) {
throw new InvalidArgumentException('Currency code must be 3 characters (ISO 4217)');
}
return new self($amount, $currency);
}
public function add(self $other): self
{
$this->ensureSameCurrency($other); // 異なる通貨同士の加算を型レベルで防ぐ
return new self($this->amount + $other->amount, $this->currency);
}
private function ensureSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException(
sprintf('Currency mismatch: %s vs %s', $this->currency, $other->currency)
);
}
}
}
Money を int の代わりに使うと何が変わるか。「JPY と USD を足してしまう」というバグが、コード実行時に InvalidArgumentException として即座に検出されます。int であれば、そのまま計算が通ってしまいます。
また final readonly class にしています。readonly はPHP 8.1から使えるキーワードで、コンストラクタで一度セットしたプロパティを変更不可にします。ValueObjectは「一度作ったら変わらない」ことが原則なので、これを型で強制しています。
Capacity:業務語彙をメソッドとして持つValueObject
// app/Domain/Event/Capacity.php
final readonly class Capacity
{
private const MIN_CAPACITY = 1;
public static function of(int $value): self
{
if ($value < self::MIN_CAPACITY) {
throw new InvalidArgumentException(
sprintf('Capacity must be at least %d', self::MIN_CAPACITY)
);
}
return new self($value);
}
public function hasRoom(int $currentCount): bool
{
return $currentCount < $this->value;
}
public function remainingSlots(int $currentCount): int
{
return max(0, $this->value - $currentCount);
}
public function isFull(int $currentCount): bool
{
return $currentCount >= $this->value;
}
}
Capacity を int にしていた場合、定員チェックのコードはこうなります:
// ValueObjectなし(アンチパターン)
if ($event->registrationCount >= $event->capacity) { ... }
if ($event->capacity - $event->registrationCount <= 0) { ... }
この条件式がController、UseCase、Serviceなど複数箇所に散らばったとき、どれか一箇所のバグが「定員を無視した申込」を生み出します。
Capacity::hasRoom() として一箇所にまとめることで、このルールの変更箇所が1ファイルに限定されます。そして hasRoom という名前は、「currentCount < capacity という数値比較」よりも意図が明確です。
UserId:フォーマット検証を生成時に行う
// app/Domain/User/UserId.php
final readonly class UserId
{
public static function generate(): self
{
return new self(self::generateUuidV4());
}
public static function fromString(string $value): self
{
if (!self::isValidUuid($value)) {
throw new InvalidArgumentException("Invalid UserId format: {$value}");
}
return new self($value);
}
private static function isValidUuid(string $value): bool
{
return preg_match(
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
$value
) === 1;
}
}
fromString() を通らなければ UserId は作れません。つまり、アプリケーション内に無効なUUIDを持つ UserId は存在できないという保証が型によって与えられます。
Ramsey/uuidのようなライブラリを使わず自前でUUID v4を生成・検証しているのは、Domain層を外部ライブラリへの依存から切り離すためです。
EnumでステータスのビジネスルールをEnumに閉じ込める
PHP 8.1からのBackedEnumを活用し、ステータス遷移のルールをEnumに持たせています。
// app/Domain/Event/EventStatus.php
enum EventStatus: string
{
case Draft = 'draft';
case Published = 'published';
case Closed = 'closed';
case Cancelled = 'cancelled';
public function canAcceptRegistrations(): bool
{
return $this === self::Published; // 公開中だけが申込を受け付ける
}
public function canBePublished(): bool
{
return $this === self::Draft; // Draftだけが公開できる
}
}
「どのステータスから何の操作ができるか」がEnumに集約されています。新しいステータスを追加したとき(例:Suspended = '一時停止')、どのメソッドを更新すべきかがEnumを見れば一目瞭然です。
Application層:UseCaseはオーケストレーターに徹する
ビジネスロジックとオーケストレーションの違い
「UseCaseにビジネスロジックを書いてはいけない」と言われても、「では何を書くのか」が分からないと判断できません。その違いを実例で見てみます。
// app/Application/UseCase/CancelRegistration/CancelRegistrationUseCase.php
final readonly class CancelRegistrationUseCase
{
public function __construct(
private EventRepositoryInterface $eventRepository
) {}
public function execute(CancelRegistrationInput $input): CancelRegistrationOutput
{
// 1. プリミティブ値をValueObjectに変換(Application層の責務)
$eventId = EventId::fromString($input->eventId);
$registrationId = RegistrationId::fromString($input->registrationId);
// 2. Repositoryからドメインオブジェクトを取得
$event = $this->eventRepository->findById($eventId);
if ($event === null) {
throw new RuntimeException("Event not found: {$input->eventId}");
}
// 3. Domainのメソッドを呼ぶ(ビジネスロジックはDomain側にある)
$event->cancelRegistration($registrationId);
// 4. 変更を保存
$this->eventRepository->save($event);
return new CancelRegistrationOutput(true);
}
}
UseCaseがやっていることは4つだけです:
- 入力値をValueObjectに変換する
- Repositoryからドメインオブジェクトを取得する
- ドメインメソッドを呼ぶ
- 変更を保存する
「キャンセルできるかどうかの判定」「開催済みイベントへの操作禁止」などのビジネスルールは、$event->cancelRegistration() の中にあります。UseCaseはそれを知らなくていいし、知るべきでもありません。
外部サービス(決済)をどの層のInterfaceにするか
PaymentServiceInterface をどこに置くかは、迷いやすいポイントです。
app/Application/Service/PaymentServiceInterface.php ← 今回の選択
Domainに置かなかった理由があります。「決済」という概念自体はビジネスにとって重要ですが、「どう決済するか(Stripe, 銀行振込, モック)」はインフラの詳細です。そして「決済が成功したら確定する」というオーケストレーションはUseCaseの責務です。DomainオブジェクトはPaymentを知る必要がありません。
Applicationに置いた理由は、UseCaseがこのInterfaceを直接使うためです。Application層が定義したInterfaceを、Infrastructure層が実装する。この依存の方向が自然です。
// app/Application/Service/PaymentServiceInterface.php(Application層が定義)
interface PaymentServiceInterface
{
public function charge(string $userId, int $amount, string $currency): PaymentResult;
public function refund(string $transactionId): PaymentResult;
}
// app/Infrastructure/Payment/MockPaymentGateway.php(Infrastructure層が実装)
final class MockPaymentGateway implements PaymentServiceInterface
{
public function charge(string $userId, int $amount, string $currency): PaymentResult
{
$transactionId = sprintf('txn_%s_%d', uniqid(), time());
return PaymentResult::success($transactionId);
}
}
MockPaymentGateway は名前の通り、本物の決済処理は行いません。開発・テスト環境ではこちらを使い、本番では StripePaymentGateway を実装してDIコンテナで差し替えます。UseCaseのコードは一行も変わりません。
RegisterForEventUseCaseで起きていること
最も複雑なUseCaseを見てみましょう。
// app/Application/UseCase/RegisterForEvent/RegisterForEventUseCase.php
public function execute(RegisterForEventInput $input): RegisterForEventOutput
{
$eventId = EventId::fromString($input->eventId);
$userId = UserId::fromString($input->userId);
$userName = UserName::fromString($input->userName);
$event = $this->eventRepository->findById($eventId);
if ($event === null) {
throw new RuntimeException("Event not found: {$input->eventId}");
}
// ▼ ここで定員・重複・ステータスのチェックが走る(Domain側で)
$registration = $event->register($userId, $userName);
// ▼ 無料イベントは決済不要
if (!$event->price()->isZero()) {
$paymentResult = $this->paymentService->charge(
$userId->value(),
$event->price()->amount(),
$event->price()->currency()
);
if ($paymentResult->isSuccessful()) {
$event->confirmRegistration($registration->id());
}
// 決済失敗時: Pending状態のまま保存(後で再決済できる設計)
} else {
$event->confirmRegistration($registration->id());
}
$this->eventRepository->save($event);
return new RegisterForEventOutput($registration->id()->value());
}
「無料イベントなら即確定、有料なら決済を試みて成功すれば確定」というフロー制御はUseCaseが担っています。これはオーケストレーションです。
ただし「定員を超えたら登録できない」「重複したら登録できない」というルールはDomain(Event::register())が担っています。この境界が明確であれば、「どの層に書くべきか」の判断基準ができます。
Input/OutputクラスでDTOとして層を跨ぐ
CreateEventInput → CreateEventUseCase → CreateEventOutput
UseCaseへの入力と出力に専用クラスを設けています。Presentationから渡されるリクエストデータをそのままUseCaseに渡さないのは、層の分離を守るためです。
PresetnationがどうリクエストをパースするかをUseCaseが知る必要はありません。UseCaseはInputクラスというシンプルなDTOだけを受け取ります。
Infrastructure層:実装の差し替えを可能にする構造
RepositoryInterfaceがDomain層にある理由
Domain層: EventRepositoryInterface(インターフェース定義)
Infrastructure層: InMemoryEventRepository(実装)
この配置は直感に反するかもしれません。「Repositoryの実装はInfrastructureにあるのに、なぜInterfaceだけDomainにあるのか」と。
理由は依存の方向です。Domain層が「Repositoryという概念を必要とする」ので、そのインターフェースはDomainが定義します。Infrastructure層はそれを「実装する」だけです。
もしInterfaceをInfrastructure層に置いてしまうと、Domain層がInfrastructure層を参照することになります。依存の方向が逆転し、Clean Architectureの核心が崩れます。
// ❌ 間違い
Domain → Infrastructure(Infrastructure層のInterfaceを参照)
// ✅ 正しい
Infrastructure → Domain(Domain層のInterfaceを実装する)
InMemoryRepositoryから始めた理由
// app/Infrastructure/Persistence/InMemory/InMemoryEventRepository.php
final class InMemoryEventRepository implements EventRepositoryInterface
{
/** @var array<string, Event> */
private array $events = [];
public function findById(EventId $id): ?Event
{
return $this->events[$id->value()] ?? null;
}
public function save(Event $event): void
{
$this->events[$event->id()->value()] = $event;
}
public function findAll(): array
{
return array_values($this->events);
}
}
DBなし、マイグレーションなし、Eloquentなし。これが InMemoryEventRepository の全貌です。
最初からEloquentで実装しなかった理由は2つです。
理由1:設計の検証を早く行うため。 DBスキーマを決める前にドメインモデルの設計を確認したかった。InMemoryなら即座に動かしてUseCaseの挙動を確認できます。
理由2:Eloquentへの依存をDomainから切り離すため。 もしDomain層に extends Model が紛れ込むと、LaravelなしではDomainのテストが書けなくなります。Repositoryパターンは、この「EloquentとDomainの癒着」を防ぐ壁として機能します。
将来Eloquentで実装する場合も、EventRepositoryInterface を満たした EloquentEventRepository を作ってDIコンテナを差し替えるだけです。Domain層・Application層のコードは一切変更不要です。
実装を通じて学んだClean Architectureの本質
「厳格に守る」からこそ気づける依存の重力
Laravelを使って開発していると、自然と「便利な機能を使いたい」という誘惑があります。Domainクラスの中でFacadeを使いたくなる。Eloquentのスコープをそのままビジネスルールとして使いたくなる。
これを「依存の重力」と呼びたいと思います。重力に逆らわないと、いつの間にかDomainがLaravelに引っ張られています。
厳格に4層分離を守る価値は、「将来フレームワークを変更できる」という話ではありません(そんな機会はほぼ来ません)。本当の価値は「ビジネスロジックがどこにあるかを、常に明確にできる」ことです。
定員オーバーの判定はどこ? → Capacity::hasRoom()
申込できないステータスの判定はどこ? → EventStatus::canAcceptRegistrations()
重複チェックはどこ? → Event::ensureCanRegister()
この問いに即答できる状態が、メンテナブルなコードの正体です。
Laravelで書く現実的なトレードオフ
今回の実装で意図的に妥協した点もあります。
1. App\ namespaceのまま
厳格なClean Architectureなら Domain\ をLaravelの app/ の外に置きたいところです。ただ、チームへの導入コストと設計の純粋さのトレードオフを考えると、app/Domain/ という配置が現実的です。
2. RuntimeException をUseCaseで直接スロー
イベントが見つからない場合に RuntimeException を使っています。本来はApplication層専用のExceptionクラスを用意するほうが丁寧ですが、今回はシンプルさを優先しました。
3. clear() メソッドがInterfaceにない
InMemoryEventRepository にはテスト用の clear() メソッドがありますが、EventRepositoryInterface には定義していません。これは意図的で、「テスト用のメソッドを本番用のInterfaceに混入させない」という判断です。テストコードでは型をinterfaceではなく実装クラスとして宣言し、clear() を呼ぶ設計にします。
まとめ
Clean Architectureは「守れば完璧」という銀の弾丸ではありません。しかし、「なぜこの構造にするか」の理由を理解した上で守ると、コードに設計者の意図が現れてきます。
この記事でお伝えしたかったことを最後にまとめます。
| 設計判断 | 理由 |
|---|---|
| Domain層にLaravelをimportしない | テスタビリティと、ビジネスロジックの純粋さを保つため |
| RepositoryInterfaceをDomain層に置く | 依存の方向を「外→内」に統一するため |
create() と reconstruct() を分ける |
オブジェクトの生成経路を型で制限するため |
| ValueObjectにビジネスメソッドを持たせる | ルールの散在を防ぎ、語彙をドメインに閉じ込めるため |
| UseCaseをオーケストレーターに徹させる | 「ロジックがどこにあるか」を常に明確にするため |
| PaymentをInterfaceで抽象化する | 実装の差し替えを可能にし、テストを書きやすくするため |
コードは以下のリポジトリで公開しています。参考にしてみてください。
GitHub https://github.com/sgm-engineer/event-booking-system
参考
- Robert C. Martin「Clean Architecture 達人に学ぶソフトウェアの構造と設計」
- Eric Evans「Domain-Driven Design」
- PHP 8.1 Enums: https://www.php.net/manual/ja/language.enumerations.php
- PHP 8.1 Readonly Properties: https://www.php.net/manual/ja/language.oop5.properties.php#language.oop5.properties.readonly-properties