PHP
laravel
DDD
リポジトリパターン
ファクトリ

PHPでDDD実装事例その②リポジトリ&ファクトリで永続化・生成処理をカプセル化(Laravel)(図あり)

背景

以前書いたこちらの記事(PHPでDDD実装事例)でPHP・Laravelで個人で運営しているハウススタジオの予約受付業務の自動化システム(一部)をDDDっぽく実装しました。
多分こちらの記事も併せて読んで頂くとよりわかりやすいと思います。

今回はこちらのLaravelとEloquentの永続化パターンのサンプルを参考にエンティティの生成と永続化の部分をファクトリとリポジトリにカプセル化する処理を自分のプロジェクトに適用してみました。
こちらのスライド(Laravelとドメインモデルと永続化モデル)も参考にしました。

上記のサンプルでは、以下の4パターンが実装されているのですが、私は3番のPOPOのEntityとEloquent Modelを参考にしました。
1 Eloquent ModelをEntityとする
2 EntityがEloquent Modelを中に持つ
3 POPOのEntityとEloquent Model
4 POPOのEntityとQuery Builder

サンプルの理解

参考にするにしてもまずはサンプルを読んで理解しなければなりません。
ただ、複数クラスにまたがっているため読んだだけだと全体像がいまいち掴めなかったため、処理の流れを図にしてみました。

Qiita用にCacooで作成しましたが、手書きで整理するだけでもかなり理解が進みました。

<リクエストされたTodoを保存する処理の流れ>
(コントローラーで言うとaddメソッド)
Laravel永続化モデル.png

簡単に流れを説明すると、
リクエストを受け取ったコントローラーがリクエストを配列に変換してAddTodoItem(サービスクラス)のメソッドに渡します。
AddTodoItemクラスではリポジトリを使って、リクエストされた情報からエンティティを生成→エンティティを永続化→エンティティを再構成→コントローラーに返却します。
ここでエンティティの生成・再構成の部分はファクトリクラスにカプセル化しています。また、永続化の際はEloquentモデルを使っていますが、AddTodoItemクラスはInterfaceに依存しているため、Repositoryの実装を入れ替えることでクエリビルダ等他の永続化方法に容易に変えることが出来ます。
コントローラーまで返されたエンティティはResponderクラスを通してJson形式に変換されクライアントに返される、といった流れになっていました。

プロジェクトへの適用

今回は上記の中で、エンティティ生成・永続化部分を参考にコードを書いていきました。

コードはGithubにまとまっています。

サービスクラス

サンプルのAddTodoItemにあたるサービスクラスです。
コントローラで編集されたリクエストデータを受け取りリポジトリを使って永続化等の処理を行います。
ここでは、リクエストのデータを使って、Customerクラス(予約申し込みしてきた顧客)とReservationクラス(予約内容)のエンティティ作成->永続化->最後に自動メール返信を行っています。

コンストラクタインジェクションでInterfaceを注入していますが、Laravelのサービスプロバイダで実装に結合しています。

namespace Asobiba\Application\Service;

use Asobiba\Domain\Models\Notification\ReservationNotificationInterface;
use Asobiba\Domain\Models\Repositories\Reservation\CustomerRepositoryInterface;
use Asobiba\Domain\Models\Repositories\Reservation\ReservationRepositoryInterface;
use Illuminate\Http\Request;

class AcceptanceReservationService
{

    private $customerRepo;
    private $reservationRepo;
    private $notification;

    //Interfaceを依存注入(サービスプロバイダ内で実装に結合している)
    public function __construct
    (
        CustomerRepositoryInterface $customerRepo,
        ReservationRepositoryInterface $reservationRepo,
        ReservationNotificationInterface $notification
    )
    {
        $this->customerRepo = $customerRepo;
        $this->reservationRepo = $reservationRepo;
        $this->notification = $notification;
    }

    //カスタマーからのリクエストを受け取ってDBに保存 + 自動返信メール送信
    //リクエストクラスには出来るだけ依存させたく無いのでコントローラーで配列などに変換予定
    public function reserve(Request $req)
    {
        //Customerエンティティ生成
        $customerId = $this->customerRepo->nextIdentity();
        $customer = $this->customerRepo->new($customerId, $req);

        //Reservationエンティティ生成
        $reservationId = $this->reservationRepo->nextIdentity();
        $reservation = $this->reservationRepo->new($reservationId, $req);

        //Customer永続化
        $this->customerRepo->persist($customer);
        //Reservation永続化
        $this->reservationRepo->persist($customerId,$reservation);

        //自動メール送信
        $this->notification->notifyToCustomer($customer,$reservation);
        $this->notification->notifyToManager($customer,$reservation);
    }

}

サービスプロバイダ

Laravelのサービスプロバイダで先ほどのInterfaceと実装を結合します。

public function register()
    {
        $this->app->bind(
            ReservationRepositoryInterface::class,
            EloquentReservationRepository::class
        );
        $this->app->bind(
            CustomerRepositoryInterface::class,
            EloquentCustomerRepository::class
        );
        $this->app->bind(
          ReservationNotificationInterface::class,
          MailReservationNotification::class
        );
        //サービスクラスの結合
        $this->app->bind(AcceptanceReservationService::class,function(){
                return new AcceptanceReservationService(
                    $this->app->make(CustomerRepositoryInterface::class),
                    $this->app->make(ReservationRepositoryInterface::class),
                    $this->app->make(ReservationNotificationInterface::class)
                );
            }
        );
    }

リポジトリ

サービスクラスから使われるリポジトリです。
中でファクトリを使ったエンティティの生成と永続化を行っています。
Eloquentモデルを通して永続化するため、EloquentCustomerRepositoryEloquentReservationRepositoryという名前にしています。
(一意な識別子の生成方法は実践ドメイン駆動設計という書籍を参考にしました。)

EloquentCustomerRepository

//packages/Asobiba/Infrastructure/Repositories/EloquentCustomerRepository.php

namespace Asobiba\Infrastructure\Repositories;

use App\Eloquents\Reservation\EloquentReservation;
use App\Eloquents\Reservation\EloquentOption;
use App\Eloquents\User\EloquentCustomer;
use Asobiba\Domain\Models\Factory\CustomerFactory;
use Asobiba\Domain\Models\Repositories\Reservation\CustomerRepositoryInterface;
use Asobiba\Domain\Models\Reservation\Reservation;
use Asobiba\Domain\Models\User\Customer;
use Asobiba\Domain\Models\User\CustomerId;
use DB;
use Illuminate\Http\Request;

class EloquentCustomerRepository implements CustomerRepositoryInterface
{

    //ファクトリを格納するプロパティ
    private $factory;
    //nextIdentitiyメソッドで使う一意な識別子を格納しているテーブル名
    private $sequence_table_name = 'customer_seqs';

    public function __construct(CustomerFactory $factory)
    {
        $this->factory = $factory;
    }
    //一意な識別子をDBで生成
    public function nextIdentity(): CustomerId
    {
        DB::table($this->sequence_table_name)->update(["nextval" => DB::raw("LAST_INSERT_ID(nextval + 1)")]);
        $customerId = DB::table($this->sequence_table_name)->selectRaw("LAST_INSERT_ID() as id")->first()->id;

        return new CustomerId($customerId);
    }

    //ファクトリを使ってエンティティを生成
    public function new(CustomerId $customerId,Request $req)
    {
        return $this->factory->createFromRequest($customerId,$req);
    }

    //Eloquentモデルを通してエンティティを永続化
    public function persist(Customer $customer)
    {
        DB::beginTransaction();
        try {
            //Customerの永続化
            $eloquentCustomer = new EloquentCustomer();
            $eloquentCustomer->id = $customer->getId();
            $eloquentCustomer->name = $customer->getName();
            $eloquentCustomer->email = $customer->getEmail();
            $eloquentCustomer->save();

            DB::commit();

        } catch (\Exception $e) {

            DB::rollback();
            dd($e->getMessage());
        }
    }


}

EloquentReservationRepository

//packages/Asobiba/Infrastructure/Repositories/EloquentReservationRepository.php
namespace Asobiba\Infrastructure\Repositories;

use App\Eloquents\Reservation\EloquentReservation;
use App\Eloquents\Reservation\EloquentOption;
use App\Eloquents\User\EloquentCustomer;
use Asobiba\Domain\Models\Factory\ReservationFactory;
use Asobiba\Domain\Models\Repositories\Reservation\ReservationRepositoryInterface;
use Asobiba\Domain\Models\Reservation\Reservation;
use Asobiba\Domain\Models\Reservation\ReservationId;
use Asobiba\Domain\Models\User\Customer;
use Asobiba\Domain\Models\User\CustomerId;
use DB;
use Illuminate\Http\Request;


class EloquentReservationRepository implements ReservationRepositoryInterface
{
    private $factory;
    private $sequence_table_name = 'reservation_seqs';


    public function __construct(ReservationFactory $factory)
    {
        $this->factory = $factory;
    }


    public function nextIdentity(): ReservationId
    {
        DB::table($this->sequence_table_name)->update(["nextval" => DB::raw("LAST_INSERT_ID(nextval + 1)")]);
        $reservationId = DB::table($this->sequence_table_name)->selectRaw("LAST_INSERT_ID() as id")->first()->id;

        return new ReservationId($reservationId);
    }

    public function new(ReservationId $reservationId, Request $req): Reservation
    {
        return $this->factory->createFromRequest($reservationId, $req);
    }

    public function persist(CustomerId $customerId, Reservation $reservation)
    {
        DB::beginTransaction();
        try {
            //Reservationの永続化
            $eloquentReservation = new EloquentReservation();
            $eloquentReservation->id = $reservation->getId();
            $eloquentReservation->customer_id = $customerId->getId();
            $eloquentReservation->plan = $reservation->getPlanName();
            $eloquentReservation->price = $reservation->getPriceOfPlan();
            $eloquentReservation->number = $reservation->getNumber();
            $eloquentReservation->date = $reservation->getDate();
            $eloquentReservation->start_time = $reservation->getStartTime();
            $eloquentReservation->end_time = $reservation->getEndTime();
            $eloquentReservation->question = $reservation->getQuestion();
            $eloquentReservation->status = $reservation->getStatus();
            $eloquentReservation->save();

            //Reservationと関連するオプションの永続化
            if ($reservation->getOptionAndPriceSet()) {
                foreach ($reservation->getOptionAndPriceSet() as $optionName => $price) {
                    $option = new EloquentOption();
                    $option->reservation_id = $reservation->getId();
                    $option->option = $optionName;
                    $option->price = $price;
                    $option->save();
                }
            }

            DB::commit();

        } catch (\Exception $e) {

            DB::rollback();
            dd($e->getMessage());

        }

    }

}

ファクトリ

エンティティや関連する値オブジェクト等の生成は全てファクトリ内で行うようにしています。

//packeges/Asobiba/Domain/Models/Factory/CustomerFactory.php
namespace Asobiba\Domain\Models\Factory;

use Asobiba\Domain\Models\User\Customer;
use Asobiba\Domain\Models\User\CustomerEmail;
use Asobiba\Domain\Models\User\CustomerId;
use Asobiba\Domain\Models\User\CustomerName;
use Illuminate\Http\Request;

class CustomerFactory
{
    public function createFromRequest(CustomerId $customerId,Request $req):Customer
    {
        //Customerエンティティを生成
        return new Customer(
            $customerId,
            new CustomerName($req->name),
            new CustomerEmail($req->email)
        );
    }
}

//packeges/Asobiba/Domain/Models/Factory/RservationFactory.php

namespace Asobiba\Domain\Models\Factory;

use Asobiba\Domain\Models\Reservation\Capacity;
use Asobiba\Domain\Models\Reservation\DateOfUse;
use Asobiba\Domain\Models\Reservation\Number;
use Asobiba\Domain\Models\Reservation\Options;
use Asobiba\Domain\Models\Reservation\Plan;
use Asobiba\Domain\Models\Reservation\Purpose;
use Asobiba\Domain\Models\Reservation\Question;
use Asobiba\Domain\Models\Reservation\Reservation;
use Asobiba\Domain\Models\Reservation\ReservationId;
use Illuminate\Http\Request;

class ReservationFactory
{

    public function createFromRequest(ReservationId $reservationId, Request $req):Reservation
    {
        return new Reservation(
            $reservationId,
            $plan = new Plan($req->plan),
            $options = new Options($req->options, $plan, $req->end_time),
            new DateOfUse($req->date, $req->start_time, $req->end_time, $plan, $options),
            $capacity = new Capacity($plan, $options),
            new Number($req->number, $capacity),
            new Purpose($req->purpose),
            new Question($req->question)
        );
    }
}

エンティティを生成したくなったらこのファクトリを使うことでnewが色んなところに散らばることが無くなりますし、生成方法に変更が発生した場合もこのクラスだけ修正すればOKになりました。

エンティティ

前回の記事ではRservationエンティティを生成する際に引数にはint型やString型を設定していましたが、今回ファクトリクラスを作るにあたって、引数が独自型になるように変更しました。
その方が引数が分かりやすくなりの間違いの防止に繋がります。

//packages/Asobiba/Domain/Models/Reservation/Reservation.php

class Reservation
{
    /** @var  ReservationId */
    private $id;

    /** @var Options */
    private $options;

    /** @var Plan */
    private $plan;

    /** @var Capacity */
    private $capacity;

    /** @var Number */
    private $number;

    /** @var DateOfUse */
    private $dateOfUse;

    /** @var Purpose  */
    private $purpose;

    /** @var Question */
    private $question;

    /** @var Status */
    private $status;

    /**
     * Reservation constructor.
     * @param ReservationId $id
     * @param Plan $plan
     * @param Options $options
     * @param DateOfUse $dateOfUse
     * @param Number $number
     * @param Purpose $purpose
     * @param Question $question
     */
    //引数を独自型に変更
    public function __construct(
        ReservationId $id,
        Plan $plan,
        Options $options,
        DateOfUse $dateOfUse,
        Capacity $capacity,
        Number $number,
        Purpose $purpose,
        Question $question
    )
    {
        $this->id = $id;
        $this->plan = $plan;
        $this->options = $options;
        $this->dateOfUse = $dateOfUse;
        $this->number = $number;
        $this->capacity = $capacity;
        $this->purpose = $purpose;
        $this->question = $question;
        if ($this->hasQuestion()) {
            $this->status = new Status('Contact');
        } else {
            $this->status = new Status('Confirmation');
        }
    }

    /**
     * @return ReservationId
     */
    public function getId(): int
    {
        return $this->id->getId();
    }

    /**
     * get total price of this reservation
     *
     * @return int
     */
    public function getTotalPrice(): int
    {
        return $this->options->getTotalPrice() + $this->plan->getPrice();
    }


    /**
     * get price of plan of this reservation
     *
     * @return int
     */
    public function getPriceOfPlan(): int
    {
        return $this->plan->getPrice();
    }


    /**
     * get options and price set of this reservation.
     *
     * @return array
     */
    public function getOptionAndPriceSet(): array
    {
        return $this->options->getOptionAndPriceSet();
    }

    /**
     * get plan name.
     *
     * @return string
     */
    public function getPlanName(): string
    {
        return $this->plan->getPlan();
    }

    /**
     * get capacity of guests.
     *
     * @return int
     */
    public function getCapacity(): int
    {
        return $this->capacity->getCapacity();
    }
    /**
     * get number of guests.
     *
     * @return int
     */
    public function getNumber(): int
    {
        return $this->number->getNumber();
    }

    /**
     * get question of this reservation
     *
     * @return string
     */
    public function getQuestion(): string
    {
        return $this->question->getQuestion();
    }

    /**
     * @return string
     */
    public function getStatus(): string
    {
        return (string)$this->status;//__toStringメソッドが定義されているため
    }


    /**
     * change status
     * @param string $status
     */
    public function changeStatus(string $status)
    {
        $method = 'to' . $status;
        $this->status = $this->status->$method();
    }

    /**
     * get Date
     *
     * @return string
     */
    public function getDate(): string
    {
        return $this->dateOfUse->getDate();
    }

    /**
     * get StartTime
     *
     * @return int
     */
    public function getStartTime(): int
    {
        return $this->dateOfUse->getStartTime();
    }

    /**
     * get EndTime
     *
     * @return int
     */
    public function getEndTime(): int
    {
        return $this->dateOfUse->getEndTime();
    }


    /**
     * check if this reservation has question
     *
     * @return bool
     */
    public function hasQuestion(): bool
    {
        return $this->question->isQuestion();
    }


}

また、新たにCustomerクラスを作りました。

//packages/Asobiba/Domain/Models/User/Customer.php
class Customer
{
    private $id;

    private $name;

    private $email;

    public function __construct(CustomerId $id,CustomerName $name,CustomerEmail $email)
    {
        $this->id = $id;
        $this->name = $name;
        $this->email = $email;  
    }

    public function getId(): int
    {
        return $this->id->getId();
    }

    public function getName():string
    {
        return $this->name->getName();
    }

    public function getEmail():string
    {
        return $this->email->getEmail();
    }
}

EloquentModel

永続化の手段としてDBを使うため、LaravelのEloquentを採用しました。
Eloquentはリポジトリのみから扱うようにして、ドメインモデルやサービスクラスが直接Eloquentに依存することが無いようにしました。
それぞれリレーションだけ定義しています。

class EloquentCustomer extends Model
{
    protected $table = 'customers';

    public function reservation()
    {
        return $this->hasOne(EloquentReservation::class,'customer_id');
    }
}
class EloquentReservation extends Model
{

    /**
     * モデルと関連しているテーブル
     *
     * @var string
     */
    protected $table = 'reservations';

    public function customer()
    {
        return $this->belongsTo(EloquentCustomer::class);
    }

    public function options()
    {
        return $this->hasMany(EloquentOption::class,'reservation_id');
    }

}

DB構造

念のため分かりやすようにDBの構造も載せておきます。

1.customersテーブル

Field Type Null Key Default Extra
id int(10) unsigned NO PRI NULL
name varchar(25) NO NULL
email varchar(100) NO NULL
created_at timestamp YES NULL
updated_at timestamp YES NULL

2.reservationsテーブル

Field Type Null Key Default Extra
id int(10) unsigned NO PRI NULL
customer_id int(10) unsigned NO MUL NULL
plan varchar(255) NO NULL
price int(11) NO NULL
number int(11) NO NULL
date varchar(255) NO NULL
start_time int(11) NO NULL
end_time int(11) NO NULL
question varchar(255) NO NULL
status varchar(255) NO NULL
created_at timestamp YES NULL
updated_at timestamp YES NULL

3.optionsテーブル

Field Type Null Key Default Extra
id int(10) unsigned NO PRI NULL auto_increment
reservation_id int(10) unsigned NO MUL NULL
option varchar(255) NO NULL
price int(11) NO NULL
created_at timestamp YES NULL
updated_at timestamp YES NULL

4.customer_seqs,reservation_seqsテーブル(一意な識別子を保存するテーブル)

Field Type Null Key Default Extra
nextval int(10) unsigned NO NULL

その他

今回は記載していませんが、予約受付後自動で顧客に返信する通知クラスを作っています。

あと、最初に記載したサービスクラスのテストをPHPUnitを使って行っているのでそのコードも記載しておきます。

class ServiceTest extends TestCase
{
    use RefreshDatabase;


    public function prepare()
    {
        //一意な識別子を生成するテーブルに初期値0を格納
        DB::table('reservation_seqs')->insert(["nextval" => 0]);
        DB::table('customer_seqs')->insert(["nextval" => 0]);
    }

    public function finish()
    {
        //テスト実行後にテーブルを初期化(何故かRefreshDatabaseが効かなかった..)
        DB::delete('delete from customers');
        DB::statement("alter table options auto_increment = 1");

    }

    /**
     * A basic test example.
     *
     * @return void
     */
    public function testReserve()
    {
        $this->prepare();

        //別ファイルでRequestを生成する処理を記述
        $request = makeCorrectRequest();

        //サービスコンテナを使ってサービスクラスの依存解決
        $service = $this->app->make(AcceptanceReservationService::class);
        //サービスクラスのreserveメソッド実行
        $service->reserve($request);

        //reservations,customers,optionsそれぞれのテーブルの中身をアサート
        $this->assertDatabaseHas('reservations', [
            'plan' => '【非商用】基本プラン(平日)',
            'id' => 1,
            'customer_id' => 1,
            'status' => 'Contact',
            'price' => 19500,
            'date' => '2017-11-26',
            'number' => 10
        ]);

        $this->assertDatabaseHas('customers', [
            'name' => 'テストユーザー',
            'email' => 'sansan106700@gmail.com'
        ]);

        $options = ['ゴミ処理' => 1500, 'カセットコンロ' => 1500, '宿泊(1〜3名様)' => 6000];
        foreach ($options as $option => $price) {
            $this->assertDatabaseHas('options', [
                'option' => $option,
                'price' => $price
            ]);
        }

        $this->finish();
    }

}

まとめ

今回リポジトリやファクトリ等を使ったり、通知専用のクラスを作ってみて、こういった明確な役割を持ったクラスを作ることで、共通の処理が一箇所に集中しやすそうだし、変更が入った時にどこを変更すべきか明確になるのがいいなぁと感じました。
あとは通知用のインターフェースを用意して、その実装としてメール通知クラスを作ったりしたので、例えば通知方法をメールからSNS通知に変える場合は、SNS通知クラスを作ってサービスコンテナのインターフェースの結合先を変更するだけでOKというのがいい感じだなぁと思いました。

あとは前回テストを作成しておいたので、リファクタリング時の安心感がハンパなかったです。

考え方やコードの書き方などご指摘あれば宜しくお願いします!