LaravelにおけるクリーンアーキテクチャなAPI実装の解説

はじめに

この記事は、トラストバンク Advent Calendar 2022の12日目です。

こんにちは、今年サーバーサイドエンジニア2年目になった石川です。

本記事では、現在私が社内で開発に携わっているAPIアーキテクチャに関する解説記事となっています。

APIアーキテクチャ

では早速APIアーキテクチャについてご紹介したいと思います。

まず、本APIアーキテクチャには、クリーンアーキテクチャが採用されています。

こちらはAPIチームのテックリードによってAPIの設計/実装に落とし込まれており、 私も日々お世話になっているアーキテクチャなわけですが、 現状、外向けの解説記事があまりなかったため本記事にて解説したいと思います。

※実際のAPIの内容については触れませんのでご了承ください

下記、テックリードの記事になります。

Laravelでクリーンアーキテクチャ (ディレクトリ構造編) - トラストバンクテックブログ

DDDの実務への落とし込み(with PHP) - トラストバンクテックブログ

ディレクトリ構成

まず、下図がAPIディレクトリ構成とクリーンアーキテクチャとの関係図になります。

※今回はサンプルで返礼品情報を取得するAPIを作成します

解説前にAPIの挙動イメージの説明

今回は、返礼品のIDから返礼品の名前と金額を取得するAPIを例に作成します。 まずはこちらの挙動のイメージを記載いたします。

パスパラメータに返礼品IDを指定し、GETリクエストを実行します。

GET /products/{product_id}

レスポンスとして、返礼品ID、返礼品名、金額を返します。

{
  "id": 123456,
  "name": "トラストバンク市ふるさと納税の返礼品",
  "amount": 10000,
}

上記のようなAPI機能について、ここからは実装例も交えて解説していきたいと思います。

実装例

では実際に、Controllerから実装を追っていきたいと思います。

Controller App\Http\Controllers\Product\Api\ProductsController

まず、APIが叩かれると、Controllerが呼び出されます。(今回はMiddlewareの処理等は省きます)

呼び出されたControllerは下記のように振る舞います。

  1. リクエスト値をもとにしたInputの生成

  2. InputをもとにしたInteractorの実行

  3. Outputのレスポンス整形

<?php
namespace App\Http\Controllers\Product\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\Products\GetRequest;
use App\Contexts\Product\Infrastructure\Presenter\CreateResponse;
use App\Contexts\Product\UseCase\Get\Interactor;
use App\Contexts\Product\UseCase\Get\Input;
use Illuminate\Http\JsonResponse;

class ProductsController extends Controller
{
    /**
     * @param Interactor $interactor
     * @param GetRequest $request
     * @return CreateResponse|JsonResponse
     */
    public function get(Interactor $interactor, GetRequest $request): CreateResponse|JsonResponse
    {
        $input = Input::fromArray($request->validated());
        $output = $interactor->execute($input);
        return new CreateResponse($output);
    }
}

Input App\Contexts\Product\UseCase\Get\Input

Controllerから呼び出されたInputはリクエスト値からValueObjectを生成します。

今回の場合は、パスパラメータで指定されたproduct_id(返礼品ID)からProductIdというValueObjectを生成し、Inputにもたせています。

Inputでは、ユーザからの入力値がValueObjectにマッチしているかバリデーションを行うことで、入力値不正によるビジネスロジックでのエラーを未然に防止します。 (なお、ValueObjectのバリデーションの前に、LaravelのFormRequestを用いてもバリデーションを行っています。)

InputValidatorSeasalt1の機能で、リクエスト値がValueObjectにマッチしているかバリデーションを行ってくれます

<?php
declare(strict_types=1);

namespace App\Contexts\Product\UseCase\Get;

use App\CoreDomain\Value;
use Seasalt\Nicoca\Components\UseCase\InputValidator;

/**
 * 入力パラメータ
 *
 * @see Interactor
 */
final class Input
{
    public function __construct(private Value\Product\ProductId $productId)
    {

    }

    /**
     * @return Value\Product\ProductId
     */
    public function getProductId(): Value\Product\ProductId
    {
        return $this->productId;
    }

    /**
     * Requestからインスタンスを作成する
     *
     * @param array<string, string> $input
     * @return static
     */
    public static function fromArray(array $input): self
    {
        $validator = new InputValidator(
            requiredFields: [
                'product_id' => Value\Product\ProductId::class,
            ]
        );
        $validator->validate($input);

        return new self(
            Value\Product\ProductId::fromNumber((int)$input['product_id']),
        );
    }
}

Interactor App\Contexts\Product\UseCase\Get\Interactor

次に、Interactorでは、先程生成したInputをもとにProductエンティティの復元などビジネスロジックの処理を実行していきます。

なお、このInteractorのコンストラクタでは、この後にEntityを復元する際に用いられる、ProductRepositoryを記載していますが、このProductRepositoryはInterfaceとなっており、実際にはこのInterfaceを実装したProductRepositoryImplがLaravelのサービスコンテナとして注入されています。(リポジトリとは、データの永続化を担うコンポーネントのことです)

ここでは、Productエンティティに対してProductRepositoryというInterfaceのみを渡すことで、ビジネスロジックと外部に依存する実装を明確に分離しています。

これによりアプリケーションのテストや移植が容易になるとともに、データストアの切り替え等も容易になります。

<?php
declare(strict_types=1);

namespace App\Contexts\Product\UseCase\Get;

use App\Contexts\Product\Domain\Entity\Product;
use App\Contexts\Product\Domain\Persistence\ProductRepository;

final class Interactor
{
    public function __construct(private ProductRepository $productRepository) {

    }

    public function execute(Input $input): Output
    {
        $product = Product::restore($input->getProductId(), $this->productRepository);
        return new Output($product);

    }
}

Entity App\Contexts\Product\Domain\Entity\Product

では、引き続きEntityの説明になります。

こちらではInputの内容をもとに、リポジトリからProductエンティティの復元を行います。

Entityとは、ざっくり説明すると特定のContextにおいて、ビジネスロジックを表現するためのオブジェクトであり、 (あくまで本APIの実装では)複数のValueObjectとそれらを操作するメソッドによって構築されています。

もっとざっくり説明すると、ValueObjectが意味のあるまとまりになっているのがEntityなんだな〜程度の認識でも一旦大丈夫です。

また、本APIでは、エンティティで使用するValueObjectをApp\CoreDomain\Value配下で管理しており、Contextに依存せず、横断的にValueObjectを使用できるようにしています。

なお、ProductRepositoryを継承したProductRepositoryImplの具体的な実装の説明については、今回のアーキテクチャの話からはそれますので割愛いたしますが、基本的にLaravelのEloquentを用いて実装されています。

<?php
declare(strict_types=1);

namespace App\Contexts\Product\Domain\Entity;

use App\CoreDomain\Value;
use App\Contexts\Product\Domain\Persistence\ProductRepository;
use App\Contexts\Product\Domain\Persistence\ProductRepositoryRecord;

final class Product
{
    private function __construct(
        private Value\Product\ProductId $id,
        private Value\Product\ProductName $productName,
        private Value\Product\Amount $amount
    )
    {

    }

    /**
     * @return Value\Product\ProductId
     */
    public function getId(): Value\Product\ProductId
    {
        return $this->id;
    }

    /**
     * @return Value\Product\ProductName
     */
    public function getProductName(): Value\Product\ProductName
    {
        return $this->productName;
    }

    /**
     * @return Value\Product\Amount
     */
    public function getAmount(): Value\Product\Amount
    {
        return $this->amount;
    }

    /**
     * 永続化されたエンティティを復元する
     * @param  Value\Product\ProductId  $productId
     * @param  ProductRepository  $repository
     * @return static|null
     */
    public static function restore(
        Value\Product\ProductId $productId,
        ProductRepository $repository,
    ): self|null
    {
        $record = $repository->restore($productId);
        return self::restoreFromRecord($record);
    }

    /**
     * @param  ProductRepositoryRecord  $record
     * @return static
     */
    private static function restoreFromRecord(ProductRepositoryRecord $record): self
    {
        return new self(
            id: $record->getId(),
            productName: $record->getProductName(),
            amount: $record->getAmount(),
        );

    }
}

Output App\Contexts\Product\UseCase\Get\Output

Outputでは、先程復元されたProductエンティティをInteractorから受け取ります。

ここでは、あくまで生成したProductエンティティを保持するだけで、実際のレスポンスの整形等は行いません。

上記の理由として、現在のAPIの仕様上では、レスポンス形式がAPIだけと決まっていますが、今後ウェブビュー等でもレスポンスを行いたいとなった場合に、Outputが特定のレスポンス形式に依存していると改修にコストがかかることが挙げられます。

<?php
declare(strict_types=1);

namespace App\Contexts\Product\UseCase\Get;

use App\Contexts\Product\Domain\Entity\Product;

/**
 * 出力結果
 *
 * @see Interactor
 */
final class Output
{
    public function __construct(private ?Product $product)
    {

    }

    /**
     * @return Product|null
     */
    public function getProduct(): ?Product
    {
        return $this->product;
    }
}

Response App\Contexts\Product\Infrastructure\Presenter\CreateResponse

最終的に、Outputからレスポンスを生成して返します。

こちらは冒頭のControllerで呼ばれています。

<?php

declare(strict_types=1);

namespace App\Contexts\Product\Infrastructure\Presenter;

use Illuminate\Http\JsonResponse;
use App\Contexts\Product\Domain\Entity\Product;

final class CreateResponse extends JsonResponse
{
    public function __construct($output)
    {
        /** @var $product Product */
        $product = $output->getProduct();
        $data = [
            'id'     => $product->getId()->getValue(),
            'name'   => $product->getProductName()->getValue(),
            'amount' => $product->getAmount()->getValue(),
        ];
        parent::__construct($data);
    }
}

と、全体の流れは以上になります。

ユニットテスト

ここからは、軽くAPIで実装しているユニットテストの例を記載していきたいと思います。

と、本題に入る前に少し前提を整理します。(Modelの定義やらなんやら)

まず、App\Models\Productを作成し、Factoryを持つよう記載しておきます。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

/**
 * 返礼品
 */
class Product extends Model
{
    use HasFactory;
}

次にDatabase\Factories\ProductFactoryを作成します。

これによって、UnitテストからModels\Product::factory()するだけでデータが生成されます。

なお、$this->fakerは継承元のFactoryクラスにて宣言されているため、そのまま使用できます。 (fakerはよしなにダミーデータを生成してくれるLaravelのライブラリです)

<?php
declare(strict_types=1);

namespace Database\Factories;

use App\Models;
use Illuminate\Database\Eloquent\Factories\Factory;

final class ProductFactory extends Factory
{
    /**
     * @var string
     */
    protected $model = Models\Product::class;

    /**
     * @return array
     */
    public function definition(): array
    {
        return [
            'name' => '[返礼品名]' . $this->faker->title(),
            'amount' => 1000,
            'created' => '2022-12-12 00:00:00',
            'modified' => '2022-12-12 00:00:00',
        ];
    }
}

では実際のテストの実装に移りたいと思います。

Featureテスト Tests\Feature

Tests\Featureでは、APIのリクエストからレスポンスまでの一連の動作を担保します。

BusinessCheckTest Tests\Feature\Product\GetBusinessCheckTest

こちらのBusinessCheckTestでは、主に200 OK以外のリクエストパターンをカバーします。

今回のケースでは、返礼品が削除された場合の404エラーのチェックを行っています。

<?php

declare(strict_types=1);

namespace Tests\Feature\Product;

use App\Models;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;

/**
 * API 例外のテスト
 */
final class GetBusinessCheckTest extends TestCase
{
    /**
     * 削除された返礼品の取得で404が返ってくること
     */
    public function testException1(): void
    {
        Models\Product::factory()->create([
            'id' => 2,
        ]);
        Models\Product::find(2)->delete();

        $this->json('GET', '/products/2'
        )->assertNotFound();
    }

    public function setUp(): void
    {
        parent::setUp();
        Artisan::call('migrate:refresh');
    }

    public function tearDown(): void
    {
        Artisan::call('migrate:refresh');
        \DB::connection()->disconnect();
        parent::tearDown();
    }
}

BusinessLogicTest Tests\Feature\Product\GetBusinessLogicTest

こちらのBusinessLogicTestでは、正常系のリクエストパターンをカバーします。

今回は、返礼品の情報を正しく取得できていることをテストしています。

<?php

declare(strict_types=1);

namespace Tests\Feature\Product;

use App\Models;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;

/**
 * 取API 正常系のテスト
 */
final class GetBusinessLogicTest extends TestCase
{
    /**
     * 返礼品情報が取得できること
     */
    public function testPassing1(): void
    {
        Models\Product::factory()->create([
            'id'     => 1,
            'name'   => 'テストの返礼品名',
            'amount' => 1000,
        ]);
        $response = $this->json('GET', '/v2/products/1')
            ->assertOk()
            ->assertJsonStructure([
                'id',
                'name',
                "amount",
            ]);
        $product = json_decode($response->getContent(), true);
        $this->assertEquals(expected: 1, actual: $product['id']);
        $this->assertEquals(expected: 'テストの返礼品名', actual: $product['name']);
        $this->assertEquals(expected: 1000, actual: $product['amount']);
    }

    public function setUp(): void
    {
        parent::setUp();
        Artisan::call('migrate:refresh');
    }

    public function tearDown(): void
    {
        Artisan::call('migrate:refresh');
        \DB::connection()->disconnect();
        parent::tearDown();
    }
}

ValidationTest Tests\Feature\Product\GetValidationTest

GetValidationTestでは、LaravelのFormRequestが正しく動作しているかをチェックします。

今回は、リクエストのクエリパラメータが適切な数値でない場合のチェックを行っています。

なお、ここではFormRequestのバリデーションエラーとなりすぐにレスポンスを返すため、Modelでのデータの生成等は不要です。

<?php

declare(strict_types=1);

namespace Tests\Feature\Product;

use Tests\TestCase;

/**
 * FormRequestによるバリデーションのテスト
 */
final class GetValidationTest extends TestCase
{
    /**
     * 返礼品IDのバリデーション
     */
    public function testCanValidateProductId(): void
    {
        // 未指定エラー
        $this->getJson('/v2/products/')
            ->assertStatus(422)
            ->assertJsonValidationErrors(['product_id' => 'The product id field is required.']);

        // 下限エラー
        $this->getJson('/v2/products/0')
            ->assertStatus(422)
            ->assertJsonValidationErrors(['product_id' => 'The product id must be at least 1.']);

        // 上限エラー
        $this->getjson('/v2/products/1000000000')
            ->assertstatus(422)
            ->assertjsonvalidationerrors(['product_id' => 'The product id must not be greater than 999999999.']);

        // 英字含み
        $this->getjson('/v2/products/a1234')
            ->assertstatus(422)
            ->assertjsonvalidationerrors(['product_id' => 'The product id must be an integer.']);
    }
}

Unitテスト Tests\Unit

Unitテストでは、基本的にUsecase配下のモジュールのテストを行います。

Usecaseのテスト Tests\Unit\UseCase\Product\DetailTest

このテストでは、Usecase内の動作をチェックします。

基本的に、Featureテストと同様の内容に感となっていますが、 APIの場合には、ユースケースのOutputとAPIのレスポンスとの間にあまり差異がないため、 FeatureテストとUnitテストとの間の差異も余りありません。

<?php
declare(strict_types=1);

namespace Tests\Unit\UseCase\Product;

use App\Contexts\Product\Domain\Exception\ProductNotFoundException;
use App\Contexts\Product\UseCase\Get\Input;
use App\Contexts\Product\UseCase\Get\Interactor;
use App\Models;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;

final class DetailTest extends TestCase
{
    /**
     * 返礼品情報を正しく取得できる
     */
    public function testPassing1()
    {
        Models\Product::factory()->create([
            'id'     => 1,
            'name'   => 'テストの返礼品名',
            'amount' => 1000,
        ]);
        $input = Input::fromArray(['product_id' => 1]);
        $interactor = resolve(Interactor::class);
        $output = $interactor->execute($input);
        $product = $output->getProduct();

        $this->assertEquals(expected: 1, actual: $product->getId()->getValue());
        $this->assertEquals(expected: 'テストの返礼品名', actual: $product->getName()->getValue());
        $this->assertEquals(expected: 1000, actual: $product->getAmount()->getValue());
    }

    /**
     * 返礼品情報が存在せずエラーとなる
     */
    public function testFailure1()
    {
        // 以降の処理で例外が発生することを検証
        $this->expectException(ProductNotFoundException::class);

        Models\Product::factory()->create([
            'id' => 2,
        ]);
        Models\Product::find(2)->delete();

        $input = Input::fromArray(['product_id' => 2]);
        $interactor = resolve(Interactor::class);
        $interactor->execute($input);
    }

    public function setUp(): void
    {
        parent::setUp();
        Artisan::call('migrate:refresh');
    }

    public function tearDown(): void
    {
        Artisan::call('migrate:refresh');
        \DB::connection()->disconnect();
        parent::tearDown();
    }
}

以上、かる〜くユニットテストのご紹介でした。

クリーンアーキテクチャで良かったこと

今回、初めてクリーンアーキテクチャを取り入れた開発に携わりましたが、 そのなかで、これはよかった!と思う点がいくつかあったため、 そちらについて書かせていただきたいと思います。

何故かここにあってはいけないはずの実装がある問題

まず、個人的な一番の嬉しさは、モジュールごとの役割がはっきりしているため、 「なぜこの実装がこんなところに書いてあるんだろう・・・」といったようなコードが、 比較的少ないといった点に感じています。

私自身まだ業務経験が浅いため、API以外のサービスのコードに触れる機会自体少ないのですが、 以前すこし、API以外のソースコードを触っていた際に、この実装はここで良いんだろうか? といったような実装を見ることがしばしばありました。

ですが、クリーンアーキテクチャを取り入れているAPIでは、 各モジュールでの役割がはっきりしているため、 多少アーキテクチャの知識がなくても、他の実装を真似していくことで、 最低限、ビジネスロジックはDomain内に書いてるよね、とか Entityから直接Model呼んだりしないよね、とか そういった基本的なレイヤーごとの分離が守られることになり、 比較的「このロジックがなんでこんなところに!?」とビックリするようなことは少ないのかなと思っています。

また、なにがどこに書いてあるかが明確なため、 バグ発生時の確認なんかもスピーディーに行えるのではないかと感じています。

開発者がレベルアップする

私のような若手のエンジニアだと特にそうかもしれませんが、 クリーンアーキテクチャを取り入れている開発に参画すると、 最初はわけも分からず、なぜかめちゃくちゃ複雑なコードがあるようにしか見えないのですが、 長い期間をかけてレビューを頂いたり、自身でも実装を進めていく中で、 徐々に全体のアーキテクチャを理解できるようになってきたと感じています。

これは本来意図しない、副次的な効果かもしれませんが、 開発者に、ある意味でクリーンアーキテクチャに沿った実装を強制させることで、 時間はかかるものの徐々にクリーンアーキテクチャへの理解が進み、 最終的には、開発者本人の設計力が上がっていくといった側面もあるのではないかと感じています。

クリーンアーキテクチャを取り入れる上での課題

上記のようなクリーンアーキテクチャの恩恵を受けつつも、 逆に、クリーンアーキテクチャを取り入れる中でいくつか課題も感じており、 今度は逆に、私自身が感じている今感じている課題感についても書かせていただきたいと思います。

開発メンバーの入れ替え問題

開発メンバーの入れ替えはどこでも難しい問題ではあると思うのですが、 難しいアーキテクチャを利用している場合には、 例えシステムの仕様を理解したとて、全体のアーキテクチャが理解できていないことで、 実装の速度が落ちたり、品質低下に繋がる恐れがあります。

これは中々難しい問題で、今後API開発チームのメンバーが入れ替わった場合にも、 開発効率や品質を落とさずに開発を行っていくということが課題になってくるのではないかと思っています。

この対策としては、まず、クリーンアーキテクチャを導入するに至った背景や、 クリーンアーキテクチャをアプリケーションにどう落とし込んでいるかについてのドキュメンテーションを行ったり、 開発メンバーの入れ替えを行う際には、ある程度伴奏期間としてペアプロを行い、 徐々にフェードアウトしていくといったような動きが必要になるかなと考えています。 (クリーンアーキテクチャに造形が深い開発メンバーだけをアサインするというのも中々現実的ではないので)

開発工数について

こちら、クリーンアーキテクチャを取り入れる際にしばしば上がる話題ですが、 個人的には、一旦スパゲッティコードが出来上がってしまうと、 最終的に、当初かかるはずのなかった莫大な工数が発生することになるので、 はじめから、適切なアーキテクチャにそって実装をしたほうが、 むしろ最終的には、工数を最適化できて良いのではないかと考えています。(がケースバイケースだとは思います)

ただ、今後拡張の予定がなかったり、そもそもドメインを分けるほどのサイズでないアプリケーションについては、 クリーンアーキテクチャを使用すると逆に開発工数が見合わなくなってしまう可能性があるため、 クリーンアーキテクチャを導入する際には、アプリケーション毎に向き不向きを考える必要があるかなと思っています。

インフラについて

本記事では、インフラのご紹介については割愛させていただきますが、 先日弊社SREが登壇していた際の資料がございますので参考までにこちらをご覧ください。

DevOps実装初期フェーズの組織がTerraformとecspressoで求めるAmazon ECS CICDの最適解/AWS ECS CICD with Terraform and ecspresso

さいごに

1年ぶり2度目のAdventCalenderとなりましたが、 昨年は実務経験も浅く、中々書くことがまとまらず技術に関連する話があまり書けなかったため、 今年は、アーキテクチャ周りの話が書けてホッとしています。

なお、今後も業務を通じて学んだ知見や個人的に学んだアウトプットを、 テックブログへ投稿していきたいと思いますので、よろしくお願いいたします!


  1. APIチームのテックリードがクリーンアーキテクチャでよく使用するコンポーネント公開しています