Reduxにドメイン層を導入する

【2018/6/18更新】immutable.js を利用せずに、当エントリー同等以上の開発が可能な、FP・型推論の強いモジュールを公開しました。https://qiita.com/Takepepe/items/a79e767b38981c910c3f


ドメインモデルの需要

フロントエンドフレームワークでプロダクトを作り込んでいくと、ビジネスロジックが view に介入しすぎてしまうことが少なからずあります。下記の様なビジネスロジックが散在しているコードに、スケール耐性があるとは思えません。リリース当時は単純だったものが、複雑に変化していくケースは往々にしてあります。


TodoItems.js

function TodoItems (props) {

const { todos, updateTodo, deleteTodo } = props
const items = todos.map(item , i) => {
const { done, show_flag, created_at } = item
const current = new Date().getTime()
const created = created_at.getTime()
const day_over = (current - created) / (1000 * 60 * 60 * 24) >= 1
if (done || !show_flag || day_over) return null
const _props = { key: i, index: i, item, updateTodo, deleteTodo }
return <TodoItem { ..._props } />
})
return (
<div className="todo-items">
<ul className="list-group">
{ items }
</ul>
</div>
)
}



Redux はアーキテクチャであったことを振り返る

昨今の React 界隈では「状態管理に Flux、Redux、MobX どれを採用するのか?」という選択肢が大きな関心ごとの様に思いますが、Flux・Redux はイベントフローアーキテクチャ、MobX は model を担うもののため、性質が異なります。Redux らしいコードが位置する層は DDDレイヤードアーキテクチャの、アプリケーション層に近い位置で、明確なドメイン層相当の概念は Redux には無いと考えています。


Reducer がドメインモデルに最も近い

Redux において、ドメインモデルを繋ぐパートは reducer です。Immutable Update Patterns で示されているとおり、immutable.js を利用することで reducer がシンプルになりますが、immutable.Record インスタンスを利用することで、Reduxはマルチパラダイムに移行できます。内部に保持する factory メソッドと composite パターンを併用すれば、OOP の強い側面を得ることが出来るはずです。OOP が可能なことを示すため、抽象クラスを見ていきます。定義は以下の様になります。


HumanModel.js

import { Record } from 'immutable'

const HumanModel = defaultValues => class extends Record({
age: 18,
gender: 'male',
name: 'yamada.taro',
...defaultValues
}) {
getAge () {
return this.get('age')
}
getGender () {
return this.get('gender')
}
getName () {
return this.get('name')
}
}

export default HumanModel



JapaneseModel.js

import HumanModel from 'path/to/HumanModel'

const JapaneseModel = defaultValues => class extends HumanModel({
country: 'japan',
prefecture: 'tokyo',
...defaultValues
}) {
getCountry () {
return this.get('country')
}
getPrefecture () {
return this.get('prefecture')
}
}

export default JapaneseModel


最終的に reducer に initialState として渡されるものは、以下の様なクラス定義から生成されたインスタンスです。


UserModel.js

import JapaneseModel from 'path/to/JapaneseModel'

export default class UserModel extends JapaneseModel({
user_id: 387,
screen_name: 'ytaro'
}) {
getUserID () {
return this.get('user_id')
}
getScreenName () {
return this.get('screen_name')
}
setScreenName (name) {
return this.set('screen_name', name)
}
}



model・reducer・store 生成まで

store 生成時に reducer が受ける model インスタンスを inject 出来る様にしなければ意味がありません。この点は reducer に wrapper を一つ中継すれば、解決できます。


UserReducer.js

// BEFORE

export default (model = new UserModel(), action) => {
switch (action.type) {
case types.SET_SCREEN_NAME: {
const { payload } = action
return model.setScreenName(payload)
}
default :
return model
}
}

// AFTER

export default function (userModel) {
return (model = userModel, action) => {
switch (action.type) {
case types.SET_SCREEN_NAME: {
const { payload } = action
return model.setScreenName(payload)
}
default :
return model
}
}
}


model インスタンス生成時に引数をとる必要性は言うまでもないかと思います。エントリーポイントで model・reducer を展開し、storeを生成します。


entry_point.js

import { createStore, combineReducers } from 'redux'

import UserModel from 'path/to/models/UserModel'
import UserReducer from 'path/to/reducers/UserReducer'
import SettingsModel from 'path/to/models/SettingsModel'
import SettingsReducer from 'path/to/reducers/SettingsReducer'

const rootReducer = combineReducers({
user: UserReducer(new UserModel({ authrized: true })),
settings: SettingsReducer(new SettingsModel())
})
const store = createStore(rootReducer)



Single source of truth

言わずと知れた Redux の三原則ですね。さて、今目の前に表示されている画面に store はいくつあるでしょうか?もし、複数の store や、それに相当するものがあるなら、考え直した方が良さそうです(例えば私の過去の資料)。プロダクトコードのどこかに Redux を採用するのであれば、機能単体からDDDの4層全てに1つのstoreが伝播する様にリファクタすることをお勧めします。(model だけが store を知らない)

コードの構造から一つの store 生成しか許容しないことは重要です。また、モジュールの依存関係・インスタンス生成・適用順を事前によく検討し、無節操なimportを許容してはいけません。


アダプターを担う redux-saga

view や model で表現するには不自然な「活動」がプログラムの中にはあります。これは、サービス層が担うもので、Redux が導入されているならば、現状では redux-saga が最も相性の良いモジュールだと思います。イベント駆動の世界から Promise の概念を追い出し、インフラストラクチャ層の責務も担い、アプリケーションサービス層として機能することもあります。

また、モデル間の依存を繋ぐ重要な「アダプター」の責務を担い、Reduxはヘキサゴナルアーキテクチャへと変貌します。この点については、後続の記事にて触れています。

実装 - Hexagonal Redux -


副次的に得られるボイラープレートの消滅

Redux のボイラープレートを好きな人は恐らくいないでしょう。DDD の原則に則るのであれば、アプリケーション層が担う作業は徹底して単調であるべきです。さて、先ほど定義した UserModel に定義されている「screen_name」を変更するためのボイラープレートを見てみます。


path/to/app/action/types/user.js

const types = {

'SET_SCREEN_NAME': 'path/to/identity/SET_SCREEN_NAME'
}
export default types


path/to/app/action/creators/user.js

import types from 'path/to/app/action/types/user'

export function setScreenName (payload) {
return { type: types.SET_SCREEN_NAME, payload }
}



UserModelReducer.js

import types from 'path/to/app/action/types/user'

export default function (initialModel) {
export default (model = initialModel, action) => {
switch (action.type) {
case types.SET_SCREEN_NAME: {
const { payload } = action
return model.setScreenName(action.payload)
}
default :
return model
}
}
}


状態を変更するコマンドは「set screen name」ですね。もし、redux-saga と immutable.js を導入するのであれば、ボイラープレートのやるべきことは一律となり、action はそのまま model に委譲されます。筆者はこの単調な作業を自作の middleware で簡略化しています。


path/to/app/redux/user.js

import { createReduxBoilerPlate } from 'path/to/my/redux/middleware'

const { types, creators, reducer } = createReduxBoilerPlate([
'setScreenName'
], '/path/to/identity/')

export { types, creators, reducer }


UserModel を変更する action が増えた時は、配列に UserModel が持つメソッド名を追加するだけです。定数とキャメルの変換作業がない点もまた気に入っています。ここまで省略されれば、MobXとそう変わらない書き味だと個人的に感じています。(コード上から無くなるだけであって、不要になるわけではないため、それぞれの機能の理解は必要です)


path/to/app/redux/user.js

import { createReduxBoilerPlate } from 'path/to/my/redux/middleware'

const { types, creators, reducer } = createReduxBoilerPlate([
'setScreenName',
'setAvatarImage',
'setProfileBackgroundImage'
], '/path/to/identity/')

export { types, creators, reducer }



appendix

当記事のアーキテクチャをサンプルコードに落としました。 https://github.com/takefumi-yoshii/redux-ddd-example

immutable.js と redux-saga に依存した構造になっていますが、他にもDDD統合方法はありそうです。