Service Layerパターンの問題点と解決策

Service Layerパターンの問題点と解決策

こんにちは!

今回は実際の開発現場でよく使われているService Layerパターンの問題点と解決策をテーマに記事を書いていきます。

Service層の導入は広く様々なプロジェクトで使われていますが、完璧なものではなく構造的な問題を抱えています。まずは、言葉の定義を整理し、どのように使いこなしていくかについて筆者なりの見解を共有していきたいと思います。

それでは、見ていきましょう!

Service層とは

そもそもService層は、本来主に以下の2つに分類して考えられています。

  • アプリケーションサービス
  • ドメインサービス

私が様々な記事を読んで確認する限りでは、Service層という言葉の意味が上記2つをどちらも含んで指していることが多い印象です。ある人はアプリケーションサービスの意味で使っており、ある人はドメインサービスの意味で使っている、といった具合です。

上記2つの言葉の意味の違いについて整理すると、以下のようになります。

用語 役割
アプリケーションサービス 複数のドメインのオーケストレーションを担当する
ドメインサービス あるドメインのビジネスロジックを担当する

具体的なコード例を示すと以下のようになります。

アプリケーションサービス

<?php
declare(strict_types=1);

namespace App\Services;

use Domain\Entities\User;
use Domain\Services\UserRegistrationService;
use Domain\Repositories\UserRepositoryInterface;
use Infrastructure\Mail\MailServiceInterface;
use Exception;

/**
 * アプリケーションサービス
 * 外界(コントローラーなど)からの要求を受け取り、ドメインオブジェクトを操作する
 */
class UserApplicationService
{
    public function __construct(
        private UserRegistrationService $domainService,
        private UserRepositoryInterface $userRepository,
        private MailServiceInterface $mailService
    ) {}

    public function register(string $name, string $email): void
    {
        // 1. ビジネスルールのチェック(ドメインサービスを利用)
        if ($this->domainService->isAlreadyRegistered($email)) {
            throw new Exception("このメールアドレスは既に登録されています。");
        }

        // 2. ドメインオブジェクト(エンティティ)の作成
        $user = new User($name, $email);

        // 3. データの保存(リポジトリを利用)
        $this->userRepository->save($user);

        // 4. 外部への通知(メールサービスを利用)
        $this->mailService->sendWelcomeMail($email);
    }
}

ドメインサービス

<?php
declare(strict_types=1);

namespace Domain\Services;

use Domain\Repositories\UserRepositoryInterface;

/**
 * ドメインサービス
 * 「同じメールアドレスのユーザーが既に存在するか」というビジネスルールを判定する
 */
class UserRegistrationService
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    public function isAlreadyRegistered(string $email): bool
    {
        $user = $this->userRepository->findByEmail($email);
        
        // ユーザーが存在すれば「登録済み(true)」を返す
        return $user !== null;
    }
}

ドメインサービスがビジネスロジックを表現しているのに対して、アプリケーションサービスはそのビジネスロジックを使って一連の手順を表現しています。

実際の開発現場でMVC + Service + Repositoryなどのパターンを使っている場合、Service層はアプリケーションサービスの意味で使われていることが多い印象があります。そこで、本記事ではアプリケーションサービスのことをServiceと定義して議論していきます。

Service層が抱えている問題

そもそもService層はなぜ導入されるのでしょう?

おそらく多くの場合、Skinny Controller(薄いController)を目指すために導入されることが多いと思います。Controllerの責務をリクエストとレスポンスの調整役のみとして、ビジネスロジックをService層に切り出します。こうすることで、自然とControllerの見通しがよくなり、FatなControllerの防止ができます。

しかし、実際に運用してみると、以下のような致命的な欠陥があることがわかってきます。

  1. Service層がFatになる
  2. Service層が「ビジネスロジックの指揮」と「ビジネスロジックの記述」の2つの責務を持ってしまう
  3. 複数のService層に同じようなビジネスロジックが分散する

上記の中で最も本質的な欠陥は、おそらく責務を持ちすぎていることでしょう。これは単一責任の原則に反しており、アンチパターンとされています。責務を持ちすぎているがゆえにFatになり、ビジネスロジックが分散してしまうとみることができます。

それでは、具体的なコードで同じようなロジックが分散する例を見てみましょう。例えば、ECサイトの「会員ランクによる割引」というビジネスロジックが、複数のユースケース(購入・見積もり・予約)に分散してしまう例を見ていきます。

以下の2つのServiceは、どちらも「プレミアム会員なら10%オフ」という同じビジネスルールを、それぞれのService内で自前で記述(計算)してしまっています。

購入処理サービス

class PurchaseService
{
    public function execute(int $userId, int $amount): void
    {
        $user = User::find($userId);

        // 【ビジネスロジックの記述】がここに漏れ出している
        if ($user->rank === 'premium' && $user->active) {
            $amount = $amount * 0.9; // 10%割引
        }

        // 購入・決済・在庫減算などの「指揮」
        $this->paymentGateway->charge($userId, $amount);
        // ...
    }
}

見積算出サービス

class QuotationService
{
    public function calculate(int $userId, int $subtotal): int
    {
        $user = User::find($userId);

        // 【全く同じロジックの記述】がここにも重複して現れる
        if ($user->rank === 'premium' && $user->active) {
            $subtotal = $subtotal * 0.9; 
        }

        return $subtotal;
    }
}

この場合、例えば10%オフの仕様が15%オフになった場合にあちこちに分散しているビジネスロジックをすべて修正する必要が出てくるため、保守性が低いコードと言えます。

なぜこのようなことが起きるかというと、ビジネスルールが1つのクラスに凝集されていないからです。1つのクラスが担当していれば、そのクラスのロジックを修正すれば、他の呼び出し個所はすべて自動的に修正されることになります。

解決策:ドメインサービスの導入

それではどうすればいいかというと、Service層からビジネスロジックの責務を剝がしてしまえばよいということになります。そして、その移行先としてドメインサービスに記述し、責務を明確に分けます。

先ほどの例でいうと、割引計算というルールをドメインサービスに切り出し、Serviceクラスはそれを呼び出すだけ(指揮するだけ)にします。

ドメインサービス(ビジネスロジック担当)

class DiscountDomainService
{
    public function applyMemberDiscount(User $user, int $amount): int
    {
        // ビジネスルールを一箇所に閉じ込める
        if ($user->rank === 'premium' && $user->active) {
            return (int)($amount * 0.9);
        }
        return $amount;
    }
}

アプリケーションサービス(指揮担当)

class PurchaseService
{
    public function __construct(private DiscountDomainService $discountService) {}

    public function execute(int $userId, int $amount): void
    {
        $user = User::find($userId);

        // 指揮:ドメインサービスに「計算して」と頼むだけ
        $finalAmount = $this->discountService->applyMemberDiscount($user, $amount);

        $this->paymentGateway->charge($userId, $finalAmount);
    }
}

こうすれば、割引計算のロジックが変更になってもドメインサービスにまとめたクラスを修正するだけで済むようになります。

今回は以上です。