DIを使ってUseCase層にInfra層の知識が漏れるといった課題を解決する

25576 ワード

久しぶりに記事投稿します!
最近、実務でNode.js+Typescript×クリーンアーキテクチャで開発していて
その中でDI(依存性の注入)を使っている部分があるのですがメリットがあまりわかっていなかったので本記事で纏めてみようと思います。

クリーンアーキテクチャについて軽く

DIを語る前に軽くクリーンアーキテクチャについて少し説明します。
クリーンアーキテクチャは関心事の分離をするという目的を達成するための1つの手法として提唱されています。
クリーンアーキテクチャでは、4層の円が描かれており、各円はソフトウェアの領域を表しています。そして、最も重要なルールとして、依存性は内側だけに向かっていなければならないとしています。

image.png

具体的にいうと、
Domain層(Entities)はUseCase層に依存してはいけない。
UseCase層はInfra層(DBや外部API)に依存してはいけない。

UseCase層がInfra層に依存してはいけないのはなぜ?

例えば、UseCaseとして、「タスクを登録する」という振る舞いがあったときを考えてみます。
このときに、「データベースに登録する」や「CSV出力する」といったinfra層の知識が漏れてしいまうと「タスクを登録する」という本来の関心事が読み取りにくくなってしまいます。(ORMやCSV出力の記述があるとコードがややこしくなる)
そのためクリーンアーキテクチャでは関心事を分離をするために依存性を内側だけに向かうようにします。

DIについて

DIを上手く使うことでUseCase層にInfra層の知識が漏れるといった課題を解決することができます。
例えば以下のタスクを登録するUseCaseについて考えてみます。

Infra層の知識が漏れているUseCase

import TaskDomainModel from '../../../domain/task/taskDomainModel'

// タスクを登録する
class CreateTaskUseCase {
    execute(
        name: string,
        accessSource: 'MYSQL' | 'RADIS' | 'LOG' // データの保存先を指定:MYSQL保存なのか、RADIS出力なのか、LOGを吐くだけなのか
    ) {
        // 初期登録
        const task = new TaskDomainModel({
            name: name,
            status: 'INIT',
        })
	
	// 永続化処理
        if (accessSource === 'MYSQL') {
            // MYSQLに登録する処理、ORMの記述など
            // .....
        } else if (accessSource === 'RADIS') {
            // RADISに登録する処理
            // ....
        } else if (accessSource === 'LOG') {
            // Logに出力する処理
            // ....
        }
        return true
    }
}
/**
 * タスクを表すモデル
 */
class TaskDomainModel {
    private id: string
    private name: string
    private status: 'DOING' | 'DONE' // 値オブジェクトにしたほうが良いがここではわかりやすいようにユニオン型で

    constructor(data: { id: string; name: string; status: 'DOING' | 'DONE' }) {
        this.id = data.id
        this.name = data.name
        this.status = data.status
    }
}

こちらはタスクを登録するUseCaseになります。1番良くない例でInfra層の知識がUseCasse層に漏れまくっています。コードの解説をすると、nameを引数にタスクのDomainModelを生成して永続化を行います。
このコードの問題点は下記2点です。

  1. タスクを登録するという本来の関心事が読み取りにくい
  2. 今後、永続化先が増えるたびにUseCaseにif分岐を追加していかなければならない

この問題を解決するために、Repository層を設けてみましょう。

Repository層を用いてデータを永続化する

repository層

class MysqlTaskRepository {
    public async save(task: TaskDomainModel): Promise<TaskDomainModel> {
        // MYSQLに登録する処理、ORMの記述など
        // .....
    }
}
class RadisTaskRepository {
    public async save(task: TaskDomainModel): Promise<TaskDomainModel> {
        // RADISに登録する処理
        // ....
    }
}
class LogTaskRepository {
    public async save(task: TaskDomainModel): Promise<TaskDomainModel> {
        // Logに出力する処理
        // ....
    }
}

useCase層

import MysqlTaskRepository from '../../../infra/mysqlTaaskRepository'
import RadisTaskRepository from '../../../infra/radisTaaskRepository'
import LogTaskRepository from '../../../infra/logTaaskRepository'

export class CreateTaskUseCase {
    execute(
        name: string,
        accessSource: 'MYSQL' | 'RADIS' | 'LOG' // データの保存先を指定:MYSQL保存なのか、RADIS出力なのか、LOGを吐くだけなのか
    ) {
        // 初期登録
        const task = new TaskDomainModel({
            name: name,
            status: 'INIT',
        })

        // 永続化処理
        if (accessSource === 'MYSQL') {
            const mysqlTaskRepository = new MysqlTaskRepository()
            await mysqlTaskRepository.save(task)

        } else if (accessSource === 'RADIS') {
            const radisTaskRepository = new RadisTaskRepository()
            await radisTaskRepository.save(task)

        } else if (accessSource === 'LOG') {
            const logTaskRepository = new LogTaskRepository()
            await logTaskRepository.save(task)
            
        }
        return true
    }
}

永続化の具体的な処理がRepositoryに隠蔽されているので、さっきよりもコードがスッキリしました。

  1. タスクを登録するという本来の関心事が読み取りにくい

この問題が解決されました。ただこのコードでは下記の課題を解決できていません。

  1. 今後、永続化先が増えるたびにUseCaseにif分岐を追加していかなければならない。

そのためにもrepositoryを抽象化してあげて、依存性を注入してみます。

改善後のコード

interfaceを定義する

interface ITaskRepository {
    save(task: TaskDomainModel): Promise<TaskDomainModel>
}

それぞれITaskRepositoryを継承

class MysqlTaskRepository implements ITaskRepository {
    public async save(task: TaskDomainModel): Promise<TaskDomainModel> {
        // MYSQLに登録する処理、ORMの記述など
        // .....
    }
}
class RadisTaskRepository implements ITaskRepository {
    public async save(task: TaskDomainModel): Promise<TaskDomainModel> {
        // RADISに登録する処理
        // ....
    }
}
class LogTaskRepository implements ITaskRepository {
    public async save(task: TaskDomainModel): Promise<TaskDomainModel> {
        // RADISに登録する処理
        // ....
    }
}
import TaskDomainModel from '../../../domain/task/taskDomainModel'
import ITaskRepository from '../../../domain/task/taskDomainRepository'

// タスクを登録する
class CreateTaskUseCase {
    private taskRepository: ITaskRepository
    constructor(taskRepository: ITaskRepository) {
        this.taskRepository = taskRepository // 抽象型を定義
    }
    execute(name: string) {
        const task = new TaskDomainModel(name)
	// if分岐がなくなる。タスクを登録するという抽象的な形になりmysqlやradisといったinfra層の知識を意識しなくても良くなった
        return this.taskRepository.save(task)
    }
}

ITaskRepositoryではインターフェースのみを定義し、実際に保存するという具体的な処理は記載していません。また、accessSourceという保存先を表すInfra層の知識が漏れていおらずif分岐もなくなったことがわかると思います。
constructorでITaskRepositoryを受け取り、ITaskRepositoryという抽象クラスに依存している状態になります。そして、taskRepositoryのインスタンス化は円の外側のレイヤーで行うことになります。(controllerやCliなどのpresenter層)

DIのテクニックを使うと、ITaskRepositoryの具体的なクラスがMySQLに保存するようなMysqlTaskRepositoryであろうが、 Radisに保存するRadisTaskRepositoryであろうが、
UseCase内では詳細を気にしなくてよくなります。
実際にはデータを永続化するという抽象的なことには依存しているけど、DBやCSVにデータを永続化するという具体的なことには依存していないため関心事が分離されている状態になります。

このようにDIを使えば、依存関係を整理することができます。

プレゼンテーション層からの使い方

class TaskController {
    async post(): Promise<void> {
        const taskName = '牛乳を買いに行く'

        // MySqlに保存する場合
        const mysqlTaskRepository = new MysqlTaskRepository()
        const mysqlCreateTaskUseCase = new CreateTaskUseCase(mysqlTaskRepository)
        await mysqlCreateTaskUseCase.run(taskName)

        // Radisに保存する場合
        const radisTaskRepository = new RadisTaskRepository()
        const radisCreateTaskUseCase = new CreateTaskUseCase(radisTaskRepository)
        await radisCreateTaskUseCase.run(taskName)

        // Logに出力する場合
        const logTaskRepository = new LogTaskRepository()
        const logCreateTaskUseCase = new CreateTaskUseCase(logTaskRepository)
        await logCreateTaskUseCase.run(taskName)

    }
}

図解

局所的に見れば、「なんでこんなややこしいことするの?」ってなりますが、プロダクトを大きくする上で層ごとの役割を明確にすることで保守性、可読性が上がっていくと思っています!

追記

このinterfaceを使ってif分岐をなくす手法はデザインパターンパターンの1つの「ストラテジーパターン」というらしいです。(この記事を書いた後に知りました。)
こちらの記事がわかりやすかったので合わせて確認いただけたらと思います。