🐈

Laravel × レイヤードアーキテクチャ(+ CQRS)

2023/07/17に公開

はじめに

Twitter APIを利用したWEBアプリケーション開発を新規ですることになり、過去技術的負債が課題となっていたため、技術的負債を作りにくく返済しやすいよう、しっかり設計したプロジェクトを立ち上げることになりました。
そこでレイヤードアーキテクチャを採用し、知見が溜まったため勉強も兼ね共有です。

本記事用にLaravel×レイヤードアーキテクチャで設計した LibraSys という架空の書籍管理システムを用意しました。
こちらをベースに解説していきます。

https://github.com/HayatoKudou/laravel-layered-architecture-exmple/

レイヤードアーキテクチャとは

一言で表すと責務ごとにレイヤーに分割するアーキテクチャです。
レイヤードアーキテクチャの主な目的は、各レイヤーを疎結合にして保守性、拡張性、テスト容易性を向上させることです。

UI層 -> Application層 -> Domain層 -> Infrastructure層
の順に各レイヤーは上位レイヤーに依存し、下位レイヤーへの依存を避けるように設計されます。

なぜレイヤードアーキテクチャに?

LaravelはMVCパターン(Model・View・Controllerの略)が採用されており、Modelでデータ操作、ViewでHTML出力、ControllerでModelとViewの制御をするということになっています。

ではなぜMVCを採用しなかったかというと、主に下記の問題が挙げられたためです。
レイヤードアーキテクチャはこれらのMVCの問題を解決することができ、各レイヤーを疎結合にすることでのメリットがあり採用しました。

Controllerの肥大化(Fat Controller)

Controllerは本来ModelとViewの制御という位置づけになってはいますが、実際にはユーザーの入力処理やビジネスロジック処理など多くの処理をすることになり、結果Controllerが過剰な責務や複雑なロジックを持つFat Controller化していました。

ディレクトリ構造秩序の崩壊

Controllerの肥大化を防ぐため、処理を分離しようと UsecaseやUtil・Logics・Services etc... のように、いろいろな場所にいろいろなロジックが乱立して、可読性が低く、使わない古いコードが放置されて、処理の責任も曖昧で、影響範囲の特定もしずらい構造となり秩序が崩壊していました。

CQRSとは

CQRS(Command Query Responsibility Segregation)はコマンドクエリ責務分離原則であり、コマンド(Write)とクエリ(Read)のモデルを分離するパターンです。

なぜCQRS?

データ取得の要件がリポジトリ間で異なる場合、本来であれば複数リポジトリからデータを呼び出し整形する必要がありますが、CQRSを導入した場合、データ取得(クエリ)は分離することができ、リポジトリを介さずとも直接SQLやEloquentModelを使うことができます。

これにより、コマンドはリポジトリを使いデータの変更に特化し、パフォーマンスや整合性を重視する一方、クエリはデータの取得に特化し、読み取り専用の処理を最適化することができます。

ディレクトリ構造

├── app
~~~
├── LibraSys
│   ├── Application
│   │   ├── ReadModel
│   │   └── Service
│   ├── Domain
│   ├── Infrastructure
│   │   ├── EloquentModel
│   │   ├── QueryService
│   │   └── Repository
│   └── UI
│       ├── Controllers
│       └── Requests
~~~

解説

LibraSys -> UI

レイヤードアーキテクチャでのUI層に位置づけされます。
この層ではUIの表示・入力の受け取り・バリデーション実行・レスポンスの作成など、ユーザーに情報を表示する役割を果たします。
Applicationレイヤーを介してビジネスロジックやデータ操作の要求を処理し、結果をユーザーに反映します。

UI -> Controllers

Controllerではバリデーションの実行・アプリケーションサービス呼び出し・クエリサービス呼び出し・レスポンスの作成をしています。

class BookController extends Controller
{
    public function getBookWithRentalHistories(
        BookQueryService $bookQueryService,
        BookControllerGetBook $request,
    ): JsonResponse
    {
        $validated = $request->validated();
        $books = $bookQueryService->fetchWithRentalHistories($validated['bookId']);
        return \response()->json($books);
    }

    public function postBook(
        BookControllerPostBook $request,
        BookAddService $service,
    ): JsonResponse
    {
        $validated = $request->validated();
        $service->add($validated['title'], $validated['description']);

        return \response()->json();
    }
}
UI -> Requests

FormRequestクラスを継承しLaravel標準のバリデーション機能を使用しています。
Controllersではロジックを書かず呼び出しのみにしたいため、バリデーションファイルを分けています。

class BookControllerPostBook extends FormRequest
{
    public function rules(): array
    {
        return [
            "title" => "required|string",
            "description" => "required|string",
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'タイトルは必須です',
            'description.required' => '詳細は必須です',
        ];
    }
}
LibraSys -> Application

レイヤードアーキテクチャでのApplication層に位置づけされます。
ドメイン層とUI層を結ぶ架け橋として機能し、ビジネスロジックのフローを制御します。ドメイン層に直接アクセスせず、ドメインの知識やビジネスルールをカプセル化し、UI層との疎結合性を高めることができます。

Application -> ReadModel

ReadModelはクエリサービス(CQRS)のレスポンスを表すDTOクラスとなります。
ReadModelは必須ではないですが、レスポンスを表すDTOクラスに格納することでコードの可読性が向上し、データ構造の理解や変更が容易になります。

class BookWithRentalHistory
{
    public function __construct(
        public readonly string $bookTitle,
        public readonly string $userName,
        public readonly CarbonImmutable $rentalDate
    ) {
    }
}
Application -> Service

Application Serviceは、UI層からのリクエストを受け取り、ドメイン層のエンティティを操作して必要なビジネスロジックを実行します。
ここではBookドメインを作成しリポジトリを介して永続化しています。

class BookAddService
{
    public function __construct(
        private readonly BookRepository $bookRepository,
    ) {
    }

    public function add(string $title, string $description): void
    {
        $book = new Book($title, $description);
        $this->bookRepository->store($book);
    }
}
LibraSys -> Domain

レイヤードアーキテクチャでのDomain層に位置づけされます。
Domain層は、ビジネスルールやドメイン知識をカプセル化し、アプリケーションの中核となるエンティティや値オブジェクトを定義します。
Bookエンティティでは、本という概念がタイトルと説明で構成されているというドメイン知識を表現しています。

class Book
{
    public function __construct(
        public readonly string $title,
        public readonly string $description
    ) {
    }
}

LibraSys -> Infrastructure

レイヤードアーキテクチャでのInfrastructure層に位置づけされます。
Infrastructure層はデータアクセスや外部リソースとのやり取りなど、アプリケーションのインフラストラクチャに関連する要素を担当するレイヤーです。

Infrastructure -> EloquentModel

LaravelのEloquentモデルを格納するディレクトリです。

class Book extends BaseModel
{
    public function rentalHistories(): HasMany
    {
        return $this->hasMany(BookRentalHistory::class);
    }
}
Infrastructure -> QueryService

QueryServiceはクエリサービス(CQRS)のデータ取得(クエリ)をしています。
ここでは書籍情報とその書籍の貸し出し履歴・ユーザー情報を取得しています。

class BookQueryService
{
    public function fetchWithRentalHistories(string $bookId): array
    {
        $book = Book::find($bookId);

        return $book->rentalHistories
            ->map(function (BookRentalHistory $history) use ($book) {
                return new BookWithRentalHistory(
                    bookTitle: $book->title,
                    userName: $history->user,
                    rentalDate: $history->rental_date
                );
            })->all();
    }
}
Infrastructure -> Repository

Repositoryはデータの永続化やデータアクセスを抽象化するためのコンポーネントです。
ドメイン層からの要求に応じてデータストアへのアクセスを提供し、データの取得・保存・更新・削除などの操作を行います。
ここではBookエンティティの保存処理をしています。

class BookRepository
{
    public function store(Domain\Book $book): void
    {
        EloquentModel\Book::create([
            'title' => $book->title,
            'description' => $book->description,
        ]);
    }
}

終わりに

今回用意したサンプルプロジェクトでは要件が少なく、レイヤードアーキテクチャ・CQRSのメリットをうまく表現できませんでした。
今回のように要件が少ないプロジェクトの場合は、MVCパターンで実装した方が良い場合もあります。

レイヤードアーキテクチャは大規模なアプリケーションや複雑なビジネスロジックに適しており、MVCパターンに比べると複雑で、実装工数やコード量、学習難易度の観点で劣っている部分もあります。
どちらを選ぶべきかは、プロジェクトの要件や目標を明確にし、慎重に選定することが大切です。

Discussion