こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。
これまでは、中村の記事で宣言した 「医師版Stack Overflow」(12/16に正式名称Docpediaとしてリリースされました) の技術的チャレンジの 記事を続けて書いていたのですが、今回はここで宣言しなかったClean Architectureについて書きます。
Clean Architecture 達人に学ぶソフトウェアの構造と設計 (アスキードワンゴ)
- 作者:Robert C.Martin,角 征典,高木 正弘
- 出版社/メーカー: ドワンゴ
- 発売日: 2018/08/01
- メディア: Kindle版
なぜ書くのか
参考にできる実例を増やしたい
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を守っていれば必要に応じて何層になっていてもよいです。
依存性逆転の原則(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層以上あります。
UseCase
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
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
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
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
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.Question
と com.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)側から見ていきます。
Controllerの役割は、HTTPでJSONを受け取り、デシリアライズして、ControllerServiceに渡すこと、 さらに、ControllerSeriviceでの処理の結果をシリアライズしてHTTPでクライアントに返すことです。 デシリアライズしたオブジェクトを使った処理は ControllerService の実装に任せます。 なお、ここで言うControllerは、SpringのControllerと同じで、Clean ArchitectureでControllerと呼ぶものとは一致しません。
QuestionsApiController
が実際にWeb側からのリクエストを受けつけて、
QuestionsApiControllerService
で定義されたメソッドを呼び、結果を返します。
OpenAPI Generatorでの生成時に serviceInterface=true
を指定しているので、
QuestionsApiControllerService
は interface
で、ここでは 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
ControllerServiceImpl の役割は、ControllerServiceで(実際にはOpenAPIで)定義したAPI機能の実装です。
ただし、ControllerServiceImplの役割は、Domain modelを外の層で使うデータ構造 (Api**
) に変換してControllerに返したり、
逆にControllerから渡されたデータをDomain modelに変換、加工してUseCaseに渡すまでです。
データの操作は、UseCaseの機能を呼び出し、Controllerへ返すデータへの変換はPresenterの機能を呼び出します。
クラス名は **ControllerServiceImpl
と命名しています。
質問の登録は、 ApiAskQuestionRequest
から AskQuestionRequest
を生成して、
QuestionsUseCase
の askQuestion
を呼びます。
質問者が必要なので別途定義されている MemberUseCase
で質問者を取得します。
質問の取得は、 QuestionsUseCase
の getQuestion
を呼びます。
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
Presenterの役割は、Domain modelのデータ構造を表示(今回はREST API)のための形式に変換することです。
Domain model⇔Db**
の変換は RepositoryImpl
内にそのまま実装されていますが、
Presenterは別途 interface として定義します。
Domain modelとDb**
などの変換にはバリエーションはないですが、
Domain modelから出力のための変換は要求する機能によっては1つに限らない
(例えば、集計のためのCSVになったり、管理画面向けのデータ構造になったり)からだと思います。
今回はREST APIしかないので、 Question
を ApiQuestion
に変換するだけです。
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
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(...) }
なお、「現在時刻の取得する」処理も DateTimeRepository
に interface
として定義することで、
この処理が現在時刻取得の実装(システムの時刻を取得する)に依存しないようにしています。
実行した時の時刻に依存しなくなるので、ユニットテストが書きやすくなります。
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に応じてさらに
分離した方がいいかもしれません。
以下の記事が参考になります。
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は正式リリースされましたが、まだまだこれからが本番です。 一緒に開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。
*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は頻繁には変わらないのでキャッシュを持つ、という方法で逃げました。