1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

なぜUseCaseはビジネスロジックを持ってはいけないのか ─ LaravelでClean Architectureの4層分離を厳格に実装して気づいたこと

1
Posted at

はじめに

「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つだけです:

  1. 入力値をValueObjectに変換する
  2. Repositoryからドメインオブジェクトを取得する
  3. ドメインメソッドを呼ぶ
  4. 変更を保存する

「キャンセルできるかどうかの判定」「開催済みイベントへの操作禁止」などのビジネスルールは、$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

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?