🏗️

【PHPでDDD】ドメイン駆動設計と上手く付き合う方法

8 min read

私は小規模な会社の代表を務めながら現場でコードも書くフルスタックエンジニアです。
今回の記事では、会社経営や現場の両方から見たDDDと上手く付き合う方法の一つを紹介したいと思います。
あくまでも弊社で決定している開発方針になりますので、「推奨する」方法ではありません。この記事は一例に過ぎませんが、皆さんの参考になればと思います。

DDDの導入を拒む企業が多い理由

DDDの導入に否定的な企業が多いと感じます。その理由をざっくり上げてみます。

  • 人材の教育が大変
  • 開発のオーバーヘッドが大きい
  • コストがかかる(インフラ、勉強、時間などのコスト)
  • 導入のメリットがイマイチわからない

ざっくりではございますが、こんな感じでしょうか。

DDDを商業プロジェクトに導入するまでの道のりは確かに長いです。また、データを軸に設計していた開発者に、ドメインを中心に設計する方法に慣れてもらう必要があり、手間がかかるのも確かです。

経営側からすると、開発のオーバーヘッドと導入コストが一番気になる部分でしょう。
これまでの設計と違い、ドメイン設計に数倍の時間を要します。また、書くコードの量も数倍になり、開発時間の増加は免れない。

ドメイン駆動設計はインフラに負担をかけるのも確かです。集約単位での更新で、変更のないレコードまで更新しなければならない。オブジェクトの使い回しも効かないので、各モジュールやレイヤーに応じてデータの再取得が発生します。
レイヤー間でデータのやり取りを行う際は、オブジェクトの詰め替えやフォーマット化などがあり、メモリの消費量やプログラムの実行時間が伸びます。

「ここまでして導入するメリットは本当にあるのか?」という結論に至るケースも多いかと思います。

導入のメリット

受託開発を行う企業であれば、お客様が望む目的に一致したプロダクトを以下に正確かつ素早く開発できるかがポイントになってきます。また、安全な運用手段を提供しながら、社内の運用コストを下げることも重要です。

1案件に割り当てられる人員や時間が限られています。その中であえてコストのかかる手法を選ぶには強い理由がなければなりません。

弊社では「製品の品質」、「安定運用」、「継続的インテグレーション」、「分業」をメリットとして感じています。ドメイン設計に時間を消費している分、自然とバグの起きにくい仕組みになります。バグが起きにくい仕組みであれば、安定した運用にも繋がります。
変更が一番多いビジネスロジックを分離させ整理させておくことで、継続的インテグレーションもしやすくなります。
レイヤー分けされていれば、社内の分業もしやすくなります。フロントエンド、アプリケーションバックエンド、ドメイン設計、インフラ管理者など。

弊社で導入に至った理由

この理由は結構シンプルです。ビジネスロジックが多すぎる案件を手掛けることになり、従来通りの設計方法で発生するリスクのコストが、DDDの導入コストを上回っていたことが主な理由でした。
ここで言うコストはお金ではなく、開発プロセスやその後の管理、お客様との信頼関係などを言います。

「この量のビジネスロジックをどう守り抜くか?」というところから始まり、開発手法そのものを見直すことから着手しました。その結果、ドメイン駆動設計の概念が一番マッチしているということで導入に至りました。

コストを踏まえた導入

今回の記事で一番伝えたい部分はここになります。
DDDの導入はあらゆる視点で見てもコストが数倍もかかる開発手法です。弊社でも結構苦労しました。
数人しか居ない会社でどのようにして導入したら良いのか、結構難しい問題です。

DDDで本来解決しようとしている問題

弊社にとってDDDを導入した目的は、「DDDという概念に従って開発すること」ではなく、「ドメインロジックを守ること」が第一の目的でした。

なので、DDDを実現する過程でコストのかかる部分を洗い出して、自社なりのルールを作ることで開発コストを下げることに視点を当てました。

開発コストを下げつつドメインのロジックを守る方法

ドメイン駆動設計という概念は未完成であると、この設計手法の著者でもあるEric Evans氏は述べています。ここから書かせて頂く内容は、良いところ取りして都合の悪い部分は最適化しようという試みですね。

プレゼンテーション層にドメインモデルを共有

「いきなり何を言い出すの?」って言われてしまいそうですが、まずは読んでみて下さい。
プレゼンテーション層はデータの入出力を行う層ですが、DDDの概念に従うとすればDTOを用意することが一般的のようです。

弊社ではLaravelを使って開発していますので、Laravelを例にします。
プレゼンテーション層に位置するControllerはリクエストを受け取り、アプリケーション層に合わせてデータ変換を行い、アプリケーション層に処理を依頼します。
この時にRequestインスタンスをユースケースに引き渡すわけにもいかないので、入力値をVOに変換しながらDTOにデータを詰め替えます。ここの作業は必要なものとして判断し、実装方法を変えることはありませんでした。

しかし、アプリケーション層で処理が完了して、プレゼンテーション層にデータを何らかのインスタンスにして結果を戻す時、プレゼンテーション層でさらにDTOインスタンスを作ります。

これは、ビュー(またはAPIレスポンス)にDTOを渡すことで、ドメインとの依存性を無くすという概念です。ドメインからビュー専用のDTOに変換しなければならないのは大きいな手間を伴います。

弊社ではドメインモデルのEntity, ValueObjectをビューに共有するという方法を取ることにしました。結局はドメインデータをDTOに詰め替えても、ドメインロジックに変更があればDTOも修正せざるを得ない。
それに合わせてビューも修正することにもなる。

その代わりに、ドメインの振る舞いが変わった場合には、影響範囲をビューまで遡って調べる必要はあるので、そのルールを追加しています。

一覧系のビューはQueryServiceで解決

複数のEntityやページングを必要とする一覧関係はQueryServiceという概念で取り入れることで解決しています。Repositoryはシンプルに保ち、複雑なクエリが必要な場面はQueryServiceで対応するという形ですね。

Entity単体で要件を満たせない場合は、複数のEntityやVOを組み合わせるDTOを利用しています。

UserQueryService.php
/**
 * 【インフラ層】
 * UserListQueryService, UserEloquent
 * 
 * 【ドメイン層】
 * UserListQueryServiceInterface, Pagination, UserSearchConditions, 
 * UserSortConditions, UserListQueryResults, UserListQueryResultItem
 */
final class UserListQueryService implements UserListQueryServiceInterface
{
    /**
     * @param UserSearchConditions $search
     * @param UserSortConditions $sort
     * @param int $page
     * @param int $limit
     * @return UserListQueryResults
     */
    public function handle(
        UserSearchConditions $search,
        UserSortConditions $sort,
        int $page,
        int $limit
    ): UserListQueryResults
    {
        $query = UserEloquent::with('recentLoginRecord');
        $this->applySearchConditions($query, $search);

        $pagination = new Pagination($query->count(), $limit, $page);
        $this->applyPaginationQuery($query, $pagination);
        $this->applySortConditions($query, $sort);

        $items = $query->get()->map(function (UserEloquent $user) {
            return new UserListQueryResultItem(
                $user->toDomain(),
                $user->recentLoginRecord?->date
            );
        })->toBase();

        return new UserListQueryResults($items, $pagination, $search, $sort);
    }

    /**
     * @param Builder $query
     * @param UserSearchConditions $search
     */
    private function applySearchConditions(Builder $query, UserSearchConditions $search)
    {
        if ($name = $search->get('user_name')) {
            $query->where('name', 'like', self::wrapWithWildcard($name));
        }
    }

    /**
     * @param Builder $query
     * @param Pagination $pagination
     */
    private function applyPaginationQuery(Builder $query, Pagination $pagination)
    {
        if ($pagination->hasLimit()) {
            $query->skip($pagination->getSkip())->take($pagination->getLimit());
        }
    }

    /**
     * @param Builder $query
     * @param UserSortConditions $sort
     */
    private function applySortConditions(Builder $query, UserSortConditions $sort)
    {
        switch (true) {
            case $sort->getAttribute() === 'name':
                $query->orderBy('user_name', $sort->getDirection());
                break;
            default:
                $query->orderBy('created_at', 'asc');
        }
    }
}

ビューをこれだけドメインに依存させるのはどうかという意見もあると思いますが、ビューもドメインロジックを持ってたりします。APIベースのアプリケーションであれば問題ないと思いますが、従来アプリケーションの場合は完全に切り離すのは難しい。
弊社ではビューからドメインを完全に切り離すメリットが節約できるコスト(時間、人員)を上回らなかったので、このような設計手段を取ることにしました。

トランザクションを利用する

こちらはDDDにおいてアンチパターンと呼ばれることが多いです。インフラ層の実装が他の層に漏れてしまうためですね。弊社でトランザクションを採用することになった理由をいくつかのセクションに分けて説明します。

ドメインモデルの設計を捻じ曲げる必要ができてしまう

複数集約を跨いだ更新が発生するパターンは非常に多いと思います。「それはモデリングが悪い」と聞こえてきそうですが、私は違うと思います。
DDDは「ビジネスの実態」と「アプリケーション実装」を限りなく近づけることで成り立つ設計手法だと思います。しかし、「データの整合性」が保てないという理由でモデリングのやり直しが必要とされるケースがあります。

私が何度も読み返して、非常に参考になった記事があります。

https://kbigwheel.hateblo.jp/entry/2018/12/03/aggregate-and-consistency

この記事では集約を跨いだビジネスルールがあり、記事の筆者さんはモデリングを変更することで無事解決に至っていますが、多くの時間と労力を消費されていると推測します。
本来はシンプルに成り立つべきルール(企業に所属できる人数)のはずが、トランザクションを利用できないという理由だけで、複雑さを増すドメイン設計が必要となります。

排他的処理もドメインロジックに関係する

上記の例でもあるように、一時的な整合性の破綻を受け入れて結果整合性で完結させている場面を見ると、整合性を守る行為そのものもドメインロジックとして捉えるべきだと思います。実際に筆頭者さんは楽観的ロックをお使いになっているわけで、その実装はドメインに入っているはずです。

なので、トランザクションという概念自体をドメイン層に共有させることも、そこまで不自然なことではないと考えています。
トランザクションの内部処理はインフラ層で行われるわけで、「排他的処理が成されている」という事実だけがドメイン層に残る形になります。

イベントソーシングについて

イベント駆動を採用するには多くのコストがかかります。社内で発生するコストだけであれば解決の余地はあるかもしれませんが、インフラ面でもコストが増加します。ランニングコストや社内体制を踏まえて、弊社では使わない選択をしています。

リソースの無駄使いを避けたい

プログラマーはトランザクションの扱いに慣れていますし、排他的処理に使われる手法も熟知しています(Mutex、楽観的ロック、悲観的ロックなど)。素晴らしい技術であり、技術者には武器の一つです。
これらの武器をプログラマーから奪って、同等の効果をもたらす仕組みの開発を強要することになります。

「手を使わずに朝食を綺麗に食べてね」と言っているようなものだと思います。「手さえ使えれば綺麗に食べれるのに…」って思うはずですね。

その状態では当然ながら多くの時間を使いますし、設計の変更も生じてしまいます。機能の振る舞い変更時にはモデリングを大きく変更することになり、品質維持によりコストがかかります。トランザクションを使わないという選択は、それだけの結果を伴うということですね。

現実的にRDBMSから乗り換えることは少ない

排他的処理が必要とされる案件でトランザクションが存在しない(RDBMS以外)のデータソースを選択する場面は多くないと思います(マイクロサービスは除く)。その場面が発生した際には、それだけの理由があって予算もあると思われます。

事前に高いコストをかけて、排他的処理をモデリングで再現するよりかは、その場面が発生したタイミングで場面に応じた実装方法に変えた方が無駄がありません。
また、Laravelフレームワークでは多くのデータソースに対応しており、ドライバーを入れ替えるだけで同じインターフェースを利用することができます。

事前に予知できる内容は考慮すべきですが、開発スピードや金銭的コストを天秤にかけながら判断しないといけないと思います。

トランザクションを使うことによる制約

弊社では、複数のデータソースを跨いだデータ更新を行う際は、「トランザクションと同様の仕組みをデータソース側に用意する」か「モデリングの結果整合性で解決する」のいずれかをルールにしています。

トランザクションを受け入れることでモデリングが楽になる

これが最大のポイントですが、モデリングに使う時間を大きく縮めることができます。独自の少ないルールを守ることで、設計ミスや人への負担が減りました。

ViewModelの概念を取り入れる

ViewModelインスタンスは、ビジネスロジックが絡まない範囲で使います。このインスタンスには、画面構成に必要な内容を持たせます。ユーザー検索画面に必要なユーザーステータス一覧や検索結果のオブジェクトなどを持たせます。
ControllerからViewに共有されるのはこのオブジェクトだけになり、コードがすっきりすると共に、View側でサジェスト(コード補完)が使えるようになります。

開発環境でカバーする

DDDを取り入れることによって、クラスやインターフェースだらけになります。弊社ではPhpStormを導入することによって開発効率を高めています。
良く使われるSetter/Getterメソッドの自動生成や、強力なサジェスト機能を有効活用しています。そのお陰でタイプミスが減り、無駄なエラーも減り、書くコードの量も大幅に減りました。

また、インターフェースの生成はLaravel側で自動化することによって、人為的にインターフェースに手を入れる機会も減りました。

まとめ

この記事は弊社の取り組みに過ぎないので、「DDD」の定義を示すものではありません。否定的な意見が多いと思いますが、このような方法もあるということを伝えたく、この記事を書くことにしました。

すべての物事が正論通りに運べれば良いのですが、実際には様々な事情が絡んできます。そのような事情が取り上げられることが少ないため、理解を含めてもらえたらと思います。

DDDは複雑なドメインロジックを成立させる上で素晴らしい概念ですが、会社の規模やプロダクトの特徴に応じた付き合い方を模索した一例を紹介しました。

Discussion

ログインするとコメントできます