この記事で書いていることを要約すると
どうすればドメイン駆動設計を段階的に取り入れられるのかVueのサンプルプロジェクトを作りながら考えてみた
ということになります。
考えるに至った経緯
最近 NUXT(typescript)とFirebaseでファイヤーエンブレム風ゲーム作ってみた で自分のコードが汚いということに気付き、ドメイン駆動設計を学んでみることにしました。
そして学び、実践してみると確かにコードが綺麗で読みやすくなったなという実感がありました。
そこでせっかくなので、仕事でもぜひ使ってみたい!と思ったのですが
- ドメイン駆動設計(コードのみの話なので正確には軽量DDD。詳しくは後述)ってどうやってフロントエンドフレームワークに適応したらいいんだろう?
- 自分が仕事で関わっているプロジェクトではドメイン駆動設計を導入できないけど、そのエッセンスを部分的にでも活用することはできないのだろうか?
といった疑問を抱えることとなりました。
今回は下記教材を用いてドメイン駆動設計を学びましたが、教材内のサンプルプロジェクトの言語はどれもC#なので、フロントエンドのフレームワークにどのように適応すればいいのかもよく分かりませんでした。
- C#でドメイン駆動開発パート1【C#でドメイン駆動開発とテスト駆動開発を使って保守性の高いプログラミングをする方法】
- ドメイン駆動設計の入門 ボトムアップでわかる!ドメイン駆動設計の基本
- エリック・エヴァンスのドメイン駆動設計
『エリック・エヴァンスのドメイン駆動設計』に関しては半分ぐらいしか読めておりません。。
そんな中、下記の記事は非常に参考になりました。
→ Vuex + DDDのアーキテクチャを考える
これでなんとなくフロントエンドでもどうやってドメイン駆動設計を適応していけばいいのかイメージがつかめたものの、だからといってすぐに自分の案件に適応できるのかというとそうではなく。
なぜ自分の関わっているプロジェクトにドメイン駆動設計が適応できないのかについてですが、その前に簡単に自分が関わっている案件の説明をしておくと、
- 主にフロントエンド開発(ReactとかVueとかAngularとか)
- 常駐エンジニア
- 数ヶ月〜半年レベルのプロジェクトがほとんど
- プロジェクトに関わる人数は基本的に少人数(1〜3人ぐらい)
- 開発フェーズとしてスクラッチから機能追加までいろいろ
という感じです。要するに私が関わる案件はスクラッチ開発だったり機能追加することが多いのですが
- スクラッチ開発→プロジェクトがどの程度大きくなるのか分からず、いきなりドメイン駆動設計を100%適応しようという気にならない
- 機能追加→既存のプロジェクトにドメイン駆動設計が適応されていることはほぼ無く、これまでの書き方を無視していきなりドメイン駆動的には書きづらい
と、どちらにせよドメイン駆動設計をいきなり完璧に適応することはできません。特に大規模な既存のフロントエンドプロジェクトでドメイン駆動設計が適応されていないケースに関しては、途中から適応しようとすると相当なリソースが必要になるためハードルは非常に高いです。(てか無理)
人によっては「いや、そんな状況だったら導入しない方がいいのでは?」と思う人もいるかもしれませんが、個人的には亜型であったとしてもドメイン処理がviewから引き剥がされてた方が分かりやすく、変更が簡単なコードになると考えています。
なので、今回はまだ希望のあるスクラッチ開発で、どうすれば(完璧でなくても)ドメイン駆動設計を適応することができるのか、考えてみました。
具体的にはサンプルプロジェクトとしてTODOアプリを作成し、その規模が大きくなるにつれてよりドメイン駆動設計が適用されていき、どのようなメリットがあるのか書いてみました。
ドメイン駆動設計とは?
「そもそもドメイン駆動設計ってなに?」という方向けに。
下記の記事がドメイン駆動設計の概要を学ぶのに分かりやすかったです。
→わかった気になるDDD入門記事まとめ
先にお伝えしておくと、当記事の内容は基本的なドメイン駆動開発の内容しか書いていません。ただ、エンティティ、アプリケーションサービス、リポジトリーの三つは理解しておいた方が分かりやすいかと思います。
また、ここまでドメイン駆動設計という言葉を使ってきましたが、この記事で書いていることは正確に言うと軽量DDDと呼ばれる、ドメイン駆動設計の中でも詳細設計以降の技術的な実装パターンの話になります。(タイトルややこしくてすみません)
ちなみに、そもそも戦略的なドメイン駆動設計の部分を省き戦術的な軽量DDDだけやっても意味はないという意見もあるようです。
ですがドメイン駆動設計を学び、実際に軽量DDDを実践してみた結果、コードが綺麗になったなという実感がありましたので、個人的には軽量DDDだけでも実践する価値はあると思っています。
フロントエンドでどうやってちょっとずつドメイン駆動設計を適応するのか
それでは実際にサンプルコードを交えつつTODOリストアプリをみんな大好きVueで作成しながら解説していきます。
まずは最初のフォルダー構成がこちら。
src/
├ App.vue
└ components/
└ Top.vue
(必要なファイル構成のみ表示しています)
<template>
<div id="app">
<Top />
</div>
</template>
<script>
import Top from "./components/Top.vue";
export default {
name: "App",
components: {
Top,
},
};
</script>
<template>
<div class="container">
<ul>
<li v-for="(todo, index) in todoList" v-bind:key="index">
<input type="checkbox" />
<label for="checkbox">{{ todo }}</label>
</li>
</ul>
<input v-model="text" type="text" />
<button @click="registerTodo">register</button>
</div>
</template>
<script>
export default {
name: "HelloWorld",
data: function () {
return {
todoList: [],
text: "",
};
},
methods: {
registerTodo() {
this.todoList.push(this.text);
this.text = "";
},
},
};
</script>
ドメイン駆動開発の話にフォーカスしたいので、UIとか処理は適当です。
現状としてサーバー無し、ローカルストレージも使っておらず、リフレッシュしたら登録したタスク全部消えるような状態です。
エンティティ
初めにTodoタスクに下記のような仕様が加わったとします。
- 文字は3文字以上で20文字以内
- 特定の言葉を省く(「アホ」とか「バカ」とか)
- 登録できるTodoは10個まで
ここでやりがちなのが、「利口なUI」ということで下記のような実装でしょう。
<template>
<div class="container">
<ul>
<li v-for="(todo, index) in todoList" v-bind:key="index">
<input type="checkbox" />
<label for="checkbox">{{ todo }}</label>
</li>
</ul>
<input v-model="text" type="text" />
<button @click="registerTodo">register</button>
</div>
</template>
<script>
export default {
name: "HelloWorld",
data: function () {
return {
todoList: [],
text: "",
};
},
methods: {
registerTodo() {
// 追加
if (
this.todoList.length >= 10 ||
/(アホ|バカ)/.text(this.text) ||
this.text.length < 3 ||
this.text.length > 20
)
return;
this.todoList.push(this.text);
this.text = "";
},
},
};
</script>
<style scoped>
.container ul {
list-style: none;
}
</style>
でもこうすると、このvueファイルには
- UIに関する処理
- ドメインに関する処理
の二つが混ざってしまい、他のページでもTodoに関する処理を書こうとすると同じ処理をもう一度書かなければならなくなります。
ということでドメイン駆動設計でいうEntity単位でファイルを分けていきます。
// 追加
var TodoList = function () { this.value = [] }
TodoList.prototype.add = function(text) {
if (
this.value.length >= 10 ||
/(アホ|バカ)/.test(text) ||
text.length < 3 ||
text.length > 20
)
return false;
this.value.push(text)
return true
}
export default TodoList
<template>
<div class="container">
<ul>
<li v-for="(todo, index) in todoList.value" v-bind:key="index">
<input type="checkbox" />
<label for="checkbox">{{ todo }}</label>
</li>
</ul>
<input v-model="text" type="text" />
<button @click="registerTodo">register</button>
</div>
</template>
<script>
import TodoList from "../helper/todo.js";
export default {
name: "HelloWorld",
data: function () {
return {
// 修正
todoList: new TodoList(),
text: "",
};
},
methods: {
registerTodo() {
// 修正
if (this.todoList.add(this.text)) this.text = "";
},
},
};
</script>
現在のフォルダー構成はこんな感じ。
src/
├ App.vue
├ components/
│ └ Top.vue
└ helper/
└ todo.js
ここで「なんでフォルダー名helperなの?」と違和感もたれた方もいるかもしれませんが、正直フォルダー名にどのような名前をつけるかというのが一番悩みました。。。
もうプロジェクト単位でガッツリとドメイン駆動設計導入してるなら、DomainってフォルダーにEntityとかValueObjectとかって名前のフォルダーを突っ込んでいったらいいと思います。
ただ、それだとドメイン駆動設計のドの字も知らない人からしたら「Domainってなに(´;Д;`)」ってなりかねません。
なので、妥協案として「helperぐらいなのかな..」って感じで付けてます。(ベターな名前がある人ぜひご共有頂ければ。。)
【2020/12/14日 追記】
社内の方と話していて Model というフォルダー名が一番適切なのでは、と考えています。
Model View ViewModel - Wikipedia
フロントエンド フレームワークはアーキテクチャーとしてMVVMが採用されていますが、よくよく考えたらまさにこれって Model の部分だなと。
Model というとRuby on Rails とかLaravel使いだと「データベースの処理を行うファイル」と思うこともあるため、万人受けする名前なのかというと微妙かもしれませんが、helperよりよっぽどマシかと思いますので。
(追記終わり)
こうしてドメインを別ファイルに分けることで、いざユーザーからの要望で複数ページでTodoの管理を行いたくなったとしてもサクッと対応することができます。
もしTodoの管理を別々に行いたいのであれば各ページで new TodoList()
すればOK。
<template>
<div class="container">
<div>サブページ</div>
<ul>
<li v-for="(todo, index) in todoList.value" v-bind:key="index">
<input type="checkbox" />
<label for="checkbox">{{ todo }}</label>
</li>
</ul>
<input v-model="text" type="text" />
<button @click="registerTodo">register</button>
</div>
</template>
<script>
import TodoList from "../helper/todo.js";
export default {
name: "subpage",
data: function () {
return {
// Topページと同じ
todoList: new TodoList(),
text: "",
};
},
methods: {
registerTodo() {
if (this.todoList.add(this.text)) this.text = "";
},
},
};
</script>
src/
├ App.vue
├ components/
│ └ Top.vue
├ pages/
│ └ Sub.vue
│
└ helper/
└ todo.js
もし、画面間で共通の状態を保持したいという場合、vuexを使うことになりますが、その場合はvuexの形式にあった形にドメイン処理を変換してやる必要があります。
export default ({
namespaced: true,
state: {
value: [],
},
getters: {
value(state) {
return state.value
},
},
mutations: {
add(state, payload) {
state.value.push(payload)
},
},
actions: {
add({ commit, state }, payload) {
if (
state.value.length >= 10 ||
/(アホ|バカ)/.test(payload) ||
payload.length < 3 ||
payload.length > 20
)
return false
commit('add', payload)
return true
},
},
})
import Vue from 'vue';
import Vuex from 'vuex';
import todoList from './todoList.js'
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
todoList
}
});
<template>
<div class="container">
<div>トップページ(<router-link to="/sub">サブページへ</router-link>)</div>
<ul>
<li v-for="(todo, index) in todoList" v-bind:key="index">
<input type="checkbox" />
<label for="checkbox">{{ todo }}</label>
</li>
</ul>
<input v-model="text" type="text" />
<button @click="registerTodo">register</button>
</div>
</template>
<script>
export default {
name: "top",
data: function () {
return {
text: "",
};
},
computed: {
todoList: function () {
return this.$store.getters["todoList/value"];
},
},
methods: {
async registerTodo() {
if (await this.$store.dispatch("todoList/add", this.text)) this.text = "";
},
},
};
</script>
<style scoped>
.container ul {
list-style: none;
}
</style>
src/
├ App.vue
├ components/
│ └ Top.vue
├ pages/
│ └ Sub.vue
└ store/
├ index.js
└ todoList.js
ちなみにですが、Todo一つ一つをValueObjectととして表現することもでき、その場合は3文字以上、20文字以内という制限はValueObject側に移行することになります。
ただ、個人的にはこの段階でValueObjectを適応するのはあまりにやり過ぎかなと思いやっていません。
あと、javascriptがオペレーターのオーバーロードに対応していないので、そもそもjavascirptでValueObjectって表現しにくいと考えてあまり使ったことがありません。
const task1 = new Todo('家の掃除')
const task2 = new Todo('お買い物')
task1 === task2 // これをするための機能が提供されていない
なんとかオーバーロードしようとしている方の記事も読みましたが、ここまでやらないといけないなら無理する必要ないんじゃないだろうか🤔(そもそもオーバーロードせずにValueObjectを表現できる方法もあるのかもしれないけど)
果たしてjavascriptで演算子オーバーロードは可能なのか
### アプリケーションサービス
さて、ここまでで複数のページにまたがるドメイン関連の処理を良い感じに一つにまとめることができましたが、今度はユーザーから下記のような要件が出てきたとします。
- 家事とその他のTodoリストは別々に表示して欲しい
- 家事のTodoには優先順位を付けて確認できるようにしたい
そこで、下記のようにUIを実装することにしました。
- 家事のTodoかその他のTodoかを選択するセレクトボックスを新たに追加
- 家事のTodoの時、優先度を0〜10の数字で入力する入力欄を追加
- 追加されたタスクはそれぞれの種類別に表示
では実装面についてですが、先ずは家事のTodoを前回作ったTodoと同じようにstoreに実装していきたいと思います。
export default ({
namespaced: true,
state: {
value: [],
},
getters: {
value(state) {
return state.value
},
},
mutations: {
add(state, payload) {
state.value.push(payload)
state.value = state.value.slice().sort((todo1, todo2) => todo2.priority - todo1.priority)
},
},
actions: {
add({ commit }, payload) {
if(payload.priority < 0 || payload.priority > 10) return false
commit('add', payload)
return true
},
},
})
家事のTodoは優先度に合わせて順番を入れ替える必要がありますので、addした後にsortします。
また、優先度は1〜10という決まりなので、それ以外の数字だと弾くようにしています。
ここまでは良いのですが、「家事のTodoの場合はhouseworkTodoListに、普通のTodoの場合はtodoListにタスクを追加する」という処理はどこに書くべきでしょう。
view側に書くことももちろん可能です。
methods: {
async registerTodo() {
let result;
if (this.todoType === "housework") {
result = this.$store.dispatch("houseworkTodoList/add", {
priority: this.priority,
text: this.text,
});
} else {
result = this.$store.dispatch("todoList/add", this.text);
}
if (result) this.text = "";
},
},
ですが、こうしてしまうと前回と同様、似たような処理が他の画面で出てきた時に同じようなコードを書く羽目になり、これはよくありません。
ということで、ドメイン駆動設計でいうアプリケーションサービスを作成し、そちらに処理を任せたいと思います。
export default ({
namespaced: true,
actions: {
add({ dispatch }, payload) {
if (payload.type === 'housework') {
return dispatch('houseworkTodoList/add', payload, {root: true})
} else {
return dispatch('todoList/add', payload.text, {root: true})
}
},
},
})
アプリケーションサービスは基本的に状態を保持しないので、storeではなく普通のjsファイルで実現したいところですが、それだとstoreにアクセスできない(はず)のでstore内にアプリケーションサービス層のファイルを作りました。
こうすることでview側が知っているのは、どのアプリケーションサービスに値を渡す必要があるのか、ということだけです。
<template>
<div class="container">
<div>トップページ(<router-link to="/sub">サブページへ</router-link>)</div>
<p>通常</p>
<ul>
<li v-for="(todo, index) in todoList" v-bind:key="index">
<input type="checkbox" />
<label for="checkbox">{{ todo }}</label>
</li>
</ul>
<p>家事</p>
<ul>
<li v-for="(todo, index) in houseworkTodoList" v-bind:key="index">
<input type="checkbox" />
<label for="checkbox">{{ todo.text }}</label>
<span> 優先度:{{ todo.priority }} </span>
</li>
</ul>
<select v-model="todoType">
<option value="normal">通常</option>
<option value="housework">家事</option>
</select>
<input v-model="text" type="text" />
<button @click="registerTodo">register</button>
<div v-if="todoType === 'housework'">
優先順位を入力
<input v-model="priority" type="number" max="10" min="0" />
</div>
</div>
</template>
<script>
export default {
name: "top",
data: function () {
return {
text: "",
todoType: "normal",
priority: 0,
};
},
computed: {
todoList: function () {
return this.$store.getters["todoList/value"];
},
houseworkTodoList: function () {
return this.$store.getters["houseworkTodoList/value"];
},
},
methods: {
async registerTodo() {
if (
await this.$store.dispatch("todoListService/add", {
text: this.text,
type: this.todoType,
priority: parseInt(this.priority, 10),
})
)
this.text = "";
},
},
};
</script>
現状の実装だと、view側からtodoListとhouseworkTodoListという二つのドメイン層のstoreに直接アクセスしてしまっていますが、それが気持ち悪いなら(ちょっと面倒ですが)todoListServiceのgetterを通してアクセスするように変えてしまうのもいいかもしれません。
computed: {
todoList: function () {
return this.$store.getters["todoListService/value"];
},
houseworkTodoList: function () {
return this.$store.getters["todoListService/houseworkListValue"];
},
},
getters: {
value(_, _1, _2, rootGetters) {
return rootGetters['todoList/value']
},
houseworkListValue(_, _1, _2, rootGetters) {
return rootGetters['houseworkTodoList/value']
}
},
次にフォルダー全体の構成図ですが、ドメイン駆動よりに寄せるのであれば下記のような構成になるかと思います。
src/
├ App.vue
├ components/
│ └ Top.vue
├ pages/
│ └ Sub.vue
└ store/
├ index.js
│
├ entity/
│ ├ houseworkTodoList.js
│ └ todoList.js
│
└ applicationService とか service とか/
└ todoList.js
これは勝手な想像ですが、entityはER図なんかでも出てくる概念ですし、ドメイン駆動設計を知らなくてもまだ何をしているか分かるフォルダー名なのかなって思います。
ですがapplicationServiceについてはドメイン駆動設計を知らないとなかなか分からないのではないでしょうか。
なのでもし、ドメイン駆動っぽさを出したくないのであれば、viewModelやpagesなどにして下位のファイル名は src/pages/ 以下のファイル名と同じにしておけば各ページ関連の処理がこのstoreファイルに入っている、と分かりやすいのではないでしょうか。
src/
├ App.vue
├ components/
│ └ Top.vue
├ pages/
│ └ Sub.vue
└ store/
├ index.js
│
├ entity/
│ ├ houseworkTodoList.js
│ └ todoList.js
│
└ viewModel/
└ Top.js
名前をviewModelにしてしまうと、1画面につき1つのファイルでアプリケーション機能を表現することになり、その処理を他の画面でも使い回すのは難しくなります。(Top.js という名前のstoreファイルを src/pages/Sub.vue で使うのは違和感があるため)
結果的にvueファイルでアプリケーションサービス層のロジックを実装しているのと同じようにみえます。
// 結局これと同じ?
methods: {
async registerTodo() {
let result;
if (this.todoType === "housework") {
result = this.$store.dispatch("houseworkTodoList/add", {
priority: this.priority,
text: this.text,
});
} else {
result = this.$store.dispatch("todoList/add", this.text);
}
if (result) this.text = "";
},
},
ただ、それでも後述するAPIの呼び出し(Repository)とか、(もしあるなら)ドメインサービスやDTOの作成なども必要な場合にこのアプリケーションサービス層に追加できますので、やはりこっちの方が変化に強いコードと言えそうです。
ドメイン駆動設計をちょっとずつ実践しようとして実感していますが、アプリケーションサービスあたりが出てきた段階で、フォルダー構成がちょっとずつドメイン駆動設計ぽくなっていってしまうのは仕方ないのかなと。
あと、今回はエンティティとアプリケーションサービスをstoreとして表現しましたが、例えば家事のTodoListとhouseworkTodoListの振る舞いが似ており、共通化したいとなった場合。
ベースクラスを作ってそれを拡張してというのがオブジェクト思考の定番のやり方ですが、storeを使っているとこれがやりづらいので、冒頭で紹介した Vuex + DDDのアーキテクチャを考える のようにエンティティをstoreではなく生のjs(ts)で表現した方がいいでしょう。
そもそも私がエンティティをstoreで表現することになったキッカケがこちらのゲームを作った時ですが、この時使ったのがvuexfireというライブラリです。これを使用してfirestoreとvuexを同期させるために仕方なくそうしたという経緯があります。
ただ、storeで表現していたDomain関連の処理を生のjs(ts)で書き直せない、したくない(時間がない、コードスタイルをvuexで統一したなど)あると思うので、ここは状況に合わせて対応すればいいのではと思います。(すいません、ここら辺はまだあまり知見がないので分からない部分でもあります)
リポジトリ
ここまで複数のドメインを組み合わせてアプリケーションの機能を実現するところまではできましたが、今度はプロダクトマネージャーから「TODOのデータをサーバー側に保存したい!」となったとします。
バックエンドでデータベースやらAPIの作成やらが終わっているという前提ですが、APIの呼び出しは基本的に画面描画とは関係ない処理です。なので、ここの処理はアプリケーションレイヤーに追加していきます。
export default ({
namespaced: true,
getters: {
value(_, _1, _2, rootGetters) {
return rootGetters['todoList/value']
},
houseworkListValue(_, _1, _2, rootGetters) {
return rootGetters['houseworkTodoList/value']
}
},
actions: {
add({ dispatch }, payload) {
let result
switch (payload.type) {
case 'housework':
result = await dispatch('houseworkTodoList/add', payload, { root: true })
break
default:
result = await dispatch('todoList/add', payload.text, { root: true })
break
}
if (result.isSucceeded) {
// HTTPの処理を記述
}
return result.isSucceeded
},
},
})
export default ({
namespaced: true,
state: {
value: [],
},
getters: {
value(state) {
return state.value
},
},
mutations: {
add(state, payload) {
state.value.push(payload)
},
},
actions: {
add({ commit, state, getters }, payload) {
if (
state.value.length >= 10 ||
/(アホ|バカ)/.test(payload) ||
payload.length < 3 ||
payload.length > 20
)
return { isSucceeded: false, value: null }
commit('add', payload)
return { isSucceeded: true, value: getters.value }
},
},
})
(store/entity/houseworkTodoList.js も同じようにactionの返り値を変更しています。)
リポジトリに関しては以上。超ザックリ。
フロントエンドに適応できるドメイン駆動設計の要素は少ない?
ここまでで、
- エンティティ
- アプリケーションサービス
- リポジトリ
という三つのドメイン駆動設計にて出てくる技術的要素を取り上げましたが、これ以外にも
- バリューオブジェクト
- ドメインサービス
- 仕様オブジェクト
- その他のドメイン駆動設計だけでない要素(インターフェースとか集約だとか...)
などなど色々あります。ですが、全部ちゃんと適応しないと分かりづらいプロジェクトって相当規模の大きいものになるのかなと思っていて、それこそ導入するんだったら中途半端にやるのではなくガッツリ導入すべきだと思います。
なので、プロジェクトが小〜中規模だけど「ドメイン駆動設計のいい部分も意識しながら作りたい」という場合、エンティティとアプリケーションサービスとレポジトリ+α必要になったものを少々、適宜適応していくというスタイルで十分なのかなと思ったりしてます。
あと、そもそもバックエンドをしっかり構築し、フロントエンドで必要なのは描画処理のみという状況にできていたならば、フロントエンドで行わなければならないドメイン関連の処理は少なくなるはず。
なので、プロジェクトの規模が大きくないにも関わらず、ドメイン関連の処理がフロントエンド側にあまりに漏れ出している場合、バックエンドの設計を見直すべきなのかもしれません。
私もまだまだドメイン駆動設計学び始めたばかりなので、あくまで参考にしていただければ幸いです。
以上。ドメイン駆動設計学んで実践してみた、でした。