💪

Laravelアプリケーションで実践する package by feature! 安全に変更や拡張ができるアプリケーションを目指して

2021/12/26に公開
1

こんにちは。
tyamahoriです。いつもお世話になってます。

そんな自分は、2020年11月より株式会社MFSにて、不動産投資サービスのオンライン不動産投資サービス「インベース」にて主にAPI,管理画面の担当として携わっております。

https://www.mortgagefss.jp/
https://investment.mogecheck.jp/

絶賛採用中です!この記事を読んで楽しそうと感じた人!!!来てください!!!
https://recruit.mortgagefss.jp/

インベースでは、バックエンドにてLaravelを使っております。
2021年6月にサービスリニューアルを行い、はや半年が立ちました。機能を追加するにあたって、アプリケーション設計・実装の方針があやふやになってきたため。今回改めて指針を整理し、来年以降の開発に生かしていくことにしました。

自分なりにようやく1つの答えにたどり着きました。今後はこの考えをベースに改善をして行きたいと思います。

設計方針概略

結論として、以下のような設計方針で行く予定です。

  1. Laravelに密に依存をせず、疎結合な対応ですすめる。
  2. ディレクトリを業務・関心・機能ごとなどに区切る
  3. 区切ったディレクトリをサブシステムとしてみなし、その中にコントローラーからEloquent/クエリビルダクラスまですべてを入れる
  4. ディレクトリ内にて完結するようにして、他のディレクトリに依存をしない
  5. 不要な機能を落とす場合は、該当するディレクトリを削除することで対応できるようにする

としました。
本当は例のアルファベット3文字の〇〇〇や〇〇〇〇アーキテクチャの思想を取り入れて声を大にしてry

どうしてLaravelに対して疎結合なのか

理由としては以下の想定しています。

  • Laravelのアップデートに対して柔軟に対応できる(はず)
  • 将来的にLaravelから他のフレームワークに乗り換える際に手間を最小化できる

LaravelはPHPにおいてはデファクトスタンダードになっていると思いますので、早々に廃れてしまうことは考えづらいです。しかしながらLaravelのアップデートは避けて通れません。LTSのアップデートは安全に行いたいものです。

DBやRedisなどのデータソース層はいわゆるリポジトリパターンを用いて、疎結合な実装をしていきます。DBの乗り換えを行うと言うことはほぼないかもしれませんので必要性が感じられない人がいるかも知れません。ただ、何かしらの新規機能を実装した際に、DBの設計に依存せず、Repostioryの入出力を定義で開発を進められるというメリットもあります。アプリケーションとDBをそれぞれに開発できるのはメリットだと考えます。

どういう考えでディレクトリを切り分けるのか

よくある従来の方法として、controller, service, repostiory, eloquent, といった分け方があります。しかしながらこのやり方だと関心事が散らばってしまい、アプリケーションの見通しが悪くなってしまします。ですので、代わりに、機能・関心・業務といったものからのアプローチを取ります。これによって、影響範囲を特定ディレクトリに閉じ込めて、アプリケーションの見通しを良くすることができます。

ミノ駆動さんの以下のツイートが参考になります。

また、機能が不要になったらディレクトリを削除することで簡単に対応できるようになります。

名人さんの記事がすごく参考になります。
https://qiita.com/mejileben/items/48473a572ec07cbaf65f

Laravelにおける具体的手順

では上記の考えを実践していくにあたって、どうするべきかをまとめます。以下の手順になるかと思います。

  1. composer.jsonを編集してディレクトリを追加する
  2. 追加したディレクトリの中にて、特定の業務や機能に特化したディレクトリを掘っていく
  3. ほったディレクトリの中に、ファイルをどんどん置いていく。

難しいのは、どのようにディレクトリを切り分けて行くかです。正直は話、ここの答えは見つけられていません。大きすぎてもいけませんし、小さすぎてもいけません。ここはチャレンジをしていくしかないと思います。ディレクトリに閉じた実装を行う前提であれば、削除はしやすいので、失敗しても後戻りしやすいと考えています。

composer.jsonの編集

以下の例を御覧ください。

composer.json
composer.json
{
  "name": "laravel/laravel",
  "type": "project",
  "description": "The Laravel Framework.",
  "keywords": [
    "framework",
    "laravel"
  ],
  "license": "MIT",
  "require": {
    "php": "^8.1",
    "fruitcake/laravel-cors": "^2.0",
    "guzzlehttp/guzzle": "^7.0.1",
    "laravel/framework": "^8.54",
    "laravel/sanctum": "^2.11",
    "laravel/tinker": "^2.5",
    "league/flysystem-aws-s3-v3": "^1.0"
  },
  "require-dev": {
    "barryvdh/laravel-debugbar": "^3.6",
    "barryvdh/laravel-ide-helper": "^2.10",
    "brianium/paratest": "^6.4",
    "facade/ignition": "^2.5",
    "fakerphp/faker": "^1.9.1",
    "laravel/sail": "^1.0.1",
    "mockery/mockery": "^1.4.2",
    "nunomaduro/collision": "^5.0",
    "phpunit/phpunit": "^9.3.3",
    "roave/security-advisories": "dev-latest"
  },
  "autoload": {
    "psr-4": {
      "App\\": "app/",
      "Database\\Factories\\": "database/factories/",
      "Database\\Seeders\\": "database/seeders/",
      "Package\\": "package/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  },
  "scripts": {
    "post-autoload-dump": [
      "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
      "@php artisan package:discover --ansi"
    ],
    "post-update-cmd": [
      "@php artisan vendor:publish --tag=laravel-assets --ansi"
    ],
    "post-root-package-install": [
      "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
    ],
    "post-create-project-cmd": [
      "@php artisan key:generate --ansi"
    ]
  },
  "extra": {
    "laravel": {
      "dont-discover": []
    }
  },
  "config": {
    "optimize-autoloader": true,
    "preferred-install": "dist",
    "sort-packages": true
  },
  "minimum-stability": "dev",
  "prefer-stable": true
}

ポイントはここです

composer.json
  "autoload": {
    "psr-4": {
      "App\\": "app/",
      "Database\\Factories\\": "database/factories/",
      "Database\\Seeders\\": "database/seeders/",
      "Package\\": "package/"
    }
  }

appと同列のディレクトリにpackageというディレクトリを作りました。そしてnamespaceはPackageから始まるようにしています。ここの命名は自由に決めてください。サービス名なり、プロジェクト名なり、コードネームなり色々あると思いますが、とにかく無難なものをおすすめしたいです。後で名前を変えることができなくなってしまうので。。

terminal
# そしてお約束の
$ composer dump-autoload

packageディレクトリを作る

先程のcomposer.jsonの内容に応じて、Laravelアプリケーションの直下にpackageディレクトリを作ります。

terminal
$ mkdir package

ディレクトリはこんな感じになります。

terminal
$ tree -L 1   
.
├── _ide_helper.php
├── _ide_helper_models.php
├── app
├── artisan
├── bootstrap
├── composer.json
├── composer.lock
├── config
├── database
├── node_modules
├── package
├── phpunit.xml
├── public
├── resources
├── routes
├── server.php
├── storage
├── tests
└── vendor

早速packageディレクトリを作り込んでいきます。

今回はこのようなサンプルで作ります。

terminal
$ cd package
$ tree -d  
.
└── SomeSpecificApplication
    └── CreateApp
        ├── Adaptor
        ├── Concrete
        │   ├── Adaptor
        │   └── Infrastructre
        │       └── Repository
        ├── Domain
        │   ├── Entity
        │   ├── Repository
        │   └── ValueObject
        └── UseCase

packageディレクトリ内部のディレクトリ構成として、SomeSpecificApplication/CreateApp としてみました。業務関心ごとにディレクトリを作るので、CreateAppのようなディレクトリが業務関心の数だけできます。

業務ディレクトリを作り込む

業務ディレクトリの詳細は以下の通りです。

terminal
$ cd SomeSpecificApplication/CreateApp
$ tree
.
├── Adaptor
│   └── CreateAppControllerInterface.php
├── Concrete
│   ├── Adaptor
│   │   └── CreateAppController.php
│   └── Infrastructre
│       └── Repository
│           └── AuthLoggerRepository.php
├── Domain
│   ├── Entity
│   │   └── LoginUserAccount.php
│   ├── Repository
│   │   └── SampleRepositoryInterface.php
│   └── ValueObject
│       ├── Age.php
│       └── Name.php
└── UseCase
    ├── CreateAppParameter.php
    ├── CreateAppResponse.php
    └── CreateAppUseCase.php

ポイントは、コントローラーから、データアクセス層まですべてを入れ込んでいます。これはLaravelのお作法からは外れてしまうやり方ではあります。たとえばコントローラーの生成コマンドそのままでは使えません。

公式のドキュメントにてStub Customizationがあるようです。こちらを利用すれば便利にはなりそうです。
https://laravel.com/docs/8.x/artisan#stub-customization

Adaptorディレクトリ

一番外側の層の役割を果たします。コントローラーを配置したり、APIのレスポンスの形式を生成するクラスをおいたりします。

  • Interfaceにしている意図としては、疎結合目的だったり、具象クラスを必要に応じて差し替えられるからです。また、__invokeメソッドを定義して、1メソッドのみを実装できるようにしてより責務を明確化させる狙いがあります。Jsonを返したり、HTMLを返したり、はたまたMockの某を返したりできます。また、コントローラ以外にレスポンスを整形するクラスを置くものありです。
CreateAppControllerInterface.php
CreateAppControllerInterface.php
// 筆者は1クラス、1パブリックメソッドを愛しています。
interface CreateAppControllerInterface
{
    public function __invoke();
}

UseCaseディレクトリ

ここではユースケース周りのクラスを格納しています。

  • ユースケースが使う、パラメータクラスです。いわゆるDTOクラスです。
CreateAppParameter.php
CreateAppParameter.php
<?php

namespace Package\SomeSpecificApplication\CreateApp\UseCase;

use InvalidArgumentException;

class CreateAppParameter
{
    /**
     * @var string
     */
    public readonly string $name;

    /**
     * @var int
     */
    public readonly int $age;

    /**
     * @param array $input
     */
    public function __construct(array $input)
    {
        if (empty($input['name'])) {
            throw new InvalidArgumentException('Name is required');
        }

        if (empty($input['age']) && !is_int($input['age'])) {
            throw new InvalidArgumentException('Name is required');
        }

        $this->name = $input['name'];
        $this->age = $input['age'];
    }
}
  • UseCaseクラスです。POPOで作ります。
CreateAppUseCase.php
CreateAppUseCase.php
<?php

namespace Package\SomeSpecificApplication\CreateApp\UseCase;

use Package\SomeSpecificApplication\CreateApp\Domain\Entity\LoginUserAccount;
use Package\SomeSpecificApplication\CreateApp\Domain\Repository\SampleRepositoryInterface;
use Package\SomeSpecificApplication\CreateApp\Domain\ValueObject\Age;
use Package\SomeSpecificApplication\CreateApp\Domain\ValueObject\Name;

class CreateAppUseCase
{
    /**
     * @param SampleRepositoryInterface $sampleRepository
     */
    public function __construct(
        private SampleRepositoryInterface $sampleRepository
    ) {
    }

    /**
     * @param CreateAppParameter $parameter
     * @return CreateAppResponse
     */
    public function __invoke(CreateAppParameter $parameter): CreateAppResponse
    {
        $name = new Name($parameter->name);
        $age = new Age($parameter->age);

        $accountUser = LoginUserAccount::new($name, $age);

        $this->sampleRepository->auth($accountUser);

        return new CreateAppResponse([
            'name' => $accountUser->name->name,
            'age' => $accountUser->age->age,
        ]);
    }
}
  • ユースケースが返す、レスポンスクラスです。いわゆるDTOクラスです。
CreateAppResponse.php

※CreateAppParameterと一緒になっています。サンプルなので許してください。

CreateAppResponse.php
<?php

namespace Package\SomeSpecificApplication\CreateApp\UseCase;

use InvalidArgumentException;

class CreateAppResponse
{
    /**
     * @var string
     */
    public readonly string $name;

    /**
     * @var int
     */
    public readonly int $age;

    /**
     * @param array $output
     */
    public function __construct(array $output)
    {
        if (empty($output['name'])) {
            throw new InvalidArgumentException('Name is required');
        }

        if (empty($output['age']) && !is_int($output['age'])) {
            throw new InvalidArgumentException('Age is required');
        }

        $this->name = $output['name'];
        $this->age = $output['age'];
    }
}

Domainディレクトリ

Value Object, Entity, RepositoryInterfaceといったいわゆるドメインクラス格納していきます。
また、Queryディレクトリを追加して、Queryクラスを置くのもありです。今回のサンプルでは作成していませんが、別の機会にここにコンテンツを追加したいと思っています。

Concreteディレクトリ

フレームワークやライブラリに依存した処理をゴリゴリ書いていきます。このディレクトリに閉じ込めて行きます。

  • Laravelではコントローラはapp/Http/Controllers配下に置かれるのが定石ですが、今回は心を鬼にします。
CreateAppController.php
CreateAppController.php
<?php

namespace Package\SomeSpecificApplication\CreateApp\Concrete\Adaptor;

use App\Http\Controllers\Controller;
use Package\SomeSpecificApplication\CreateApp\Adaptor\CreateAppControllerInterface;
use Package\SomeSpecificApplication\CreateApp\UseCase\CreateAppParameter;
use Package\SomeSpecificApplication\CreateApp\UseCase\CreateAppUseCase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CreateAppController extends Controller implements CreateAppControllerInterface
{
    /**
     * @param Request $request
     * @param CreateAppUseCase $createAppUseCase
     */
    public function __construct(
        private Request $request,
        private CreateAppUseCase $createAppUseCase,
    ) {
        // FromRequestは必要に応じて使いましょう。筆者はFormRequestを使うべき宗派に属していましたが、
	// 結局コントローラにて最初にバリデーションするのが良いのでは?というところに落ち着いています。
        $this->validate($this->request, [
            'user_name' => ['required', 'string', 'max:255'],
            'user_age' => ['required', 'integer', 'min:0', 'max:40'],
        ]);
    }

    /**
     * @return JsonResponse
     */
    public function __invoke(): JsonResponse
    {
        $parameter = new CreateAppParameter([
            'name' => $this->request->input('user_name'),
            'age' => $this->request->input('user_age'),
        ]);

        $response = $this->createAppUseCase->__invoke($parameter);

        return new JsonResponse([
            'content' => [
                'message' => 'Successfully created user!',
                'userProfile' => [
                    'name' => $response->name,
                    'age' => $response->age,
                ],
            ],
        ]);
    }
}
  • repostioryの具象クラスでは単純にログを出力しています。
AuthLoggerRepository.php
AuthLoggerRepository.php
<?php

namespace Package\SomeSpecificApplication\CreateApp\Concrete\Infrastructre\Repository;

use Package\SomeSpecificApplication\CreateApp\Domain\Entity\LoginUserAccount;
use Package\SomeSpecificApplication\CreateApp\Domain\Repository\SampleRepositoryInterface;
use Psr\Log\LoggerInterface;

class AuthLoggerRepository implements SampleRepositoryInterface
{
    /**
     * @param LoggerInterface $logger
     */
    public function __construct(
        private LoggerInterface $logger
    ) {
    }

    /**
     * @param LoginUserAccount $loginUserAccount
     * @return void
     */
    public function auth(LoginUserAccount $loginUserAccount): void
    {
        $this->logger->info('login', [
            'message' => 'success!',
            'user' => [
                'name' => $loginUserAccount->name->name,
            ],
        ]);
    }
}

routing設定

routingに関してはLaravelに任せます。

routes/api.php
api.php
<?php

use Package\SomeSpecificApplication\CreateApp\Adaptor\CreateAppControllerInterface;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/

Route::post('sample', CreateAppControllerInterface::class)->name('sample');

AppServiceProvider設定

コントローラとリポジトリのInterfaceと具象クラスを紐付けます。

AppServiceProvider.php
AppServiceProvider.php
<?php

namespace App\Providers;

use Package\SomeSpecificApplication\CreateApp\Adaptor\CreateAppControllerInterface;
use Package\SomeSpecificApplication\CreateApp\Concrete\Adaptor\CreateAppController;
use Package\SomeSpecificApplication\CreateApp\Concrete\Infrastructre\Repository\AuthLoggerRepository;
use Package\SomeSpecificApplication\CreateApp\Domain\Repository\SampleRepositoryInterface;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register(): void
    {
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot(): void
    {
        $this->app->bind(
            SampleRepositoryInterface::class,
            AuthLoggerRepository::class
        );

        $this->app->bind(
            CreateAppControllerInterface::class,
            CreateAppController::class
        );
    }
}

まとめ

Laravelのオーソドックスなルールからは逸脱してしまっているものの、ディレクトリを関心事に切ることによって、見通しの良い設計・実装が実現できるかと思います。今後はこちらを試してみて、メリット・デメリットを検証して行きたいと思っています。

ソース

GitHubにアップしています。

プロジェクト

https://github.com/tyamahori/laravel-template

該当ディレクトリ

https://github.com/tyamahori/laravel-template/tree/main/src/package/SomeSpecificApplication/CreateApp

Discussion