Clean Architecture 1つのController/Presenterで複数のUseCaseを扱う

この記事は[LIFULLその3 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/lifull3)の14日目の記事です。 
株式会社LIFULL テクノロジー本部のアーキテクトの冨田です。

## はじめに

Clean ArchitectureのController/Presenter/UseCase周りは設計、実装するときに結構悩みます。

1つのController/Presenterで複数のUseCaseを扱うことは、よくあることではないかなと思います。
また、複数のController/Presenterから1つのUseCaseを使うこともあるはずです。

PCとスマートフォンで画面デザインが異なるショッピングサイトを運営しているという想定で設計してみましょう。
今回の主題はController/PresenterとUseCaseなのでFrameWorks & Drivers層、Enterprise Business Rules層についての説明は省きます。

## 仕様

### UseCases

- 商品を閲覧する
- 一緒によく購入される商品をレコメンドを閲覧する

### Controllers/Presenters

- PCの商品ページ
  - 商品を閲覧する
  - 一緒によく購入される商品をレコメンドを閲覧する
- スマートフォンの商品ページ
  - 商品を閲覧する

## 実装

### 前提

このサンプルは下記の環境で実装しています

- Kotlin 1.3.50
- JDK 1.8.0 202
- Micronaut 1.2.7

### UseCases

#### 商品を閲覧する

サンプルのためinterfaceやDTOをまとめて定義しています

```kotlin:BrowseDefnition.kt
package net.tomiyan.shop.business.product

data class BrowseInput(val id: String)

data class BrowseOutput(val id: String, val name: String)

interface BrowseInputBoundary {
    fun browse(input: BrowseInput, outputBoundary: BrowseOutputBoundary)
}

interface BrowseOutputBoundary {
    fun present(output: BrowseOutput)
}
```

```kotlin:BrowseUseCase.kt
package net.tomiyan.shop.business.product

import io.micronaut.runtime.http.scope.RequestScope

@RequestScope
class BrowseUseCase: BrowseInputBoundary {
    override fun browse(input: BrowseInput, outputBoundary: BrowseOutputBoundary) {
        outputBoundary.present(
            BrowseOutput(
                input.id,
                "product ${input.id}"
            )
        )
    }
}
```

#### 一緒によく購入される商品をレコメンドを閲覧する

```kotlin:RecommendDefinition.kt
package net.tomiyan.shop.business.product

data class RecommendInput(val id: String)

data class RecommendOutput(val name: String)

interface RecommendInputBoundary {
    fun browse(input: RecommendInput, outputBoundary: RecommendOutputBoundary)
}
interface RecommendOutputBoundary {
    fun present(output: RecommendOutput)
}
```

```kotlin:RecommendUseCase.kt
package net.tomiyan.shop.business.product

import io.micronaut.runtime.http.scope.RequestScope

@RequestScope
class RecommendUseCase: RecommendInputBoundary {
    override fun browse(input: RecommendInput, outputBoundary: RecommendOutputBoundary) {
        outputBoundary.present(RecommendOutput("recommend for product ${input.id}"))
    }
}
```

### Controllers/Presenters

サンプルのためViewModelとPresenterを同じファイルで定義していますが、
肥大化する場合は分けたほうが良いでしょう。

#### PCの商品ページ

```kotlin:PcPresenter.kt
package net.tomiyan.shop.ui.product

import net.tomiyan.shop.business.product.BrowseOutput
import net.tomiyan.shop.business.product.BrowseOutputBoundary
import net.tomiyan.shop.business.product.RecommendOutput
import net.tomiyan.shop.business.product.RecommendOutputBoundary

data class PcViewModel(val name: String, val recommend: String)

class PcPresenter: BrowseOutputBoundary, RecommendOutputBoundary {
    private lateinit var browse: BrowseOutput
    private lateinit var recommend: RecommendOutput

    override fun present(output: BrowseOutput) {
        browse = output
    }

    override fun present(output: RecommendOutput) {
        recommend = output
    }

    fun viewModel(): PcViewModel {
        return PcViewModel(browse.name, recommend.name)
    }
}
```

```kotlin:PcController.kt
package net.tomiyan.shop.ui.product

import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import net.tomiyan.shop.business.product.*

@Controller("/pc/product")
class PcController(private val browse: BrowseInputBoundary, private val recommend: RecommendInputBoundary) {

    @Get(
        produces = [MediaType.TEXT_PLAIN],
        uri = "/{id}"
    )
    fun index(id: String): String {
        val presenter = PcPresenter()

        // 商品閲覧 UseCaseの利用
        browse.browse(BrowseInput(id), presenter)

        // レコメンド商品閲覧 UseCaseの利用
        recommend.browse(RecommendInput(id), presenter)

        // Presenterを利用しViewModelを生成する
        val viewModel = presenter.viewModel()

        // ViewModelをテンプレートに渡すが、今回は説明なので割愛
        return """
            for pc
            product name: ${viewModel.name}
            recommend: ${viewModel.recommend}
        """.trimIndent()
    }
}
```

#### スマートフォンの商品ページ

```kotlin:SpPresenter.kt
package net.tomiyan.shop.ui.product

import net.tomiyan.shop.business.product.BrowseOutput
import net.tomiyan.shop.business.product.BrowseOutputBoundary

data class SpViewModel(val name: String)

class SpPresenter: BrowseOutputBoundary {
    private lateinit var browse: BrowseOutput

    override fun present(output: BrowseOutput) {
        browse = output
    }

    fun viewModel(): SpViewModel {
        return SpViewModel(browse.name)
    }
}
```

```kotlin:SpController.kt
package net.tomiyan.shop.ui.product

import io.micronaut.http.MediaType
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import net.tomiyan.shop.business.product.*

@Controller("/sp/product")
class SpController(private val browse: BrowseInputBoundary) {

    @Get(
        produces = [MediaType.TEXT_PLAIN],
        uri = "/{id}"
    )
    fun index(id: String): String {
        val presenter = SpPresenter()

        // 商品閲覧 UseCaseの利用
        browse.browse(BrowseInput(id), presenter)

        // Presenterを利用しViewModelを生成する
        val viewModel = presenter.viewModel()

        // ViewModelをテンプレートに渡すが、今回は説明なので割愛
        return """
            for smart phone
            product name: ${viewModel.name}
        """.trimIndent()
    }
}
```

## まとめ

実装する言語によってはメソッド名に気をつける必要はありますが、
商品詳細のOutputBoundaryInterface、レコメンドのOutputBoundaryInterfaceを実装したPcPresenter、SpPresenterを用意すれば実現できます。

明日は@masamichiさんの記事です。

## 参考文献

- [Clean Architecture　達人に学ぶソフトウェアの構造と設計](https://asciidwango.jp/post/176293765750/clean-architecture)