Vuex + Clean Architectureを頑張ってみた

なぜ

  • Vue, Vuexのアプリが大きくなると、様々なロジックが様々なところに散在する結果となったことがあったので、ロジックの集約・ロジックとライブラリ依存の実装の分離を目的として、Vuexの流れに従いながらClean Architectureを入れてみることをチャレンジしました
  • 一緒に開発する人がVueやClean Architecture初めてなこともあるので、Clean Architectureを徹底することはせず、ミニマムでClean Architectureの要素を入れました

行なったこと

Vuexの一般的データフローでActionで行われることが多い「データを外部から取ってきてstateに保存する」「ユーザ操作により追加・削除・変更されたデータをstateに保存する」という処理を以下のようにClean Architecture化しました

f:id:yukithornton:20200630222029p:plain

上記のフロー(矢印はデータの流れの向き)によって以下が実現できるようになります

  • 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内で生成するようにしました
    • Component側にあまりドメインの知識を散在させたくなかったこと、Actionと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用のオブジェクトを保存することで、ドメインをそのまま保存しないようにしました
    • 正規化とも同じですが、巨大なドメインが頻繁に更新されるとなんどもComponentの再レンダリングが走ってしまうからです
    • ドメインの知識がVue ComponentやGetterに散在してしまうことによって、それぞれの責務が不明瞭になることを防ぎたかったからです
  • ちなみに以下のItemsStateやItemOrderStateはクラスである必要がないのでインターフェースで宣言しました
export const state = () => ({
  items: {
    byId: {} as ItemsState,
    order: {} as ItemOrderState,
  },
});

行ってみてどうだったか

  • 単体テストが非常に書きやすくなりました
    • Cypressをつかった統合テストは導入しているものの、特にドメインロジック周りは可能な限り単体テストも書きたいと思っていました
    • ライブラリの依存を局所的にすることでテストしやすい領域が増えました
  • コードの量は圧倒的に増えたが、機能を変更したい時に変更すべき箇所が誰にでもわかるようになっているのが成果だと感じています
  • Getterの使い分け、storeに保存するか迷うようなstateの扱いなど、まだまだ考えきれていないところがあるので、様々な種類のユースケースを実装する中で設計を洗練させていきたいです