この記事はLIFULLその3 Advent Calendar 2019の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をまとめて定義しています
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)
}
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}"
)
)
}
}
一緒によく購入される商品をレコメンドを閲覧する
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)
}
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の商品ページ
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)
}
}
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()
}
}
スマートフォンの商品ページ
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)
}
}
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さんの記事です。