フロントエンドエンジニアの国定です。
この記事では、TypeScript + Vue.js で開発しているフロントエンドに今年からドメイン駆動設計(DDD)を取り入れ始め、ひとまず設計が落ち着いてきたのでその経緯とアーキテクチャについて解説します。
課題
Vuex(Store)の責務は、エラー判定などのドメインロジック・データの永続化・API の呼び出しなど、State 管理のほかにも多岐にわたっています。UI の改善や機能追加など変化の多いフロントエンドでは開発が進むにつれ Vue + Vuex のあちこちに同じ処理が散在してしまいます。
テックタッチでもメンテナンスを繰り返す度にコードの複雑さが増し、機能追加や修正に時間がかかるようになってきました。去年から少しずつ改善を進め Vue や Vuex 周りは整理されてきましたが、複雑な UI の状態管理にはフロントエンド特有の概念を整理していくことが重要だと考え、ドメイン駆動設計の実装パターンを導入し始めました。
アーキテクチャ
この図は基本的な処理の流れを表現したものです。新規機能作成時はこのアーキテクチャに従って開発を進めています。クリーンアーキテクチャに倣った構成になっていますがこの記事ではクリーンアーキテクチャの説明は割愛します。
今回デモ用に作った ToDo リストのソースコードはこちら。
GitHub - 92thunder/clean-architecture-todo
Domain
ドメインの関心ごとはすべてこの層に記述していきます。上記の図では Service からしか触らないような書き方になっていますが、Store や UI などでも、コンポーネントの表示条件などが必要になるので適宜 import して使います。
// types.ts export type TaskState = 'TODO' | 'DONE' export type Task = Readonly<{ title: string description: string state: TaskState }> // utils.ts export function createTask(title: string): Task { if (!title) { throw new Error('タイトルの形式が不正です') } return { title, description: '', state: 'TODO' } } // reducers.ts export function changeState(task: Task, taskState: TaskState): Task { return { ...task, state: taskState } }
型は types, プロパティの変更は reducers, そのほかの処理は utils として記述しています。 TypeScript の Readonly を徹底することでオブジェクトがドメイン以外の部分で変更されることを防ぎます。クラスで表現すると、Tree Shaking で落としてくれないなどの問題があるため、型+関数ですべて表現するようになっています。
Vuex や Redux などの Store の中にドメインの複雑さを閉じ込めようとしている記事もよく見かけますが、ドメインレイヤの分離・特定フレームワークへの依存をしたくなかったので Domain 層として分離することを選びました。
ドメインオブジェクトを DTO(Data Transfer Object)のように扱うことになるので、オブジェクト指向のアンチパターンのようにも見えますが、これによって Vuex の state としてクラス⇔オブジェクトを変換せず扱うことができるため、結果的に変換ロジックを入れずに済むため Flux で DDD をやりにくかった部分を解消できていると考えています。
Service
いわゆるアプリケーションサービス層で、Domain と Repository などのアプリケーションに必要な永続化やAPI呼び出しを使ってユースケースを実現します。
export class TaskService { repository: ITaskRepository constructor(repository: ITaskRepository) { this.repository = repository } addTask(title: string): Task { try { // Domain/utilsでtask作成 const task = createTask(title) // Repositoryを使って保存する const tasks = this.repository.add(task) return tasks } catch (e) { throw e } } ... }
ソースコードとしては外の層に依存していませんが、addTask の戻り値が Store のことを意識してしまっているため、厳密にはクリーンアーキテクチャとは言えないかもしれません。 たとえば Store や UI に変更が必要になった時、Service にも変更が必要になることがあるでしょう。
Store(Vuex)
UI の State 管理が主な責務になります。 ドメインロジックや Repository の呼び出しがないため、ほかの Vuex Module の呼び出しなどの State 管理に集中できます。
// Vuex Actions constructor() { super() this.taskService = new TaskService(new TaskRepository()) } addTask(title: string) { try { // 永続化などアプリケーションとしての仕事はApplicationServiceに任せる const tasks = this.taskService.addTask(title) // Serviceの返り値をStoreに反映 this.commit('updateTasks', tasks) } catch (e) { // エラーがあればUIに反映する this.flash.dispatch('showFlash', e.message) } }
基本的な処理は UI からの命令を受け取り、Service から返ったデータを State に反映し、 Getters を経由して UI にデータが伝搬されるだけなので細かい説明は割愛します。 Vuex + TypeScript をサポートするライブラリとして vuex-smart-module を使用しているので、Actionsはクラスとして定義しているため、コンストラクタで Service を注入しています。
UI(Vue.js)
クリックイベントなどを受け取って Store に伝え、state・getters を経由して UI に状態を反映するだけなので Vue.js の基本的な使い方と変わりません。
Vue.js や React を使っているとドメイン知識が UI のあちこちに散在しがちですが、ドメインの関心ごとは適切に移動しましょう。
軽量DDDに陥らないために
軽量 DDD とは、ドメイン駆動設計で語られる技術的な実装パターンのみを取り入れる設計手法です。 現状に満足せずに複雑さを増していくドメインにきちんと向き合いながらよりよいソフトウェアにしていきたいですね。
弊社ではプロダクトデザインチームからも UI 的に、Biz チームからも顧客への説明的に会社全体で用語を統一していきたいよね、という流れ(ユビキタス言語の策定)が盛り上がってきているため、開発チームとしてもドメイン駆動設計をやりやすい雰囲気になってきていると感じます。
まとめ
紹介したアーキテクチャはまだシステムの一部分でしか実装できていませんが、以前の Vuex の Actions になんでも詰め込んでいた時のコードに比べてはるかに読みやすくなっています。
もちろん小規模なシステムではこのようなアーキテクチャは不要で、Flux アーキテクチャにしたがって開発するだけで十分でしょう。
フロントエンドでしか存在しない概念や複雑な UI インタラクションをあるべき場所にきれいに書くために、永くソフトウェアを発展させ続けるために、 Web フロントエンドでもドメイン駆動な開発を導入してみてはいかがでしょうか。