MVC + Service + Repository で破綻した話と、今ならこう直す設計

MVC + Service + Repository で破綻した話と、今ならこう直す設計

こんにちは!

今回は筆者の設計に関する失敗談を語りつつ、今ならこういう設計に直すだろうなとという設計方法についてご紹介しようと思います。 初級から中級くらいのエンジニアにとってはある程度参考になる話かと思います。

筆者の過去の開発現場ではMVC + Service + Repositoryという構成になっており、ビジネスロジックは原則Serviceクラスに書くという規約になっていました。 しかし、今振り返ると筆者が在籍していた終盤の方では明らかにFatなServiceクラスになってしまっており、保守性が落ちてしまっていたと思います。

そこで、この問題を今ならこう解決するという設計方針を共有したいと思います。

最初に結論を書くと、ビジネスロジックをDomain層に逃がすのが有効です。具体的に見ていきましょう!

当時の具体的な問題点

当時のコードの代表的なFat Serviceクラスはイメージとして以下のような形になっていました。

class PageService
{
    public function handle(User $user)
    {
        // 権限
        if ($user->isAdmin() || ($user->isSales() && $this->flagA($user))) {
            $canEdit = true;
        } else {
            $canEdit = false;
        }

        // 表示条件
        if (!$user->isAdmin() && $this->flagB($user)) {
            $showWarning = true;
        }

        // 例外ケース
        if ($user->isSuspended()) {
            $canEdit = false;
            $showWarning = true;
        }

        // ...まだまだ続く
    }
}

ログインユーザーの権限によって一部の表示方法が変わる、かつその他多種のフラグによっても部分的な表示方法が変わるような仕様となっていました。 これを1つのメソッドの中で行っており、全体の行数としては100行を超えるメソッドもあったかと思います。 内部でprivateなメソッドに分けるなど工夫はしていたものの、そもそも明らかに業務ロジックが多く、どうしてもFatなServiceクラスになってしまっていました。

こうなると可読性もかなり落ちてしまいますし、バグが含まれていた場合の改修コストが高くなってしまいます。また、将来的な仕様変更にも耐えられなくなる可能性が高いです。

では、どうすればよかったのでしょうか?

解決策 ビジネスロジックをDomain層に逃がす

いろいろな解決アイデアはあると思いますが、クリーンアーキテクチャを経験した今であれば、おそらくDomain層を作成し、そちらにビジネスロジックを逃がす設計にすると思います。 クリーンアーキテクチャにおけるDomain層とは、ビジネスロジックの実装をカプセル化するための層になります。

イメージ的には、Domain層にビジネスロジックを書き、Serviceクラスはそれらを呼び出すだけ、といったイメージです。

それでは、ここからサンプルコードを通して具体的な構成案について見ていきます。以下は一例です。

app/
├─ Http/
│   ├─ Controllers/
│   └─ Requests/
│
├─ Services/                // アプリケーションサービス
│   └─ PageService.php
│
├─ Repositories/            // インフラ実装
│   └─ Eloquent/
│       └─ UserRepository.php
│
├─ Domain/
│   └─ Page/
│       ├─ Model/
│       │   ├─ PageContext.php
│       │   ├─ PageResult.php
│       │   └─ PageState.php
│       │
│       ├─ Service/
│       │   ├─ PagePermissionService.php
│       │   ├─ PageWarningService.php
│       │   └─ PagePolicyService.php
│       │
│       └─ DataAccess/
│           └─ UserRepositoryInterface.php
│
└─ Providers/
    └─ RepositoryServiceProvider.php

本当はPresenter層やらUseCase層などもあるのですが、当時は時間的な制約もあり、FatなServiceクラスのみを対象としてDomain層にビジネスロジックを逃がす設計にしていたかなと思います。

より詳細な内部の実装イメージとしては、以下のような形です。

Domain / Model

Domain Modelは業務における言葉をコードに落とし込んだもので、要素としてはValueObjectやEntityがあります。

PageContext(ValueObject)

class PageContext
{
    public function __construct(
        public readonly User $user,
        public readonly bool $flagA,
        public readonly bool $flagB,
    ) {}
}

PageContextの役割は、判断に必要な状態をまとめることです。

PageResult(ValueObject)

class PageResult
{
    public function __construct(
        public readonly bool $canEdit,
        public readonly bool $showWarning,
    ) {}
}

PageResultの役割は、Bladeに渡す意味のある結果をまとめることです。

PageState(enum / 状態表現)

enum PageState
{
    case Editable;
    case ReadOnly;
    case Warning;
}

PageStateの役割は、ページの状態をまとめることです。このクラスのおかげで条件分岐は劇的に減ります。

Domain / Service

Domain Serviceはビジネスロジックをカプセル化するために使います。

PagePermissionService

class PagePermissionService
{
    public function canEdit(PageContext $context): bool
    {
        if ($context->user->isSuspended()) {
            return false;
        }

        return $context->user->isAdmin()
            || ($context->user->isSales() && $context->flagA);
    }
}

PagePermissionService の役割は、編集権限に関するビジネスロジックを管理することです。

PageWarningService

class PageWarningService
{
    public function shouldShow(PageContext $context): bool
    {
        return !$context->user->isAdmin()
            && $context->flagB;
    }
}

PageWarningService の役割は、表示ルールを管理することです。

PagePolicyService(統合役)

class PagePolicyService
{
    public function __construct(
        private PagePermissionService $permission,
        private PageWarningService $warning,
    ) {}

    public function evaluate(PageContext $context): PageResult
    {
        return new PageResult(
            canEdit: $this->permission->canEdit($context),
            showWarning: $this->warning->shouldShow($context),
        );
    }
}

PagePolicyService は多岐にわたるビジネスロジックのまとめ役で、アプリケーション層から見ると唯一の窓口になります。

Serviceクラス

PageService

class PageService
{
    public function handle(User $user): PageResult
    {
        $context = new PageContext(
            user: $user,
            flagA: $this->calcFlagA($user),
            flagB: $this->calcFlagB($user),
        );

        return $this->pagePolicyService->evaluate($context);
    }
}

あとは、ServiceクラスからDomain層の窓口であるpagePolicyService を呼び出すだけで処理を実行することができるようになります。 Serviceクラスが驚くほどスッキリしたことが確認できるでしょう。

まとめ

今回のまとめです。

  • Serviceに溜まっていた多くのビジネスロジックは、「業務ルールの置き場所がなかった」だけだった。
  • Domain層を導入すると、Serviceは“考える”のをやめ、“つなぐ”ことに専念できるようになる。

他のアーキテクチャの知見や多くのデザインパターンを知っておくと、その都度最適な設計を選択することができるようになります。ぜひ一緒に学習していきましょう!

ではでは。