エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

ドメイン分離でユースケースの不安定度(Instability)を下げた話

【Unit4 ブログリレー7日目】

こんにちは、Unit4のキムです。この記事はUnit4チーム (m3.com開発チーム) メンバーにより投稿されるブログリレー7日目の記事です。

本記事では、近年やけに不安定になってきたユースケースコンポーネントをドメイン分離を通じて安定させた事例をご紹介します。この内容は先日のtechtalk発表内容の続きでもあるので、ぜひ発表のほうもご覧ください!

youtu.be

とある山の登山道。本文には関係ありません。

はじめに

Unit4のサービス「m3クイズ」は約15年間の成長に伴い、保守性と拡張性を維持することが課題となっています。特にビジネスルールが集中するユースケースレイヤーは極力変更を避けたいところですが、コンポーネントの肥大化とともに、修正頻度が上がるようになりました。この問題を Clean Architecture の不安定度に基づいて診断し、数値的に評価しながら改善させることにしました。

不安定度と SDP

まず、本題に入る前に、Clean Architecture が提唱する原則の1つである「安定依存の原則(Stable Dependencies Principle: SDP)」について簡単に説明します。

SDPは「依存関係は安定性の方向に向けるべき」という原則です。つまり、不安定なコンポーネントが安定したコンポーネントに依存することは良いですが、その逆は避けるべきだということです。

ここでの「安定性」とは、変更の難しさ(変更してはいけなさ)を表します。多くのコンポーネントから依存されているコンポーネントは安定しており、変更が難しいと言えます。一方、他のコンポーネントへの依存が多いコンポーネントは不安定であり、変更が容易(変更されやすさ)です。

この安定性を数値化したものが「不安定度(Instability)」です。不安定度は次の式で計算されます:

I = Fan_out / (Fan_in + Fan_out)

ここで:

  • Fan_in (求心依存): そのコンポーネントに依存している他のコンポーネントの数
  • Fan_out (遠心依存): そのコンポーネントが依存している他のコンポーネントの数
  • I (不安定度): 0 (完全に安定)から 1 (完全に不安定)の値

理想的なアーキテクチャでは、ドメインモデルやビジネスルールなどの中核部分は安定しており(I値が低い)、UIやデータベースアクセスなどの外部部分は不安定(I値が高い)であるべきです。

「今日のクイズ」と「夕方クイズ」

次に、事例で挙げるサービスの概要を軽く説明しましょう。「m3クイズ」配下で提供するクイズの種類には、「今日の臨床クイズ」(以下「今日のクイズ」)と「夕方クイズ」があります。

  • 今日のクイズ: 毎日04時~翌日04時の間解答できる設問一式
  • 夕方クイズ: 当日の今日のクイズに15時までに解答した人にだけ、18時~翌日04時の間に解答できる設問

あるユーザーに夕方クイズの挑戦権があるか否かの判定には、今日のクイズの解答状況を確認しなければなりません。つまり、夕方クイズは今日のクイズに依存することがわかります。

1. 問題診断

上述のように、ユースケースレイヤーの肥大化が問題視される中、特にClinicalQuizUseCaseは、既存の「今日のクイズ」と後発の「夕方クイズ」という2つの異なる機能を担当していました。これによって、次の問題が生じていました:

  1. 高い不安定度: ClinicalQuizUseCaseは多くの外部コンポーネントに依存しており、不安定度が0.8とかなり高い値でした。しかしこの部品はサービスのコアである「今日のクイズ」業務ロジックを担当しており、変更のリスクを抑止したいです。

  2. SRP(単一責任の原則)違反: 「今日のクイズ」以外にも、「夕方クイズ」「設問のパーソナライズ」(タイトル等をユーザー毎に変える機能)という異なる責任を1つのクラスが担っており、メソッド数も16個に達しました。

  3. テストの複雑さ: 機能が混在しているため、ユニットテストが複雑になっていました。

コードの一部を見てみましょう:

@Component
class ClinicalQuizInteractor(
    // 計16個もの依存先
    // 次の2つは「夕方クイズの取得」に利用する
    private val eveningQuizCampaignRepository: EveningQuizCampaignRepository,
    private val eveningQuizHistoryRepository: EveningQuizHistoryRepository,
    // 次の2つは「設問のパーソナライズ」に利用する
    private val questionCustomTitleRepository: QuestionCustomTitleRepository,
    private val htmlParseService: HtmlParseService,
    // ... 他の依存先
) : ClinicalQuizUseCase {
    // 今日のクイズ関連のメソッド
    override fun getAnswerableDailyQuiz(account: Account): ClinicalQuiz? {
        // 実装
    }
    
    // 夕方クイズ関連のメソッド
    override fun getCurrentOpenedEveningQuiz(account: Account): EveningQuiz? {
        // 実装
    }
    
    // その他多数のメソッド
}

このクラスは16個もの依存先を持ち、「今日のクイズ」以外の複数の機能を実装しています。

2. リファクタリング方針

問題を解決するために、次の方針を立てました:

  1. ドメイン分離: 今日のクイズと夕方クイズの機能をパッケージレベルで分離し、それぞれ専用のユースケースクラスを作成する。

  2. ドメインサービスの導入: 共用のビジネスロジックをドメインサービスとして抽出し、両ユースケースから再利用できるようにする。

  3. クロスドメインサービスのDIP: クロスドメインサービス(複数ドメインを跨ぐサービス)はインターフェースとして定義し、実装は別ドメインに配置します。このようなDIP(依存性逆転の原則)を通じて依存関係の方向を正しく制御する。

この方針により、次のメリットが期待できます:

  • 依存先の削減による不安定度の低下
  • SRPに則った設計と軽量化
  • 変更の影響範囲の限定
  • テストの容易さ

3. 実装

3.1 ドメイン分離

まず、夕方クイズ機能を専用のパッケージに分離しました:

// 新しいインターフェース
package com.m3.m3quiz.evening.usecase.inputport

interface EveningQuizUseCase {
    fun getCurrentOpenedEveningQuiz(account: Account): EveningQuiz?
    fun tryAnswerTodaysEveningQuiz(questionId: Long, account: Account)
}

// 実装
package com.m3.m3quiz.evening.usecase.interactor

@Component
class EveningQuizInteractor(
    private val eveningQuizCampaignRepository: EveningQuizCampaignRepository,
    private val eveningQuizHistoryRepository: EveningQuizHistoryRepository,
    private val eveningQuizRepository: EveningQuizRepository,
    private val eveningQualificationService: EveningQualificationService,
    private val personalizationService: PersonalizationService,
    // ... 他の依存先
) : EveningQuizUseCase {
    // 実装
}

これで、ClinicalQuizInteractorから夕方クイズ関連のコードを削除し、専用のクラスに移動させました。

3.2 ドメインサービスの導入

次に、共通の「設問のパーソナライズ」と「夕方クイズ提供」機能をドメインサービスとして抽出しました:

package com.m3.m3quiz.clinical.domain.service

@Service
class PersonalizationService(
    private val questionCustomTitleRepository: QuestionCustomTitleRepository,
    private val htmlParseService: HtmlParseService,
) {
    fun personalizeQuestion(
        question: ClinicalQuestion,
        publishedAt: OffsetDateTime,
        account: Account?,
    ): PersonalizedClinicalQuestion {
        // 実装
    }
}

package com.m3.m3quiz.evening.domain.service

@Service
class EveningQuizFetchService(
    private val eveningQuizCampaignRepository: EveningQuizCampaignRepository,
    private val eveningQuizRepository: EveningQuizRepository,
    private val eveningQuizHistoryRepository: EveningQuizHistoryRepository,
    private val personalizationService: PersonalizationService,
    private val eveningQualificationService: EveningQualificationService,
) {
    fun fetchEveningQuizBanner(
        now: OffsetDateTime,
        account: Account,
    ): QuizBanner? {
        // 実装
    }
}

そしてClinicalQuizInteractorEveningQuizFetchServicePersonalizationServiceに依存するように変更しました:

@Component
class ClinicalQuizInteractor(
    // 「夕方クイズの取得」を提供するドメインサービス
-    private val eveningQuizCampaignRepository: EveningQuizCampaignRepository,
-    private val eveningQuizHistoryRepository: EveningQuizHistoryRepository,
+    private val eveningQuizFetchService: EveningQuizFetchService,
    // 「設問のパーソナライズ」を提供するドメインサービス
-    private val questionCustomTitleRepository: QuestionCustomTitleRepository,
-    private val htmlParseService: HtmlParseService,
+    private val personalizationService: PersonalizationService,
    // ... 他の依存先
) : ClinicalQuizUseCase {
    override fun getQuizBanner(account: Account): QuizBanner {
        // ...
        // 夕方クイズの処理を委譲
        return eveningQuizFetchService.fetchEveningQuizBanner(now, account)?.takeIf { it.isOpened } ?: run {
            // 今日のクイズ情報を取得
            fetchDailyQuizBanner(now, account)
        }
    }
    
    // その他のメソッド
}

3.3 クロスドメインサービスのDIP

「夕方クイズ」と「今日のクイズ」両ドメインに跨ぐ「夕方クイズの挑戦権確認」機能は、夕方クイズにインターフェースを作成し、今日のクイズに実装体を配置します。

// インターフェース
package com.m3.m3quiz.evening.domain.service

/** 夕方クイズの挑戦権を調べるドメインサービス */
interface EveningQualificationService {
    fun isCurrentlyQualified(now: OffsetDateTime, account: Account): Boolean
}

// 実装
package com.m3.m3quiz.clinical.domain.service.impl

/** 今日のクイズに関連する夕方クイズの挑戦権を調べるサービス */
@Service
class ClinicalEveningQualificationServiceImpl(
    private val clinicalQuizRepository: ClinicalQuizRepository,
) : EveningQualificationService {
    override fun isCurrentlyQualified(now: OffsetDateTime, account: Account): Boolean = 
        clinicalQuizRepository.isCurrentlyEveningQualified(now, account)
}

4. 結果

ではリファクタリングの結果を分析してましょう。

4.1 ユースケース複雑度の低下

ユースケース 指標 リファクタリング前 リファクタリング後 変化
ClinicalQuizUseCase(及び実装体) 求心依存 (Ca) 4 4 -
遠心依存 (Ce) 16 14 -2
不安定度 (I) 0.8 0.778 -0.022
メソッド数 16 11 -5
EveningQuizUseCase(及び実装体) 求心依存 (Ca) N/A 2 N/A
遠心依存 (Ce) N/A 7 N/A
不安定度 (I) N/A 0.778 N/A
メソッド数 N/A 2 N/A

ClinicalQuizUseCaseの遠心依存が16から14に減少し、不安定度は0.800から0.778へと若干軽減されました。

一方で、新設のEveningQuizUseCaseの不安定度は0.8となり、ユースケースとしては比較的高い不安定度です。しかしこれは夕方クイズという小規模なドメインに特化したビジネスルールのコンポーネントとして、妥当な値と判断しています。

また、ClinicalQuizUseCaseEveningQuizUseCase等に夕方クイズ関連のメソッドを5つ委譲することになり、ユースケースの責任が適切に分担されました。

4.2 SDP強化

導入したドメインサービスも、アーキテクチャの安定性にどう貢献するか見てみます:

ドメインサービス 求心依存 (Ca) 遠心依存 (Ce) 不安定度 (I)
EveningQualificationService(及び実装体) 3 2 0.4
PersonalizationService 2 2 0.5
EveningQuizFetchService 2 5 0.714

ドメインサービスの不安定度は0.4~0.714と、ちょうど0.5のあたりに位置しています。これらが不安定度0.5~0.8に分布するユースケースレイヤーに依存されていることから、SDPが強化され、より Clean Architecture に近づきます。実際にユースケースとドメインサービスの不安定度関係を図解すると、綺麗にSDPが成り立つことがわかります。

SDPが成り立つユースケースとドメインサービス関係
SDPが成り立つユースケースとドメインサービス関係

4.3 クロスドメインサービスの拡張性

EveningQualificationServiceは必要に応じて、どのドメインからも実装できます。夕方クイズの挑戦権に第3のサービスが関わるような仕様変更があった場合、当該ドメインで実装体を作成するだけで対応できます。つまり、ドメインに関して少ない変更で拡張できるようになりました。

4.4 依存関係とアーキテクチャの再構築

これらの実装の結果、次のような依存関係になりました:

リファクタリング後のアーキテクチャ
リファクタリング後のアーキテクチャ

相互依存がなく、レイヤーの役割が明瞭なアーキテクチャができています。

5. まとめ

今回のリファクタリングを通じて得られた効果とノウハウをまとめます:

  1. 不安定度軽減: 共通のビジネスロジックをドメインサービスとして抽出することで、コードの再利用性が高まり、ユースケースの複雑さが軽減されます。その結果、自然にSDPが強化されます。

  2. SRP強化: ユースケースの責任をドメインレベルで分離することで、SRPが強化されます。

  3. クロスドメインサービスのインターフェース定義: ドメインに関する拡張性が向上します。

  4. メトリクスの活用: 不安定度などのメトリクスを活用することで、リファクタリングの効果を数値的に評価できます。

この時点でふと思われる方もいるかもしれません。もしかして...

SDPとSRPは実はスケールに違いがあるものの、結構似ている思想なのでは?

そこで、Gemini に両者の関わりをまとめてもらいました!

In essence, SRP is about what goes into a component, while SDP is about how that component relates to others in the system. Following SRP at the component level leads to well-defined components, which in turn makes it possible to apply SDP to structure the dependencies in a way that protects the core logic from volatile details.

コンポーネントの役割を機能・構造的に絞って設計すれば、アーキテクチャは自然に安定依存に化していくこと。うむうむ、納得行きますね。

一方で、ClinicalQuizUseCaseは依然として比較的に不安定度を持っています。このクラスはサービスで最も重要かつ高水準のビジネスルールを持つため、高い安定度が求められます。更なる責任分担が考慮できますね!

アーキテクチャの改善は一度で完了するものではなく、継続的な取り組みが必要です。今回の経験を活かし、今後も定期的にコードベースを評価し、必要に応じてリファクタリングを行っていきたいと思います。

We are Hiring!

エムスリーでは一緒にプロダクト開発をするエンジニアを絶賛募集中です。しっかりコミュニケーションを取ってより良いプロダクトを作れたら、と思う方は是非ご応募ください。

エンジニア採用ページはこちら

jobs.m3.com

エンジニア新卒採用サイト

fresh.m3recruit.com

カジュアル面談はこちら

jobs.m3.com