エムスリーテックブログ

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

Clean Architectureなにもわからないけど実例を晒して人類に貢献したい

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。

これまでは、中村の記事で宣言した 「医師版Stack Overflow」(12/16に正式名称Docpediaとしてリリースされました) の技術的チャレンジの 記事を続けて書いていたのですが、今回はここで宣言しなかったClean Architectureについて書きます。

f:id:fukubaya:20200205205515j:plain
浪江駅(なみええき)は、福島県双葉郡浪江町にある、東日本旅客鉄道(JR東日本)常磐線の駅。本文には特に関係ありません。

なぜ書くのか

参考にできる実例を増やしたい

Clean Architectureってよく聞くし、完全に理解したような気がしていたけど、 実践するとなるとなにもわからない、という印象でした*1。 Docpediaは最初からClean Architectureに基づいて構築するつもりでしたが、 上記記事で宣言していなかったのは正直マサカリが恐くて書くかどうか迷っていたからです*2

それでも書く気になったのは、実例を1つ紹介することで、今後実践する人の参考にしてほしいと思ったからです。 いろいろ検索しても実例まで載っている記事をあまり見かけることがなく、少ない事例から理解するのが難しいと感じました。 爆死覚悟で事例を1つ公開することで、理解の手助けができればよいなと思います。

Tech Blogはそのままドキュメントになる

もう一つの理由は、Tech Blogに書けば社外に知見を公開できるのと同時にそのままチーム向けのドキュメントとして機能すると分かったからです。 途中から新たに加入したメンバーやインターン生も過去の記事が理解の参考になったと聞きました。 どうせ書くなら社内にも社外に役に立つ形になっていた方がお得です。 社内向けだけに書くよりモチベーションも上がりますし、新しい取り組みに挑戦する理由づけにもできます。

www.m3tech.blog www.m3tech.blog www.m3tech.blog

参考記事

実例が少ないとは言いましたが、今回の構成を考えるにあたって、以下の記事がとても参考になりました。 むしろこれだけでかなり理解できると思います。 今回の内容はこれらを補強する実例の一つです。

nrslib.com qiita.com qiita.com www.nuits.jp

サンプルアプリケーションの概要

Docpediaのコードをそのまま載せると説明に不要な部分もあって分かりづらくなるので、 簡素化したものを実例として紹介します。

SpringBoot + Kotlin でユーザ画面で使用するための、以下の2つのREST API実装します。

  • 質問を投稿する
  • 質問IDを指定して質問を取得する

The Dependency Ruleに基づいた実装例

The Dependency Rule

例の図です。内側がビジネスルール(質問を投稿する、質問を取得する)など抽象度の高い層で、 外側はDB、外部APIなど具体性が高い層です。 内側にあるビジネスルールなど抽象度が高いものは、外側の実装、変更に依存してはいけません(The Dependency Rule)。 例えば、質問の保存先がRDBからS3に変わったとしても、質問を投稿する機能の実装に影響を与えてはいけません。 そもそも内側は外側に 具体的 に何があるかを知りません。

ただし、ルールを厳しく適用すると技術的都合で実装が不可能ではないが困難になる場合もあるので、例外はあると思います。 例えば、外側のDBを知らない層に対して @Transactional を設定しないといけない場合もありますし、 DBのクエリが重いので部分的にキャッシュを導入することになった影響で、呼び出し側も変更しないといけないこともあります。

図では4つの円で表現されていますが、実際のプロダクトでは、 The Dependency Ruleを守っていれば必要に応じて何層になっていてもよいです。

f:id:fukubaya:20200205182250j:plain
同心円の図 (The Clean Code Blog)

依存性逆転の原則(Dependency Inversion Principle)

このルールを守るため、依存性逆転の原則を利用します。 JavaやKotlinなどでは、interface でこれを実現します。 外側は定義した interface を実装し、内側はこの interface を利用することにすれば、 内側は外側を 具体的 に知らなくて済みます。

図の右下はこの流れを説明してます。<I>interface で、白抜き矢印はUMLの実現(Realization)と同じ意味です。 例えば、Use Case InteractorがPresenterを呼ぶ場合、直接Presenterを呼ぶとルールに反します。 そこで、Use Case Interactorは interface である Use Case Output Portを呼ぶことで、外側のPresenterを知ることなく、Presenterを呼ぶことができます。 同様に、Use Case Interactorは interface である Use Case Input Portを呼ぶことで、外側のControllerを知ることなく、Controllerを呼べます。

ここでは、ControllerやPresenterなどの名前には意味はなく、 違う層の間で interface を介して依存性をコントロールすることが目的です。 以下の例でも、 UseCase, Domain model, Interactor, Repository, Controller, Presenter など層に応じた名前が出てきますが、 これらの名前はClean Architecureの例をそのまま適用していたり、ドメイン駆動設計(DDD)の概念を参考にしていたり、Springのスタイルに沿っていたり、OpenAPI Generatorの設定に合わせていたりなど、雰囲気でつけているだけで、 作る人が適切に命名すればよいと思います。 むしろどこの概念にも出てこないけど、役割を表現できる適切な名前があればそれが混乱が少なくてよいと思います。

全体図

今回の全体図です。 <DS> は Data Structureでデータ構造です。Kotlinでは data class です。 また、DIは依存性注入(Depedency Injection) です。Springでは inserface@Autowired している関係です。

元の図に合わせて4層に分けてみましたが、 実際には同じ層の中でもさらに interface を定義して依存性を定義しているので、4層以上あります。

f:id:fukubaya:20200205192656p:plain
全体図

UseCase

f:id:fukubaya:20200205194527p:plain
QuestionsUseCase

UseCaseの役割は、あるメソッドに対して、どんな入力で、何が返されるのかを 定義 することとしました。 メソッドが具体的にどういう処理をするかは決めません。メソッド名(とコメント)で機能を 定義 だけします。 packageも分けていますが、クラス名だけで識別できるように **UseCase と命名しています。

まずは質問を投稿する機能と、質問を取得する機能の2つ機能が必要なのでそれらを定義します。

package com.m3.docpedia.usecase.inputport

import com.m3.docpedia.domain.model.question.AskQuestionRequest
import com.m3.docpedia.domain.model.question.Question

interface QuestionsUseCase {
    /**
     * 質問を登録する
     */
    fun askQuestion(askQuestionRequest: AskQuestionRequest)

    /**
     * 質問を取得する
     */
    fun getQuestion(questionId: Int): Question?
}

質問の登録 (askQuestion) は、登録要求 (askQuestionRequest: AskQuestionRequest) を引数に渡して登録処理を実行します。返り値はありません。

質問の取得 (getQuestion) は、質問ID (questionId: Int) *3を引数に渡して、処理の結果、返り値として Question が返ります。ただし該当の質問がない場合もあるので nullable です。

Domain model

f:id:fukubaya:20200205194624p:plainf:id:fukubaya:20200205194555p:plain
Question, AskQuestionRequest

Clean ArchitecureではEntitiesと呼ばれる層にだいたい対応しています。 ここの命名はドメイン駆動設計の概念と混ざって曖昧になってしまっていますが、適当な命名が思い付かなかったのでこう呼びます。 ここでのDomain modelの役割は、ドメインの概念を表現したデータ構造の定義です。 クラス名のルールは作りませんでしたが、 packageは com.m3.docpedia.domain.model で統一しています。

UseCaseで定義した機能に合わせて、質問を表すモデルと、質問作成要求を表すモデルが必要になるので定義します。 なお、質問を定義すると質問者を表すモデル Member も必要になりますがここでは省略します。

package com.m3.docpedia.domain.model.question

import com.m3.docpedia.domain.model.member.Member
import java.time.OffsetDateTime

/**
 * 質問
 */
data class Question(
    /** 質問ID */
    val id: Int,
    /** タイトル */
    val title: String,
    /** 本文 */
    val body: String,
    /** 質問日時 */
    val askAt: OffsetDateTime,
    /** 質問者 */
    val askMember: Member
)
package com.m3.docpedia.domain.model.question

import com.m3.docpedia.domain.model.member.Member

/**
 * 新規質問作成要求
 */
data class AskQuestionRequest(
    /** タイトル */
    val title: String,
    /** 本文 */
    val body: String,
    /** 質問者 */
    val askMember: Member
)

Interactor

f:id:fukubaya:20200205195020p:plain
QuestionsInteractor

Interactorの役割は、UseCaseで定義した機能の実装です。 ただし、Interactorの役割は、Domain modelの操作までであり、結合、加工、集約までは実装しますが、 実際にDBや外部APIから取得する部分は実装しません。 Domain modelの永続化、取得はRepositoryの役割です。 クラス名は **Interactor としました。

質問の登録機能と、質問の取得機能を実装します。 特に加工などはしないので、Repositoryのメソッドを呼んだ結果をそのまま返しています。 実際のデータ取得処理は実装しません。 QuestionsRepository とて注入するだけです*4。 これにより依存性が逆転し QuestionsInteractor は、データ操作部分の実装に依存しなくなります。 QuestionsRepository の先が、RDBだろうが、外部APIだろうが、ローカルのファイルだろうが、S3だろうが関係ありません。

package com.m3.docpedia.usecase.interactor

import com.m3.docpedia.domain.model.question.AskQuestionRequest
import com.m3.docpedia.domain.model.question.Question
import com.m3.docpedia.domain.repository.QuestionsRepository
import com.m3.docpedia.usecase.inputport.QuestionsUseCase
import org.springframework.stereotype.Component

@Component
class QuestionsInteractor(
    val questionsRepository: QuestionsRepository
) : QuestionsUseCase {
    override fun askQuestion(askQuestionRequest: AskQuestionRequest) {
        questionsRepository.createQuestion(askQuestionRequest)
    }

   override fun getQuestion(questionId: Int): Question? {
        return questionsRepository.getQuestion(questionId)
    }
}

Repository

f:id:fukubaya:20200205195042p:plain
QuestionsRepository

Repositoryの役割は、Domain modelのライフサイクルに関わる操作を 定義 することです。 具体的にどのように取得するか(RDBから? 外部APIから? ローカルのファイルから? S3から?) は決めません。 ここにDBやAPIとのやりとりまで含めて実装してもよいのですが、MyBatisを通してのDBからの取得とその変換部分がすべて一つになっていると大きすぎると思ったので、ここにもう一層設けました。 UseCaseと同じく、メソッド名(とコメント)で機能を 定義 だけします。 クラス名は **Repository と命名しています。

質問の操作として、質問の追加と質問の取得を定義します。 細かく徹底すれば AskQuestionRequest に対応するこの層のモデルを定義して QuestionRepository で 変換した方がさらに依存性を下げられますが、今のところ実害はないので、Domain modelのまま渡します。

package com.m3.docpedia.domain.repository

import com.m3.docpedia.domain.model.question.AskQuestionRequest
import com.m3.docpedia.domain.model.question.Question

interface QuestionsRepository {
    /**
     * 新規の質問を追加する
     */
    fun createQuestion(askQuestionRequest: AskQuestionRequest)

    /**
     * 質問を取得する
     */
    fun getQuestion(id: Int): Question?
}

RepositoryImpl

f:id:fukubaya:20200205195106p:plain
QuestionsRepositoryImpl

RepositoryImplの役割は、Repositoryで定義したDomain model操作機能の実装です。 ただし、RepositoryImplの役割は、Domain modelを外の層で使うデータ構造 (Db**など) に変換して渡したり、 逆に外の層から取得したデータをDomain modelに変換、加工して操作するまでです。 SQLの結果をオブジェクトにマッピングしたり、JSONのシリアライズ/デシリアライズなどは実装しません。 クラス名は **RepositoryImpl と命名しました。

質問の登録では、AskQuestionRequest から登録に必要な情報を抜き出したり、加工し、 実際にDB操作をする QuestionMapper に渡します。 質問の取得では、質問IDを渡して QuestionMapper からSQLの結果として DbQuestion が返ってくるので、 Domain modelである Question に変換して返します。

package com.m3.docpedia.adapter.gateway

import com.m3.docpedia.adapter.gateway.db.QuestionMapper
import com.m3.docpedia.adapter.gateway.db.model.DbQuestion
import com.m3.docpedia.domain.model.question.AskQuestionRequest
import com.m3.docpedia.domain.model.question.Question
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional

@Repository
class QuestionsRepositoryImpl(
    val questionMapper: QuestionMapper,
    val memberRepository: MemberRepository
) : QuestionsRepository {

    @Transactional
    override fun createQuestion(askQuestionRequest: AskQuestionRequest) {
        TODO("""
            askQuestionRequestから登録に必要な情報を取り出し、
            questionMapperを通して質問をinsertする
        """)
    }

    override fun getQuestion(id: Int): Question? {
        TODO("""
            questionMapperを通してDbQuestionを取得。
            memberRepositoryを通して質問者を取得。
            Questionに変換して返す。
        """)
}

DocpediaではデータベースとのやりとりにMyBatisを使いました。 Mapperの定義と実装例はすでに 前回の記事 に書いてあるので、 そちらを参照してください。

DbQuestion は、SQLの結果を表すモデルなので、この中で Question に加工、変換して返しています。 MyBatisは自分で定義したクラスにマッピングしてくれますが、そうでない実装では、 java.sql.ResultSet など、ドメインが依存したくないデータ構造の場合もあるので、 これらも上位の層に合わせて変換する必要があります*5

UseCase、Interactor、Repositoryはこれらのモデルを知らない、知ってはいけないはずなので、 この層のモデルはDbQuestion, DbAnswer, DbMember などすべて Db を先頭につけています。 もちろん適用する層が明確になるように package は分けてはいますが、 それでもクラス名として命名しておかないと、機能追加を繰り返すうちに他の層に混ざっていく可能性があると思ったからです。 例えば、com.m3.docpedia.domain.model.Questioncom.m3.docpedia.adapter.gateway.db.model.Question はimport文では明確に違いますが、コード中はどちらも Question になってしまって、どちらの Question を触っているかが分かりづらくなります。 IDEのコード補完で間違った Question を指定してしまい、そのまま実装が進んでしまうこともありえます。 また、コードレビューではテキストしか情報がないので、「この層に Db** があるのはおかしい」など、レビューでも気づきやすくなるはずです。

package com.m3.docpedia.adapter.gateway.db.model

import com.m3.docpedia.annotation.Data
import java.time.OffsetDateTime

/**
 * 質問
 */
@Data
data class DbQuestion(
    /** 質問ID */
    val id: Int,
    /** タイトル */
    val title: String,
    /** 本文 */
    val body: String,
    /** 質問者Id */
    val askMemberId: Int,
    /** 質問日時 */
    val askTimestamp: OffsetDateTime
)

今回の例にはありませんが、Docpediaには質問の検索機能もあります。 検索にはElasticSearchを使っているので、検索APIの結果をマッピングしたモデルも この層のモデルなので EsQuestion, EsAnswer など Es を先頭に付与しています。

Controller, ControllerService

UseCaseからDBへの保存、取得までは実装できたので、次は反対側(Web)側から見ていきます。

f:id:fukubaya:20200205195155p:plain
Controller, ControllerService

Controllerの役割は、HTTPでJSONを受け取り、デシリアライズして、ControllerServiceに渡すこと、 さらに、ControllerSeriviceでの処理の結果をシリアライズしてHTTPでクライアントに返すことです。 デシリアライズしたオブジェクトを使った処理は ControllerService の実装に任せます。 なお、ここで言うControllerは、SpringのControllerと同じで、Clean ArchitectureでControllerと呼ぶものとは一致しません。

QuestionsApiController が実際にWeb側からのリクエストを受けつけて、 QuestionsApiControllerService で定義されたメソッドを呼び、結果を返します。 OpenAPI Generatorでの生成時に serviceInterface=true を指定しているので、 QuestionsApiControllerServiceinterface で、ここでは Autowired するだけです。

package com.m3.docpedia.adapter.restapi.controller

import com.m3.docpedia.adapter.restapi.model.ApiAskQuestionRequest
import com.m3.docpedia.adapter.restapi.model.ApiQuestion
...

@RestController
@Validated
@RequestMapping("\${api.base-path:}")
class QuestionsApiController(@Autowired(required = true) val service: QuestionsApiControllerService) {

    @RequestMapping(value = ["/questions/ask"], consumes = ["application/json"], method = [RequestMethod.POST])
    fun askQuestion(
        @Valid @RequestBody apiAskQuestionRequest: ApiAskQuestionRequest
    ): ResponseEntity<Unit> {
        return ResponseEntity(service.askQuestion(apiAskQuestionRequest), HttpStatus.valueOf(201))
    }

    @RequestMapping(value = ["/questions/{id}"], produces = ["application/json"],method = [RequestMethod.GET])
    fun getQuestion(
        @PathVariable("id") id: kotlin.Int
    ): ResponseEntity<ApiQuestion> {
        return ResponseEntity(service.getQuestion(id), HttpStatus.valueOf(200))
    }
}
package com.m3.docpedia.adapter.restapi.controller

import com.m3.docpedia.adapter.restapi.model.ApiAskQuestionRequest
import com.m3.docpedia.adapter.restapi.model.ApiQuestion
interface QuestionsApiControllerService {

    fun askQuestion(apiAskQuestionRequest: ApiAskQuestionRequest): Unit

    fun getQuestion(id: kotlin.Int): ApiQuestion
}

これらのコードは、OpenAPIの定義から自動で生成されるので、実際には手で書きません。 OpenAPIでは、どういうメソッド(=endpoint)があるか、どういうモデル(=スキーマ)があるかを定義します。 詳しくはこちらの記事を見てください。 なお、さきほどの Db** と同じように、こちらのモデルは先頭に Api をつけるようにしています。

package com.m3.docpedia.adapter.restapi.model

import com.fasterxml.jackson.annotation.JsonProperty
import javax.validation.constraints.NotNull

/**
 * 質問
 * @param id 質問ID
 * @param title タイトル
 * @param body 質問本文
 * @param askMember
 * @param askAt 質問日時
 */
data class ApiQuestion(

    @get:NotNull
    @JsonProperty("id") val id: kotlin.Int,

    @get:NotNull
    @JsonProperty("title") val title: kotlin.String,

    @get:NotNull
    @JsonProperty("body") val body: kotlin.String,

    @get:NotNull
    @JsonProperty("askMember") val askMember: ApiAskMember,

    @get:NotNull
    @JsonProperty("askAt") val askAt: java.time.OffsetDateTime,
)
package com.m3.docpedia.adapter.restapi.model

import com.fasterxml.jackson.annotation.JsonProperty
import javax.validation.constraints.NotNull

/**
 * 質問を投稿する要求
 * @param title 質問タイトル
 * @param body 質問本文
 * @param askMemberId 質問者ID
 */
data class ApiAskQuestionRequest(

    @get:NotNull
    @JsonProperty("title") val title: kotlin.String,

    @get:NotNull
    @JsonProperty("body") val body: kotlin.String,

    @get:NotNull
    @JsonProperty("askMemberId") val askMemberId: kotlin.Int
)

ControllerServiceImpl

f:id:fukubaya:20200205195223p:plain
ControllerServiceImpl

ControllerServiceImpl の役割は、ControllerServiceで(実際にはOpenAPIで)定義したAPI機能の実装です。 ただし、ControllerServiceImplの役割は、Domain modelを外の層で使うデータ構造 (Api**) に変換してControllerに返したり、 逆にControllerから渡されたデータをDomain modelに変換、加工してUseCaseに渡すまでです。 データの操作は、UseCaseの機能を呼び出し、Controllerへ返すデータへの変換はPresenterの機能を呼び出します。 クラス名は **ControllerServiceImpl と命名しています。

質問の登録は、 ApiAskQuestionRequest から AskQuestionRequest を生成して、 QuestionsUseCaseaskQuestion を呼びます。 質問者が必要なので別途定義されている MemberUseCase で質問者を取得します。

質問の取得は、 QuestionsUseCasegetQuestion を呼びます。 Question はこれより上位の層の形式なので ApiQuestion に変換する必要がありますが、 その変換処理は ApiQuestionPresenter として注入し、実装に依存しないようにします。

package com.m3.docpedia.adapter.restapi.controller.impl

import com.m3.docpedia.adapter.restapi.controller.QuestionsApiControllerService
import com.m3.docpedia.adapter.restapi.model.ApiAskQuestionRequest
import com.m3.docpedia.adapter.restapi.model.ApiQuestion
import com.m3.docpedia.base.exception.NotFoundException
import com.m3.docpedia.domain.model.question.AskQuestionRequest
import com.m3.docpedia.usecase.inputport.MemberUseCase
import com.m3.docpedia.usecase.inputport.QuestionsUseCase
import com.m3.docpedia.usecase.outputport.ApiQuestionPresenter
import org.springframework.stereotype.Component

@Component
class QuestionsApiControllerServiceImpl(
    val questionsUseCase: QuestionsUseCase,
    val apiQuestionPresenter: ApiQuestionPresenter
) : QuestionsApiControllerService {

    override fun askQuestion(apiAskQuestionRequest: ApiAskQuestionRequest) {
        val askQuestionRequest = AskQuestionRequest(
            title = apiAskQuestionRequest.title,
            body = apiAskQuestionRequest.body,
            askMember = membersUseCase.getMember(apiAskQuestionRequest.askMemberId)
        )
        questionsUseCase.askQuestion(askQuestionRequest)
    }

    override fun getQuestion(id: Int): ApiQuestion {
        val question = questionsUseCase.getQuestion(id)
            ?: throw NotFoundException("question(id=$id) is not found")
        return apiQuestionPresenter.toApiQuestion(question)
    }
}

Presenter

f:id:fukubaya:20200205195243p:plain
ApiQuestionPresenter

Presenterの役割は、Domain modelのデータ構造を表示(今回はREST API)のための形式に変換することです。 Domain model⇔Db** の変換は RepositoryImpl 内にそのまま実装されていますが、 Presenterは別途 interface として定義します。 Domain modelとDb**などの変換にはバリエーションはないですが、 Domain modelから出力のための変換は要求する機能によっては1つに限らない (例えば、集計のためのCSVになったり、管理画面向けのデータ構造になったり)からだと思います。

今回はREST APIしかないので、 QuestionApiQuestion に変換するだけです。

package com.m3.docpedia.usecase.outputport

import com.m3.docpedia.adapter.restapi.model.ApiQuestion
import com.m3.docpedia.domain.model.question.Question

interface ApiQuestionPresenter {
    /**
     * 質問をAPIの形式に変換する
     */
    fun toApiQuestion(
        question: Question
    ): ApiQuestion
}

PresenterImpl

f:id:fukubaya:20200205195305p:plain
ApiQuestionPresenterImpl

PresenterImplの役割は、Presenterで定義したDomain modelの表示用のモデルへの変換機能の実装です。

Question はドメインモデルとして必要な形式ですが、実際にユーザ画面のAPIとして欲しい形式はまた違います。 例えば、Question の本文は普通のテキストですが、 ApiQuestion の本文はHTMLです。 したがって、改行を <br/> に変換したり、HTMLエスケープしたり、URLを <a> タグに変換したりする必要があります。 また、 Member には質問者の名前や生年などが含まれていますが、 ApiAskMember では表示時点での質問者の年代(20代、30代など)さえあればよいので、現在時刻から計算して変換します。 これが管理画面のAPIであればまた違うPresenterと実装が必要です。

package com.m3.docpedia.adapter.restapi.presenter

import com.m3.docpedia.adapter.restapi.model.ApiAskMember
import com.m3.docpedia.adapter.restapi.model.ApiQuestion
import com.m3.docpedia.base.service.StringService
import com.m3.docpedia.domain.model.member.Member
import com.m3.docpedia.domain.model.question.Question
import com.m3.docpedia.domain.repository.DateTimeRepository
import com.m3.docpedia.usecase.outputport.ApiQuestionPresenter
import org.springframework.stereotype.Component

@Component
class ApiQuestionPresenterImpl(
    val dateTimeRepository: DateTimeRepository
) : ApiQuestionPresenter {
    override fun toApiQuestion(
        question: Question
    ): ApiQuestion {
        return ApiQuestion(
            id = question.id,
            title = question.title,
            body = StringService.toSafeHtmlAndConvertUrlLink(question.title),
            askMember = toApiAskMember(q.askMember),
            askAt = question.askAt
        )
    }

    /**
     * 質問者をAPIの形式に変換する
     */
    fun toApiAskMember(member: Member): ApiAskMember {
        // 質問者の表示時点の年代を生年から計算するために必要
        val now = dateTimeRepository.now()
        TODO("memberをApiAskMemberに変換する")
        return ApiAskMember(...)
    }

なお、「現在時刻の取得する」処理も DateTimeRepositoryinterface として定義することで、 この処理が現在時刻取得の実装(システムの時刻を取得する)に依存しないようにしています。 実行した時の時刻に依存しなくなるので、ユニットテストが書きやすくなります。 Repository の命名は適切ではないかもしれませんが、外からデータを取ってくる、 という意味ではDBや外部APIと同等なのでこのような命名にしています。

よかった点

とにかくテストが書きやすい

テストは各 interface の 実装である Interactor, RepositoryImpl, ControllerServiceImpl, PresenterImpl のそれぞれに対して書くことになります *6が、 それぞれの責務を意識すると、テスト項目が自然と決まるので迷いなくテストを書くことができます。

また、上層から下層には依存していないので、複雑なmockを書く必要がありません。 例えば、QuestionsInterfactor では、 QuestionsRepository だけをmockすればよく、 その先のDBの動作のmockまでする必要がありません。

機能変更、追加時にどこを変えるかが明確になる

それぞれの責務を理解していると、機能変更、追加の際にどこを直すべきか、どこは直さなくていいか、を判断しやすくなります。 これは実装する人だけでなく、コードレビューする側も「この機能追加でここを変更するのはおかしい」といった指摘がしやすくなります。

課題

Domain modelの設計

Domain modelには、何となく外側の層とは独立した概念をモデル化しておけばよさそう、 と思って設計しましたが、このDomain modelの設計も注意が必要でした。

例えば、Question がさらに質問者として Member を持っています。 Member はユーザの名前から生年、診療科など多くの情報を持っていますが、一覧画面、詳細画面、管理画面で必要な情報は異なります。 Member の一部しか使わないのに、毎回 Member の全情報を取得しなければならないのはコストが高いです*7

Domain model(とRepository)を参照用と更新用に分け(CQRS)、 さらに参照用もUseCaseに応じてモデルをそれぞれ用意することも検討すべきだと思いました。 更新(AskQuestionRequest) と参照(Question) は分かれていましたが、Question はUseCaseに応じてさらに 分離した方がいいかもしれません。 以下の記事が参考になります。

little-hands.hatenablog.com

Domain modelの生成は誰の役割か?

QuestionsServiceControllerImpl 内で、 ApiAskQuestionRequest からDomain modelである AskQuestionRequest を生成しています。 これはUseCaseの役割ではないか、との指摘がありました。 少なくとも出力に関しては Presenter の役割になっているので、生成もUseCaseか別の層に役割を分離するのは自然だと思います。

IDEなどでpackageの並びが直観的でない

慣れればそれほど問題にならないのですが、IDEなどではpackage辞書順にファイルが並ぶので、 階層とpackageの位置が必ずしも一致しません。どれがどこにあるかがちょっと分かりにくいです。 packageの命名と、どの層をどこに入れるかは再考の余地があります。

com
└── m3
    └── docpedia
        ├── adapter
        │   ├── gateway
        │   │   ├── DateTimeRepositoryImpl.kt
        │   │   ├── QuestionsRepositoryImpl.kt
        │   │   ├── db
        │   │   │   ├── MemberMapper.kt
        │   │   │   ├── QuestionMapper.kt
        │   │   │   └── model
        │   │   │       ├── DbMember.kt
        │   │   │       └── DbQuestion.kt
        │   │   └── elasticsearch
        │   │       └── model
        │   │           ├── EsAnswer.kt
        │   │           └── EsQuestion.kt
        │   └── restapi
        │       ├── controller
        │       │   ├── QuestionsApiController.kt
        │       │   ├── QuestionsApiControllerService.kt
        │       │   └── impl
        │       │       └── QuestionsApiControllerServiceImpl.kt
        │       ├── model
        │       │   ├── ApiAskMember.kt
        │       │   ├── ApiAskQuestionRequest.kt
        │       │   └── ApiQuestion.kt
        │       └── presenter
        │           └── ApiQuestionPresenterImpl.kt
        ├── domain
        │   ├── model
        │   │   ├── member
        │   │   │   └── Member.kt
        │   │   └── question
        │   │        └── Question.kt
        │   └── repository
        │       ├── DateTimeRepository.kt
        │       ├── MemberRepository.kt
        │       └── QuestionsRepository.kt
        └── usecase
            ├── inputport
            │   ├── MembersUseCase.kt
            │   └── QuestionsUseCase.kt
            ├── interactor
            │   ├── MembersInteractor.kt
            │   └── QuestionsInteractor.kt
            └── outputport
                └── ApiQuestionPresenter.kt

Domain modelの命名も統一した方がよかった?

各層のモデルの命名を Db**, Es**, Api** など接頭辞を統一して、どの層に属するモデルか分かりやすくしたのですが、 最上位のEntitiesに属するDomain modelは最上位だから接頭辞がない方がそれっぽい、と思ってpackageだけ揃えたのですが、 やはりコード上は Domain** などの接頭辞があった方が層を意識しやすかったと思います。

We are hiring!

Docpediaは正式リリースされましたが、まだまだこれからが本番です。 一緒に開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。

open.talentio.com

jobs.m3.com

*1:「本当の意味で」何が正解なのか今でもよく分かりません

*2:すでに公開前の社内レビューでもたくさん指摘をいただきました。

*3:徹底するなら質問IDを表すモデル、型も定義すべきですがやりすぎになるのでそのまま Int としています。質問IDに間違って回答IDとかユーザIDの別の値を渡してしまう可能性が高まったら分けてもいいと思います。

*4:Spring 4.3 以降では コンストラクタ・インジェクションでは @Autowired を書かなくてもよいです

*5:当初、java.sql.Date を例に挙げていましたが java.util.Date とほぼ同じなんですね。知らなかった。

*6:ただし実際のDB操作をするMappperのテストだけは実際のDBに接続してテストが必要です。Controller側は自動生成されたコードで、ある程度動作は保証されているのでテストは書いていません。

*7:Memberは頻繁には変わらないのでキャッシュを持つ、という方法で逃げました。