Shin x Blog

PHPをメインにWebシステムを開発してます。Webシステム開発チームの技術サポートも行っています。

独立したコアレイヤパターン

独立したコアレイヤは、アプリケーション実装パターンである。以下のような特徴を持つ。

  • アプリケーションを、何を実現するのか(What)と、どのように実現するのか(How)に分ける。
  • What は、コアレイヤに実装する。ユースケースやドメインロジックを実装する。フレームワークやライブラリには依存しない。UI やデータベースからは独立している。
  • How は、サービスレイヤ(仮)に実装する。フレームワークやライブラリを活用して、ユースケースが要求する技術詳細を実装する。
  • コアレイヤが必要な技術詳細はポート(インタフェース)で定義し、これに依存する。サービスレイヤでは、それに対するアダプタを実装する。

ここでは、Web アプリケーションを念頭においているが、内容は Web アプリケーションに特化するものではなく、他種のアプリケーションでも適用可能と考える。

モチベーション

  • 技術詳細からの独立

コアレイヤでは、ユースケースやドメインを記述するが、UI、フレームワークやサードパーティライブラリ、外部ミドルウェア通信等の詳細技術とは独立しているので、処理の流れやドメインロジックといった何を実現するのかを平易に記述していくだけで良い。こうしたコードは、後に他の開発者がコードを見た時にもアプリケーションが実現したいことが把握しやすい。

  • フレームワーク、サードパーティライブラリからの独立

コアレイヤは、フレームワークやサードパーティライブラリには依存しない。こうした機能は、ポート経由で間接的に利用する。あくまでコアレイヤは、ポートに依存しているだけでその詳細には関与しない。こうしておくことで、フレームワークやライブラリのバージョンアップや入れ替えが発生しても、その影響はサービスレイヤで実装するアダプタに留まり、コアレイヤには及ばない。

  • サービスレイヤからの独立

コアレイヤは、サービスレイヤにも依存しないので、単体のアプリケーションとして利用することも可能である。つまり、テストランナーを使えば、単体でテストを実行できる。また、異なるサービスレイヤ、例えば Web アプリケーションとバッチアプリケーションがあっても、同じコアレイヤを使うことができる。

  • コアレイヤからの独立

サービスレイヤについても、コアレイヤから独立していることで、要求される技術に特化した処理を実装すれば良くなる。HTTP リクエストのハンドリング、レスポンス生成、データベースアクセス、メール送信といったものだ。こうしたものは、汎用フレームワークやライブラリで提供されているので、それらをアダプタとして、コアレイヤに提供すれば良い。

  • シンプルなルール

本パターンで定めているのは、レイヤをコアレイヤとサービスレイヤの 2 つに分ける、コアレイヤは外部の技術詳細に依存せずに自身で定義したポートに依存する、だけである。

このシンプルなルールにより、小さなアプリケーションにも適用しやすいものとなっている。

全体

下記に独立したコアレイヤパターンのイメージ図を示す。

f:id:shin1x1:20180510235928p:plain

この図を見れば、ヘキサゴナルアーキテクチャやオニオンアーキテクチャ、クリーンアーキテクチャを想起するだろう。本パターンは、ヘキサゴナルアーキテクチャ( Ports and Adapters パターン)の一種である。コアレイヤを中心におき、外部にある実装はインタフェース経由で利用する。

前述したアーキテクチャと異なるのは、レイヤ構造を 2 つにしている点とポートの種類や役割を限定していないことである。例えば、ヘキサゴナルアーキテクチャでは、ポートをプライマリとセカンダリに区分している。細かな違いではあるが、定義を明確にするために新たな名称を付けた。

コアレイヤ

コアレイヤは、ユースケースとユースケースが利用するサービスのポート(インタフェース)を実装する。ユースケースは、想定される処理を順に記述していく。データベースアクセスなど技術詳細を利用したい場合、必要な API をインタフェースとして実装して、それに依存しておく。ユースケース内では、このインタフェースを利用して値の取得や保存などの処理を行う。

アプリケーションドメインに関する実装、エンティティや ValueObject、ドメインサービスなど、もコアレイヤに含める。ただ、これは必須ではなく、シンプルなユースであればこうしたドメインモデルを使わずにユースケースを実装する場合もある。

このレイヤに技術詳細は含まないので、Web アプリケーションフレームワークやサードパーティライブラリなどには依存しない。ただし、データやアルゴリズムなどを提供するライブラリについては依存する場合がある。例えば、PHP であれば、日付時間ライブラリである Carbon や Chronos 、データ構造ライブラリの php-ds などの依存は考えられる。

以下に一つの例を示す。ここでは、銀行口座の情報を取得するユースケースを実装している。

なお、本エントリ全体で例示するソースコードは、シンプルな銀行口座アプリケーションのものである。また、AccountNumber や Account などはドメインモデルとして、このレイヤに含まれているとする。

<?php
declare(strict_types=1);

namespace Acme\Account\UseCase\GetAccount;

use Acme\Account\Domain\Exceptions\NotFoundException;
use Acme\Account\Domain\Models\Account;
use Acme\Account\Domain\Models\AccountNumber;

final class GetAccount
{
    private $query;

    public function __construct(GetAccountQueryPort $query)
    {
        $this->query = $query;
    }

    public function execute(AccountNumber $accountNumber): Account
    {
        return $this->query->findAccount($accountNumber);
    }
}

interface GetAccountQueryPort
{
    public function findAccount(AccountNumber $accountNumber): Account;
}

GetAccount クラスがユースケース、GetAccountQueryPort インタフェースがこのユースケースが利用するポートである。ユースケースでは、コンストラクタにポートを引数で受け取る。execute メソッドにユースケースを実装する。このメソッドでは、単に GetAccountQueryPort インタフェースの findAccount メソッドを実行してその戻り値を返すだけである。

execute メソッドを見れば、このユースケースが何をするかは一目瞭然である。たが、実際に口座情報がどこにあり、それをどのように行うかはコアレイヤは知らない。

ポートには、ユースケースごとに定義する。読み取り処理を担う Query ポート、データ保存やメール送信など副作用を伴う処理を担う Command ポート、アプリケーション全体で横断的に利用する Transaction ポートなど考えられる。ただ、これは本パターンで規定しておらず、あくまで一例である。

ユースケースはフレームワークやサードパーティライブラリには依存せず、データベースなどにも直接接続しないので、テスト用データベースのセットアップなどなくても簡単にテストが書ける。以下が GetAccount ユースケースのテストである。

<?php
declare(strict_types=1);

namespace Acme\Test\Account\UseCase;

use Acme\Account\Domain\Exceptions\NotFoundException;
use Acme\Account\Domain\Models\Account;
use Acme\Account\Domain\Models\AccountNumber;
use Acme\Account\UseCase\GetAccount\GetAccount;
use Acme\Account\UseCase\GetAccount\GetAccountQueryPort;
use PHPUnit\Framework\TestCase;

final class GetAccountTest extends TestCase
{
    /**
     * @test
     * @throws NotFoundException
     */
    public function execute()
    {
        $sut = new GetAccount(
            new class implements GetAccountQueryPort
            {
                public function findAccount(AccountNumber $accountNumber): Account
                {
                    return Account::ofByArray([
                        'account_number' => $accountNumber,
                        'email' => 'a@example.com',
                        'balance' => 1000,
                    ]);
                }
            }
        );

        $accountNumber = 'A0001';
        $actual = $sut->execute(AccountNumber::of($accountNumber));

        $this->assertSame($accountNumber, $actual->accountNumber()->asString());
        $this->assertSame(1000, $actual->balance()->asInt());
    }

    /**
     * @test
     * @expectedException \Acme\Account\Domain\Exceptions\NotFoundException
     */
    public function error_account_not_found()
    {
        $sut = new GetAccount(
            new class implements GetAccountQueryPort
            {
                public function findAccount(AccountNumber $accountNumber): Account
                {
                    throw new NotFoundException();
                }
            }
        );

        $sut->execute(AccountNumber::of('Z9999'));
    }
}

GetAccountQueryPort をインジェクトするだけでユースケースは実行できるので、無名クラスでこのインタフェースを実装して与えている。GetAccountTest クラスの execute メソッドでは正常ケースとして、Account インスタンスを返している。一方、error_account_not_found メソッドでは、口座情報が見つからないケースとして例外を送出している。このようにユースケースではインタフェースに依存しているだけなので、インジェクトするクラスの実装をいかようにも変えることができる。

このユースケースは、あまりにシンプルでイメージが沸かないということであれば、後述する口座間送金ユースケースの例を見ると良い。

サービスレイヤ

サービスレイヤは、UI や データベースなどアプリケーション外部との連携を実装する。

このレイヤでは、2つの責務を担う。1 つ目は、UI からのアクションを契機にユースケースを実行することである。コアレイヤから提供されるユースケースクラスを実行することになる。2 つ目は、実行するユースケースが依存しているポートに対するアダプタを実装することだ。インタフェースを満たせば、その実装の詳細は自由である。

先の GetAccount ユースケースに対する本レイヤの実装を見ていこう。ここでは、Laravel 5.6 を利用しているものとする。

まず、GetAccount ユースケースが要求している GetAccountQueryPort に対するアダプタ実装が以下となる。

<?php
declare(strict_types=1);

namespace App\Action\GetAccount;

use Acme\Account\Domain\Exceptions\NotFoundException;
use Acme\Account\Domain\Models\Account;
use Acme\Account\Domain\Models\AccountNumber;
use Acme\Account\UseCase\GetAccount\GetAccountQueryPort;
use App\Eloquents\EloquentAccount;

final class GetAccountAdapter implements GetAccountQueryPort
{
    private $account;

    public function __construct(EloquentAccount $account)
    {
        $this->account = $account;
    }

    public function findAccount(AccountNumber $accountNumber): Account
    {
        $account = $this->account->findByAccountNumber($accountNumber);
        if (is_null($account)) {
            throw new NotFoundException(sprintf('account_number %s not found', $accountNumber->__toString()));
        }

        return $account->toModel();
    }
}

GetAccountAdapter クラスでは、GetAccountQueryPort を実装している。内部では、Eloquent を利用して、データベースから口座情報を取得し、Account インスタンスとして返している。ここでは、コンストラクタで EloquentAccount をインジェクトしているが、ファサードを使っても問題無い。サービスレイヤでは、フレームワークの機能を利用して、より開発しやすい方法で実装すれば良い。

Laravel にはサービスコンテナという DI コンテナがあるので、GetAccount クラス生成時にこのアダプタを与えるように定義しておく。ここでは、CoreServiceProvider を追加して、その中で DI コンテナに定義を追加している。

<?php
declare(strict_types=1);

namespace App\Providers;

use Acme\Account\UseCase\GetAccount\GetAccount;
use App\Action\GetAccount\GetAccountAdapter;

final class CoreServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        //
    }

    public function register(): void
    {
        $this->app->bind(GetAccount::class, function () {
            $adapter = app(GetAccountAdapter::class);

            return new GetAccount($adapter);
        });
    }
}

Laravel のルーティングからユースケースを実行する GetAccountAction は以下になる。

<?php
declare(strict_types=1);

namespace App\Action\GetAccount;

use Acme\Account\Domain\Models\AccountNumber;
use Acme\Account\UseCase\GetAccount\GetAccount;
use Illuminate\Http\Request;

final class GetAccountAction
{
    private $useCase;

    public function __construct(GetAccount $useCase)
    {
        $this->useCase = $useCase;
    }

    public function __invoke(Request $request, string $accountNumber)
    {
        $account = $this->useCase->execute(AccountNumber::of($accountNumber));

        return response()->json([
            'account_number' => $account->accountNumber()->asString(),
            'balance' => $account->balance()->asInt(),
        ]);
    }
}

コンストラクタで、先程 CoreServiceProvider でサービスコンテナに登録した GetAccount インスタンスが与えられる。ルーティングからは、__invoke メソッドが実行される。この中でパラメータのバリデーションなどを行って、ユースーケースを実行する。HTTP に関することは、このクラスで処理して、ユースーケースには渡さないようにする。ユースーケースからは、Account インスタンスが返されるので、これを JSON で出力している。

このように、アプリケーション外部やフレームワークなどに関する処理は全てこのレイヤで行う。

口座間送金ユースケース

より実際のアプリケーションに近いかたちとして、口座間の送金処理ユースケースの実装例を見る。

処理の流れ

  • 送金元口座番号、送金先口座番号、送金金額を指定する。
  • 送金元口座番号から送金元口座情報を取得する。
  • 送金先口座番号から送金先口座情報を取得する。
  • 送金元口座から送金金額を出金する。
  • 送金先口座に送金金額を入金する。
  • 送金元口座の取引ログに出金を記録する。
  • 送金先口座の取引ログに入金を記録する。
  • 送金元口座残高を保存する。
  • 送金先口座残高を保存する。

コアレイヤ

ユースケース TransferMoney クラスは以下のようになる。

まず、3 つのポートに依存している。TransferMoneyQueryPort はデータ取得のような読み取り処理を担うポート、TransferMoneyCommandPort はデータ保存やメール送信のような副作用を伴う処理を担うポートTransactionPort は、データベーストランザクションを担う汎用ポートである。

execte メソッドでは、送信元口座番号、送信先口座番号、送金金額、そして取引ログを記録するために処理日時を引数に取る。これは、サービスレイヤから与えられる。

<?php
// (snip)
final class TransferMoney
{
    private $query;
    private $command;
    private $transaction;

    public function __construct(
        TransferMoneyQueryPort $query,
        TransferMoneyCommandPort $command,
        TransactionPort $transaction
    ) {
        $this->query = $query;
        $this->command = $command;
        $this->transaction = $transaction;
    }

    public function execute(
        AccountNumber $sourceNumber,
        AccountNumber $destinationNumber,
        Money $amount
    ): Balance {
        return $this->transaction->transaction(function () use ($sourceNumber, $destinationNumber, $amount) {
            [$source, $destination] = $this->query($sourceNumber, $destinationNumber);

            if ($source->accountNumber()->equals($destination->accountNumber())) {
                throw new DomainRuleException('source can not transfer to same account');
            }

            if ($source->balance()->lessThan($amount)) {
                $message = sprintf('source account does not have enough balance for transfer %s', $amount->asInt());
                throw new DomainRuleException($message);
            }

            $source->withdraw($amount);
            $destination->deposit($amount);

            $this->store($source, $destination, $amount, $now);

            $this->command->notify($source);

            return $this->query->findAccount($sourceNumber)->balance();
        });
    }
}

(TBD)

サンプルアプリケーション

github.com

参考