Vuex + Clean Architectureを頑張ってみた
なぜ
- Vue, Vuexのアプリが大きくなると、様々なロジックが様々なところに散在する結果となったことがあったので、ロジックの集約・ロジックとライブラリ依存の実装の分離を目的として、Vuexの流れに従いながらClean Architectureを入れてみることをチャレンジしました
- 一緒に開発する人がVueやClean Architecture初めてなこともあるので、Clean Architectureを徹底することはせず、ミニマムでClean Architectureの要素を入れました
行なったこと
Vuexの一般的データフローでActionで行われることが多い「データを外部から取ってきてstateに保存する」「ユーザ操作により追加・削除・変更されたデータをstateに保存する」という処理を以下のようにClean Architecture化しました
上記のフロー(矢印はデータの流れの向き)によって以下が実現できるようになります
- Vuexの単方向フローを守りながら、アプリのロジックをユースケース・ドメインで表現できるようになる
- ユースケースやドメインはVuexというライブラリを知らずに済むので、例えばReact, Reduxに移行する場合でも変更箇所を最小限に抑えられる
StateGatewayに対してDriver層の何かを足しても良いとは思ったのですが、他のメンバーのことを考えて軽量にしたかったのと、mutaitonを呼ぶcommitを呼ぶだけの関数をこの段階で追加することによってあまり旨味が出るように思えなかったので、一旦StateGatewayだけに留めておきました
どう実現したか
Nuxt.js + Vuex + Typescriptでの実装例を紹介します
- Vue Component, 各種Port, ItemGateway, ApiClient, ドメインクラスは特別なことはしていないので説明を省略します
- 適当な名前としてItem~という名前を使っています
Actionの実装
Vuexの実装とClean Architectureの実装の接点となるところなので一番悩みました
- storeを参照したりcommitを呼んだりするStateGatewayがどうしてもActionの引数であるActionContextを必要とするので、Actionが呼ばれるごとにUsecase, StateGatewayのインスタンスを生成するようにしています
- インスタンスを無駄に生成しないようにするには、Usecaseの関数を呼ぶ時に引数としてActionContextを渡す事も考えられましたが、UsecaseがVuexに依存するのは避けたかったのでこのような実装に落ち着きました
- Usecaseに渡すドメインのインスタンスはAction内で生成するようにしました
// 型宣言が冗長になってしまうを避けるため以下の1行は入れています type Ctx = ActionContext<RootState, RootState>; const itemGateway = new ItemGateway(new ApiClient()); const usecase = function(context: Ctx): Usecase { return new Usecase( itemGateway, new StateGateway(context), ); }; export const actions: ActionTree<RootState, RootState> = { load(context: Ctx): void { usecase(context).load(); }, addItem(context: Ctx, item: String): void { usecase(context).addItem(new ItemDomain(item)); }, }
Usecaseの実装
ドメインの操作の詳細はドメインのクラスに実装している想定で、Usecaseではドメインを使ったデータの参照・保存の大まかな流れを記述しています
- ItemPort: 外部からのデータの参照・保存
- StatePort: ステートの参照と保存
この例ではitemをどの順番で表示するかをドメインロジックとして表現したいという想定をしているため、ドメインクラスの関数addItemを呼ぶようにしました
export default class Usecase { constructor(private itemPort: ItemPort, private statePort: StatePort) {} async load(): Promise<void> { const items = await this.itemPort.findItems(); this.statePort.storeItems(items); } async addItem(item: ItemDomain): Promise<void> { const savedItem = await this.itemPort.saveItem(item); const items = this.statePort .findItems() .addItem(savedItem); this.statePort.storeItems(items); } }
StateGateway
- StateGatewayではActionContextをつかってcommitやstateに直接アクセスしています
- ドメインのインスタンスをそのままstateに入れることを避けたかったので(後述)、ドメインに内包されるデータををstateにとって最適な形に詰め直し、commitを呼ぶようにしました。逆方向の変換も同じです。(詳細な実装は省きます)
export default class StateGateway implements StatePort { constructor(readonly context: ActionContext<RootState, RootState>) {} storeItems(items: ItemListDomain): void { // toItemsState, toItemOrderStateはドメインからState格納用のオブジェクトに変換する this.context.commit('SET_ITEMS', toItemsState(items)); this.context.commit('SET_ITEM_ORDER', toItemOrderState(items)); } findItems(): ItemListDomain { // toItemListDomainはState格納用のオブジェクトからドメインに変換する return toItemListDomain(this.context.state.items.byId, this.context.state.items.order); } }
Mutation
MutationではGatewayから渡されたものをそのまま入れるようにしています
export const mutations: MutationTree<RootState> = { SET_ITEMS: (state, items: ItemsState) => { state.items.byId = items; }, SET_ITEM_ORDER: (state, order: ItemOrderState) => { state.items.order = order; }, }
State
- stateは変更の単位やアクセスのしやすさを考えて正規化しています
- Vuex用の良い記事が見つからなかったので、ReduxのNormalizing State Shapeを参考にしています
- State用のオブジェクトを保存することで、ドメインをそのまま保存しないようにしました
- ちなみに以下のItemsStateやItemOrderStateはクラスである必要がないのでインターフェースで宣言しました
export const state = () => ({ items: { byId: {} as ItemsState, order: {} as ItemOrderState, }, });