この記事はドメイン駆動設計 #1 Advent Calendar 2018 19日目の記事です。
何かとDDDの話はサーバサイドエンジニアの話として取り上げられます。 それもそのはず、サービスの根幹となるビジネスドメインのロジックはサーバサイド上組まれる事が多い事にあると思います。 ではクライアントサイドは関係ないのかというとそんな事はなくて、Layered Architectureのように部分的にDDDの戦術的なエッセンスを導入する事はさることながら、サーバAPIがGraphQL、RESTful API、CQRS+ESなアーキテクチャであれば同様にそれらに適した設計を検討する必要があります。ましてや、ゲーム開発ではより多角的に全く別のアプローチを取る事があります。 つまり、開発するクライアントそのものの要件、規模、周辺環境(サーバサイド周辺技術等)の進化に合わせて、現在適用している設計パターンそのものを蒸留し、戦略的に進化させ続ける必要があります。問題領域(ドメイン)はどこにでもあるし、それは技術的なフレームワークにも当てはまります。Evans本16章、17章あたりの話です。
一方で、構築するサービス全体を俯瞰して捉えた場合、クライアントはサーバサイドから見てDTOを橋渡し役として境界が分かれた状況にあります。 現実的な話をすれば、蒸留という行為自体独立して必要に応じてやればいい、やっていくだけという話なのですが、それで結論づけてしまうと元も子もないのと、必要なタイミングで懐刀がないというのはよろしくないですし、クライアントアプリケーションをよりより素早く、より漸進的な設計で形にするヒントが隠されているかもしれません。
まだ検証段階なのですが(本当はAdvent Calendarに間に合わせたかったけど無理があった)、クライアント設計パターンの1つである所のVIPERを蒸留することでいくつか見えてきた事があるので、書き残しておきます。
DDD関連用語: 蒸留、宣言的設計、進化する秩序、戦略的設計
この話を始める前に、クライアントアプリケーション設計パターンに触れる必要があります。 MVP(+Passive View or Supervising Controller)、MVVM、MVI、Flux、VIPERといった近年よく見られるスマートフォンアプリ開発で採用される設計パターンはいずれもメリット/デメリットがあり、規模や開発チームの習熟度、作成するアプリケーションのビジネスの方向性に合わせて適用される必要があり、これが絶対というものはありません。共通して言えることは、特定のclassないしfunctionに責務を与え、ソースコードに秩序をもたらしている、ということです。
今回はVIPERをベースに書きます。 余談ですが、個人的には、VIPERパターンを採用する事が多いです。理由としては、実装が面倒と思われてしまう箇所があるものの、素朴であり、VIPERパターンを実現するフレームワーク実装のソースコードは0byte(つまり不要)、現代のGUIアプリケーション開発の現状を踏まえた上で最低限の責務分離がされている事、クライアント上で必要な要件がinterfaceとして可視化されている事、且つ未来に向けての秩序の進化をしやすい、といった所です。蒸留とミニマリズムはどんな優れた設計にも欠かせないものですが、戦略的設計にとってはミニマリズムはより一層重要です(Evans本第17章より)。
VIPERパターンではMVP + Passive ViewにおいてP(Presenter)で曖昧に責務を持っていたApplication LogicをI(Interactor)に、そして画面遷移にまつわる責務をR(Routing)に持たせ、Presenterをglue layerに特化し、かつこれら一連の責務をContract interfaceとして定義します。 Presenterは時としてViewの代わりにViewModelを持つ事がありますが、いずれにしてもUIの用語は入りません。 Routingについては本記事では省略します。
VIPERパターンの弱点として、処理フローを担保していないというものがあります。 その為、Presenterの実装についてはやはり開発者を慣れるまで悩ませる事になります。Application LogicやUI logicをPresenterに記述してしまったり。 単一方向の処理フローに着目したFluxやMVIが流行る理由も分かるなあという気持ちです。
そんなVIPERパターンですが、大規模なアプリケーションにおいてVIPERを軸にコーディングガイドラインを設け、コードレビューやチーム内での議論を通して真摯に向き合った結果、Presenter methodの実装は100%テンプレコードになっています。
// Rxの場合
interactor.doSomething()
.subscribeOn(/**/)
.observeOn(/**/)
.subscribe(view::render, view::render)
.addTo(disposables)
// Kotlin Coroutinesの場合
launch {
runCatching { withContext(/**/) { interactor.doSomething() } }
.onSuccess(view::render)
.onFailure(view::render)
}
Interactorは引数を元に結果を返すだけになり、Viewは与えられた引数、あるいは(上記コードをViewModelを利用する形に置換した上で)ViewModelの変更を購読し描画するだけになります。
これは大きな成果で、Presenter(glue layer)の実装は機械的に生成できる事を意味しています。 では、何を軸にPresenterを機械的に生成したら良いのでしょうか。
ここで着目したのはSide-Effect-Free FunctionやCQSの考え方です。 問い合わせと変更を分離して副作用の心配を取り除くのが同パターンの趣旨ではありますが、 これを逆に考えて、CommandとQueryからクライアント設計の契約(Contract)を導きだす事ができないかというのを考えました。
例えば(一部省略しつつも)以下のようなCommandとQueryの定義を書いたとします。
interface ProductsDefinition {
data class Product(
val productId: ProductId,
val title: ProductTitle,
val price: Price,
val description: Description
)
sealed class PurchaseProductEvent(
private val productId: ProductId
) {
data class Purchased(productId: ProductId) : PurchaseProductEvent(productId)
data class Unavailable(productId: ProductId) : PurchaseProductEvent(productId)
}
class AllProducts(): Query<List<Product>>
class ProductsByKeyword(keyword: Keyword): Query<List<Product>>
class Purchase(productId: ProductId): Command<PurchaseProductEvent>
}
上記コードを読む際に気をつけて欲しいのは、PurchaseProductEventはUI domainのDomainEventである点です。 UI DomainのDomainEventとBusiness DomainのDomainEventは一致しません。 いっそStateと呼んだ方が最近の言葉遣いにはあってるかもなーと思いつつこのままにしておきます。
上記のDefinitinon interfaceから、以下のコードを生成できます。(命名はやりすぎ感が否めませんが…)
interface ProductsContract {
interface View {
fun render(products: List<Product>)
fun render(event: PurchaseProductEvent)
fun render(e: Throwable)
}
interface QueryHandler {
suspend fun whenever(query: AllProducts): List<Product>
suspend fun whenever(query: ProductsByKeyword): List<Product>
}
interface CommandHandler {
suspend fun whenever(command: Purchase): PurchaseProductEvent
}
interface Interactor: QueryHandler, CommandHandler
interface Presenter {
fun given(query: AllProducts)
fun given(query: ProductsByKeyword)
fun given(command: Purchase)
}
interface Routing
}
class PurchasePresenter(
ui: CoroutineDispatcher,
private io: CoroutineDispatcher,
view: View,
queryHandler: QueryHandler,
commandHandler: CommandHandler,
routing: Routing
): PurchaseContract.Presenter, CoroutineContext {
private val job: Job = Job()
override val coroutineContext: CoroutineContext = job + ui
override fun given(query: AllProducts) {
launch {
runCatching { withContext(io) { queryHandler.whenever(query) } }
.onSuccess(view::render)
.onFailure(view::render)
}
}
override fun given(query: ProductsByKeyword) {
launch {
runCatching { withContext(io) { queryHandler.whenever(query) } }
.onSuccess(view::render)
.onFailure(view::render)
}
}
override fun given(command: Purchase) {
launch {
runCatching { withContext(io) { commandHandler.whenever(command) } }
.onSuccess(view::render)
.onFailure(view::render)
}
}
}
アプリ開発者はDefinitionと、生成されたContract、Presenter実装クラスを元にInteractor、Viewを実装します。
さらに、ここでInteractorに着目します。 Interactorは上記コードにおいてQueryHandler、CommandHandlerを実装していますが、これらを分離する事を考えてみます。 CQRS+ESアーキテクチャにおけるAPI Response(DTO)は、クライアントにとってView Aggregateとなる事が期待されます。 同じく、GraphQLやその他RPCにおける問い合わせ(Query)の結果も、View Aggregateをサーバサイドとクライアントサイドとで合わせて秩序をもたらす事を期待されています。 これを前提とした場合、これらの技術スタックを選定したクライアントのQueryHandlerの実装クラスは、IDLやAPI Clientを元に自動生成ができるのではないでしょうか。なぜなら、もうサーバサイドでView Aggregateを作ってくれているからです。 仮にキャッシュを挟むにしても、View Aggregateが正しく作られている状況であれば、クライアントはそれをそのまま使う以外の道はないので、さほど難しい事にはならないはずです。
ここまで理想的に運ぶと、クライアントも同様に本来の役割である所のUI開発と、CommandHandlerの実装に集中できます。
実際そんなにうまくいくはずは無く、特にQueryHandler実装クラスの自動生成の話については、Firebaseに並ぶBaaS APIとの併用も考えられるので、現実問題厳しいと考えてます。そこで引き続き人間が実装するとなると、QueryHandlerとCommandHandlerを分離するメリットも薄く、返って難解な設計に受け取られてしまうため、結果として分離しない選択肢を取る事も致し方ないと思います。
ただ、今回紹介したView Aggregate、Event(State)、Query、CommandからクライアントアプリのContract interfaceとクライアント実装の一部を生成をする、という選択肢がとてもしっくり来ているので、もう少し深掘りたい所です。これを実現するソリューションを趣味で開発中で、Advent Calendarに間に合わせたかったのですが、間に合いませんでした。残念…。
今回はVIPERを題材に検討しましたが、他の設計パターンやアーキテクチャにおいてもそのものを蒸留し洗練された設計パターンが生まれる事を期待しています。すべてを語り切れてない上に多くの誤解を生みそうな記事になってしまいましたが、以上です。