LoginSignup
10
12

More than 1 year has passed since last update.

Vue 3のレイヤ分けで悩む

Last updated at Posted at 2022-09-07

はじめに

Vue 3 Composition API と Firebase でちょっとしたアプリを作成しようとした際、レイヤ間の依存関係の整理方法で試行錯誤した過程を残しておきます。

サンプルコードの題材はFirestoreをバックエンドとした単純なCRUDアプリです。

なお、Firestoreアクセスできるのは認証済ユーザに限定するので、前提としてFirebase Authenticationで認証していますが、認証まわりの部分はコードも含め本記事では割愛しています。以下の記事に軽くは記載しています。

コンポーネント構成

以下のような画面・コンポーネント構成を例として記載していきます。

vue-layer-1.png

各コンポーネントの役割の概要は以下です。

  • Items
    • Itemのコレクションのリスナーを取得
      • Firestoreのリアルタイムアップデートを使用するので、「データの取得・管理」でなく「リスナーの取得」という表現をしています。
    • Itemのコレクションを表示コンポーネントであるItemListにpropsで提供
  • ItemList
    • propsで受け取ったItemのコレクションそれぞれをv-forでItemLineにpropsで提供
  • ItemLine
    • propsで受け取ったItemを1行として表示
    • Itemの更新操作を反映
src/components/Items.vue
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Item } from '../domain/models/Item';
import { useOrderBy } from '../composables/useOrderBy';
import ItemList from './ItemList.vue';

// Itemのコレクションのリスナーを取得
// 詳細は後述
const items = ref([]);
...

// Itemのリストをnameで降順ソート
const { orderBy } = useOrderBy();
const itemsOrderedByName = computed (() => {
  const orederDesc = true;
  return orderBy(items.value, 'name', orederDesc);
});
</script>

<template>
  <h2>一覧</h2>
  <ItemList :items="itemsOrderedByName" />
</template>
src/components/ItemList.vue
<script setup lang="ts">
import { Item } from '../domain/models/Item';
import ItemLine from './ItemLine.vue';

const props = defineProps<{
  items: Item[]
}>();
</script>

<template>
  <ul>
	<li v-for="item in props.items" :key="item.id">
      <ItemLine :item="item" />
    </li>
  </ul>
</template>
src/components/ItemLine.vue
<script setup lang="ts">
import { inject, ref } from 'vue';
import { Item } from '../domain/models/Item';

const props = defineProps<{
  item: Item
}>();

// Itemの更新を永続化
// 詳細は後述
</script>

<template>
  <input type="checkbox" v-model="item.isCompleted" @change="updateItem(item)"/>
  {{ item.name }}
</template>

テスト準備

後ほどVitestを使用したテストコードを書いていくので、セットアップのメモも残しておきます。

$ npm i -D vitest @vue/test-utils jsdom
package.json
{
  "name": "firebase-test",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview",
	"test": "vitest"
  },
  "dependencies": {
    "firebase": "^9.9.3",
    "vue": "^3.2.37",
    "vue-router": "^4.1.4"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^3.0.3",
    "@vue/test-utils": "^2.0.2",
    "jsdom": "^20.0.0",
    "typescript": "^4.6.4",
    "vite": "^3.0.7",
    "vitest": "^0.22.1",
    "vue-tsc": "^0.39.5"
  }
}
vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
  }
})

コンポーネントとデータストアの間の依存関係の整理

今回の例をDDDで言うレイヤ化アーキテクチャに当てはめると以下になるかと思っています。

レイヤ 説明
UI層 Vue 3 依存部分
アプリケーション層 UI層のイベント契機で呼び出され、ドメインモデルを協調させて実行するロジック
ドメイン層 本アプリで扱うItem等のモデル
インフラストラクチャ層 Firestore 依存部分

ただのCRUDアプリだと、アプリケーション層・ドメイン層のロジックはほぼ無かったりするので、モデルも貧血気味(というかもはやミイラ)です。。。
ここでは特にUI層とインフラストラクチャ層について、お互い直接依存していたりすると、フレームワークやバックエンドの変更やバージョンアップ時に影響範囲が広くなってしまうので、しっかり切り離したいところです。

以下で、段階的に依存関係を切り離していきます。

コンポーネントのコード内で直接Firestore操作

一番安易なのは、コンポーネントのコード内で直接Firestoreのライブラリを呼び出してしまうことかと思います。

vue3-layer-2.drawio.png
※モデルのItemクラスの記載は省略してます。

コード

src/components/Items.vue
<script setup lang="ts">
import { inject, ref, Ref, onUnmounted, computed } from 'vue';
import { query, collection, onSnapshot } from "firebase/firestore";

import { firestoreKey } from "../plugins/firebase";
import { Item } from '../domain/models/Item';
import { useOrderBy } from '../composables/useOrderBy';
import ItemList from './ItemList.vue';

const firestore = inject(firestoreKey);
if(!firestore) throw new Error('provide missing: firestore');

const items: Ref<Item[]> = ref([]);
const q = query(collection(firestore, 'items'));
const unsubscribe = onSnapshot(q, (querySnapshot) => {
  items.value = querySnapshot.docs.map(doc => {
	const data = doc.data();
    return new Item(data.id, data.name, data.completed);
  });
});

onUnmounted(() => {
  unsubscribe();
});

const { orderBy } = useOrderBy();
const itemsOrderedByName = computed (() => {
  const orederDesc = true;
  return orderBy(items.value, 'name', orederDesc) as Item[];
});
</script>

<template>
  <h2>一覧</h2>
  <ItemList :items="itemsOrderedByName" />
</template>

単純にコンポーネント内に全部詰め込んだ感じですが、さすがにFirestoreインスタンスは他コンポーネントでも使う可能性が高いので、初期化済インスタンスをグローバルでProvideしたものをInjectして使用しています。

src/infrastructure/firebase/index.ts
import { initializeApp } from 'firebase/app';
import { getFirestore } from "firebase/firestore";

import { firebaseConfig } from './config';

const firebaseApp = initializeApp(firebaseConfig);
const firestore = getFirestore(firebaseApp);

export {
  firestore,
}
src/plugins/firebase.ts
import { App, Plugin, InjectionKey } from 'vue'
import { Firestore } from "firebase/firestore";

import { firestore } from '../infrastructure/firebase';

export const firestoreKey: InjectionKey<Firestore> = Symbol('firestore');

export const firebase: Plugin = {
  install(app: App) {
    app.provide(firestoreKey, firestore);
  }
}
src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

import { firebase } from "./plugins/firebase";

const app = createApp(App);
app.use(firebase);
app.mount('#app');

ちなみにVue 2の頃は、同等のことをやる場合、Provide/Injectでなくインスタンスプロパティを使用して、src/plugins/firebase.tsの中でVue.prototype.$firestore = firestoreのように登録し、各コンポーネント内でthis.$firestoreで使用していたりしました。

Vue 3でもglobalPropertiesを使用すれば同じことができますが、setupメソッド内ではthisが使えず、getCurrentInstance経由なら使えるものの非推奨のようなので、Provide/Injectを使うのが基本のようです。

テスト

この構成で一番厄介なのは、コンポーネントの単体テストがしづらいことかと思います。
InjectするFirestoreインスタンスに加え、firestoreライブラリのquery, collection, onSnapshotメソッドも含めてmock化していく必要があって面倒ですし、そうやって単体テストしてもやはり実際にFirestoreに接続して動作確認しないと不安なので、結局は単体テストしない方向に流れがちな気がします。

Firestore操作をcomposableに切り出す

そこで、コンポーネントからロジックを一通りcomposableに切り出します。

vue3-layer-3.drawio.png

コード

src/components/Items.vue
<script setup lang="ts">
import { inject, ref, onUnmounted, computed } from 'vue';

import { useItem } from '../composables/useItem';
import { useOrderBy } from '../composables/useOrderBy';
import ItemList from './ItemList.vue';

const { items, subscribeAll } = useItem();
const unsubscribe = subscribeAll();
onUnmounted(() => {
  unsubscribe();
});

const { orderBy } = useOrderBy();
const itemsOrderedByName = computed (() => {
  const orederDesc = true;
  return orderBy(items.value, 'name', orederDesc);
});
</script>

<template>
  <h2>一覧</h2>
  <ItemList :items="itemsOrderedByName" />
</template>

composable側に追い出したことで、コンポーネントからFirestoreへの直接の依存がなくなりました。

src/composables/useItem.ts
import { inject, ref, Ref } from 'vue';
import {
  QueryDocumentSnapshot, onSnapshot,
  doc, setDoc, query, collection
} from "firebase/firestore";

import { firestoreKey } from "../plugins/firebase";
import { Item } from '../domain/models/Item';

export function useItem() {

  const firestore = inject(firestoreKey);
  if(!firestore) throw new Error('provide missing: firestore');

  const collectionKey = 'items';

  const items: Ref<Item[]> = ref([]);

  const converter = {
    toFirestore: (item: Item) => {
      return {
        id: item.id,
        name: item.name,
        isCompleted: item.isCompleted,
      };
    },
    fromFirestore: (snapshot: QueryDocumentSnapshot) => {
      const data = snapshot.data();
      return new Item(data.id, data.name, data.isCompleted);
    }
  };
  
  const subscribeAll = () => {
    const q = query(collection(firestore, collectionKey));
    const unsubscribe = onSnapshot(q, (querySnapshot) => {
      items.value = querySnapshot.docs.map(doc => {
        return converter.fromFirestore(doc);
      });
    });
    // 戻り値の型をfirestoreライブラリ(Unsubscribe)依存にしたくないので
    // ラップして () => void 型にして返す
    const execUnsubscribe = () => {
      unsubscribe();
    };
    return execUnsubscribe;
  };

  const updateItem = (item: Item) => {
    const ref = doc(firestore, collectionKey, item.id).withConverter(converter);
    setDoc(ref, item);
  };
  
  return {
    items,
    subscribeAll,
    updateItem,
  }
}

composable側は、ItemLineから呼び出すことになるupdateItemメソッドも集約することができ、ItemLineと重複していたInject処理やconverterを1箇所にまとめることもできます。

ただ、これだと注意が必要そうな点として、useItem()実行時に返されるインスタンス(itemsを含む)は、useItem()の実行ごとに都度生成されるため、Itemsコンポーネント側でuseItem()で生成しsubscribeしたitemsと、ItemLineコンポーネント側でupdateItemのため別途useItem()実行した際にできるitemsは別ものになります。
この例ではデータ更新はFirestore経由で ItemLine —update—> Firestore —subscribe—> Items の流れで反映されるので問題ありませんが、メモリ上でデータ管理する場合などはコンポーネントをまたいで反映が届くよう対応が必要そうです。(後述するRepository内でデータ保持する、Fluxパターンを使う、など。)

テスト

コンポーネント側はcomposableのmockだけ用意すればよく、テストしやすくなりました。
composable側はFirestore依存のためテストしづらいのは同じです。

src/test/components/Items.test.ts
import { describe, beforeEach, expect, it, vi, Mock } from 'vitest'
import { shallowMount, VueWrapper } from '@vue/test-utils'
import { ref, Ref } from 'vue';

import { Item } from '../../domain/models/Item';
import * as composable from '../../composables/useItem';
import Items from "../../components/Items.vue";
import ItemList from "../../components/ItemList.vue";

let items: Ref<Item[]>;
let subscribeAll: Mock;
let updateItem: Mock;

let wrapper: VueWrapper;

describe('Items', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    
	const testItems = [
	  new Item('id1', 'name1', false),
	  new Item('id3', 'name3', false),
	  new Item('id2', 'name2', true),
	];
    items = ref([...testItems]);
    subscribeAll = vi.fn();
    updateItem = vi.fn();

    vi.spyOn(composable, 'useItem').mockReturnValue({
      items,
      subscribeAll,
      updateItem,
    });

    wrapper = shallowMount(Items);
  });

  it('useItem経由でItemのコレクションのリスナーを取得する', () => {
    expect(subscribeAll).toBeCalledTimes(1);
  });

  it('Itemのコレクションをnameで降順ソートした結果を、ItemListのpropsに渡す', () => {
    const child = wrapper.getComponent(ItemList);
    expect(child.props().items).toStrictEqual([
      new Item('id3', 'name3', false),
      new Item('id2', 'name2', true),
      new Item('id1', 'name1', false),
    ]);
  });
});

ビジネスロジックを持たない単純なCRUDアプリであれば、コンポーネントをテストしやすくしたいだけなら、最低限これでもよいかと思います。
ただ、composableがVueとFirestoreの両方に依存している状況は、できれば避けたいところです。

Firestore操作をRepository実装に切り出し、composable経由で使用する

Firestore操作をcomposableからインフラストラクチャ層のRepository実装に切り出し、DIPに則ることにより、UI層からFirestoreへの依存を完全に排除します。

vue3-layer-4.drawio.png

※ドメインモデルのItemは各所から使われるので、Itemへの依存の矢印は省略してます。

コード

src/composables/useItem.ts
import { ref, Ref, inject } from 'vue';

import { itemRepositoryKey } from '../plugins/repositories';
import { Item } from '../domain/models/Item';

export function useItem() {
  const itemRepository = inject(itemRepositoryKey);
  if(!itemRepository) throw new Error('provide missing: itemRepository');

  const items: Ref<Item[]> = ref([]);
  
  const subscribeAll = () => {
    return itemRepository.subscribeAll(items);
  };

  const updateItem = (item: Item) => {
    itemRepository.update(item);
  };
  
  return {
    items,
    subscribeAll,
    updateItem,
  }
}
src/plugins/repositories.ts
import { App, InjectionKey, Plugin } from 'vue'

import { itemRepository } from "../modules";
import { IItemRepository } from "../domain/repositories/IItemRepository";

export const itemRepositoryKey: InjectionKey<IItemRepository> = Symbol('itemRepository');

export const repositories: Plugin = {
  install(app: App) {
    app.provide(itemRepositoryKey, itemRepository);
  }
}

src/main.tsで上記プラグインをapp.useしますが、コードは割愛します。

Repositoryインスタンスの生成部分を以下ファイルに切り出し、DIコンテナ相当としています。

src/modules.ts
import { firestore } from './infrastructure/firebase';

import { IItemRepository } from './domain/repositories/IItemRepository';
import { ItemRepositoryOnFirestore } from './infrastructure/ItemRepositoryOnFirestore';

const itemRepository: IItemRepository = new ItemRepositoryOnFirestore(firestore);

export {
  itemRepository,
}
src/domain/repositories/IItemRepository.ts
import { Ref } from 'vue';
import { Item } from '../models/Item';

export interface IItemRepository {
  subscribeAll: (item: Ref<Item[]>) => () => void;
  update: (item: Item) => void;
}
src/infrastructure/ItemRepositoryOnFirestore.ts
import { Ref } from 'vue';
import {
  Firestore, QueryDocumentSnapshot, onSnapshot,
  doc, setDoc, query, collection, QueryConstraint
} from "firebase/firestore";

import { Item } from '../domain/models/Item';
import { IItemRepository } from '../domain/repositories/IItemRepository';

export class ItemRepositoryOnFirestore implements IItemRepository {

  private readonly firestore;

  private readonly collectionKey = 'items';

  private readonly converter = {
    toFirestore: (item: Item) => {
      return {
        id: item.id,
        name: item.name,
        isCompleted: item.isCompleted,
      };
    },
    fromFirestore: (snapshot: QueryDocumentSnapshot) => {
      const data = snapshot.data();
      return new Item(data.id, data.name, data.isCompleted);
    }
  };

  public constructor(firestore: Firestore) {
    this.firestore = firestore;
  }

  public update(item: Item): void {
    const ref = doc(this.firestore, this.collectionKey, item.id).withConverter(this.converter);
    setDoc(ref, item);
  }

  public subscribeAll(comics: Ref<Item[]>): () => void {
    return this.subscribeWithQuery(comics, []);
  }

  private subscribeWithQuery(items: Ref<Item[]>, constraints: QueryConstraint[]): () => void {
    const q = query(collection(this.firestore, this.collectionKey), ...constraints);
    const unsubscribe = onSnapshot(q, (querySnapshot) => {
      items.value = querySnapshot.docs.map(doc => {
        return this.converter.fromFirestore(doc);
      });
    });

    const execUnsubscribe = () => {
      unsubscribe();
    };
    return execUnsubscribe;
  }
}

Repository側は、subscribeの引数の型としてVueのRefへの依存だけは残ってしまいました。ここはシンプルに依存排除できそうな方法が思いついておらず、そのままにしていますが、この程度であれば影響は小さいかな、と思うことにしてます。。。

2022/9/16追記

@juner コメントありがとうございます。
いただいたコメントの意図と合っているかちょっと自信ありませんが、上記でitems.valueの値を配列ごと置き換えていたところを、以下のように配列の中身だけ置き換えるようにすると、Ref依存をなくすこともできました。

src/composables/useItem.ts抜粋
const items: Ref<Item[]> = ref([]);
  
const subscribeAll = () => {
  return itemRepository.subscribe(items.value);
};
src/infrastructure/ItemRepositoryOnFirestore.ts抜粋
public subscribe(items: Item[]) {
  const q = query(collection(this.firestore, this.collectionKey));
  return onSnapshot(q, (querySnapshot) => {
    items.splice(0, items.length, ...querySnapshot.docs.map(doc => {
      return this.converter.fromFirestore(doc);
    }));
  });
}

ちょっと手間ですが、以下のように、全データ置き換えでなく、変更のある要素のみ置き換えにすることもできました。

src/infrastructure/ItemRepositoryOnFirestore.ts抜粋
public subscribe(items: Item[]) {
  const q = query(collection(this.firestore, this.collectionKey));
  return onSnapshot(q, (querySnapshot) => {
    querySnapshot.docChanges().forEach((change) => {
      const changedItem = this.converter.fromFirestore(change.doc);
      if (change.type === "added") {
        items.push(changedItem);
      }
      if (change.type === "modified") {
        const index = items.findIndex((item) => item.id === changedItem.id);
        items.splice(index, 1, changedItem);
      }
      if (change.type === "removed") {
        const index = items.findIndex((item) => item.id === changedItem.id);
        items.splice(index, 1);
      }
    });
  });
}

性能等も考慮したうえで何がベストかは、まだ理解・検証不足で言えませんが、機能的にはいずれも問題なさそうです。

なお、上記の変更は以降のコード例には反映できていません。

テスト

ここまでやると、composableもテストしやすくなります。
Repositoryのinterfaceを実装したテスト確認用クラス(FakeItemRepository)を代わりにProvideしてやれば、Firestoreなしでテスト可能です。
なお、テスト確認用クラスをうまく作れば無くても済みますが、Spyを使用してRepositoryのメソッド呼び出し状況を容易に確認することも可能です。

src/test/composables/useItem.test.ts
import { 
  describe, it, expect,
  beforeEach, afterEach,
  vi, SpyInstance
} from 'vitest';
import { Ref, provide } from 'vue';

import { withSetup } from './test-utils';

import { useItem } from '../../composables/useItem';
import { Item } from '../../domain/models/Item';
import { IItemRepository } from '../../domain/repositories/IItemRepository';
import { itemRepositoryKey } from '../../plugins/repositories';

const testItems = [
  new Item('id1', 'name1', false),
  new Item('id3', 'name3', false),
  new Item('id2', 'name2', true),
];

class FakeItemRepository implements IItemRepository {
  subscribeAll(items: Ref<Item[]>) {
    items.value = [...testItems];
    return () => {};
  }

  updatedItem: Item | undefined;
  update(item: Item) {
    this.updatedItem = item;
  };
}

let fakeItemRepository: FakeItemRepository;
let spySubscribeAll: SpyInstance;
let spyUpdate: SpyInstance;

let composed: {
  result: {
    items: Ref<Item[]>;
    subscribeAll: () => () => void;
    updateItem: (item: Item) => void
  },
  unmount: () => void
};

describe('useItem', () => {

  beforeEach(() => {
    vi.clearAllMocks();

    fakeItemRepository = new FakeItemRepository();
    spySubscribeAll = vi.spyOn(fakeItemRepository, 'subscribeAll');
    spyUpdate = vi.spyOn(fakeItemRepository, 'update');

    composed = withSetup(() => useItem(), {
      provider: () => {
        provide(itemRepositoryKey, fakeItemRepository);
      },
    });
  });

  it('subscribeする前はitemsは空', async () => {
    expect(composed.result.items.value).toStrictEqual([]);
  });

  it('subscribeすると、itemsにRepositoryのsubuscribe結果が反映される', async () => {
    composed.result.subscribeAll();
    expect(composed.result.items.value).toStrictEqual([...testItems]);
    // Spyを使う場合
    expect(spySubscribeAll).toHaveBeenCalledTimes(1);  
    expect(spySubscribeAll).toBeCalledWith(composed.result.items);
  });

  it('updateItemすると、Repositoryのupdateに移譲される', async () => {
    const item = new Item('id3', 'name3', true);
    composed.result.updateItem(item);
    expect(fakeItemRepository.updatedItem).toBe(item);
    // Spyを使う場合
    expect(spyUpdate).toHaveBeenCalledTimes(1);  
    expect(spyUpdate).toBeCalledWith(item);
  });

  afterEach(() => {
    composed.unmount();
  });
});

ただ、このProvideをするのが少し面倒です。
コンポーネントのテストであれば、テスト対象コンポーネントのwrapperのmount時にProvideを指定するだけでよいのですが、composableのテストの場合、何かしらcomposableを使用するコンポーネントを用意してそこに対してProvideする必要があります。Provide/Injectに限らず、ライフサイクルフックを使用しているcomposableなどでも同様のようです。
そこで、以下を参考にヘルパー関数でテスト用コンポーネントの構築を行っています。
https://vuejs.org/guide/scaling-up/testing.html#recipes
https://github.com/ktsn/vue-composable-tester

src/test/composables/test-utils.ts
import { createApp, h } from 'vue';

export function withSetup<R>(
  composable: () => R,
  options: { provider?: () => void }
): {
  result: R,
  unmount: () => void
} {
  const Child = {
    setup() {
      const result = composable()
      const wrapper = () => result
      return { wrapper }
    },
    render() {},
  }

  const app = createApp({
    setup() {
      options.provider?.()
    },
    render() {
      return h(Child, {
        ref: 'child',
      })
    },
  })
  const vm = app.mount(document.createElement('div'));

  return {
    result: vm.$refs.child.wrapper(),
    unmount: () => app.unmount(),
  }
}

おまけ

ちなみに、DIPでFirestore依存がRepository実装に切り出せていれば、わざわざcomposableを使わずに、Repositoryインスタンスをコンポーネント内で直接InjectまたはDIコンテナから直接取得するという手も考えられます。
その場合、モジュール数が減る(コード量も多少減る)というメリットはありますが、個人的にはコンポーネントはcomposableのみに依存する方がきれいでわかりやすいかと思います。

コンポーネント内でRepositoryインスタンスを直接Inject

vue3-layer-5.drawio.png

コンポーネントのテスト時は、Wrapperのmount時に前述のFakeItemRepositoryをProvideすることになります。

src/test/components/Items.test.ts抜粋
fakeItemRepository = new FakeItemRepository();
spySubscribeAll = vi.spyOn(fakeItemRepository, 'subscribeAll');
wrapper = shallowMount(Items, {
  global: {
    provide: {
      [itemRepositoryKey]: fakeItemRepository
    }
  }
});
コンポーネント内でRepositoryインスタンスをDIコンテナから直接取得

vue3-layer-6.drawio.png

コンポーネントのテスト時は、DIコンテナからの取得結果をFakeItemRepositoryに差し替えることになります。

src/test/components/Items.test.ts抜粋
import * as modules from '../../modules';

...
    
fakeItemRepository = new FakeItemRepository();
spySubscribeAll = vi.spyOn(fakeItemRepository, 'subscribeAll');
vi.spyOn(modules, 'getItemRepository').mockReturnValue(fakeItemRepository);
wrapper = shallowMount(Items);

この場合、DIコンテナ側がインスタンスのexportだと、mock差し替えしにくいので、インスタンス取得メソッドを返すように変えてます。

src/modules.ts抜粋
let itemRepository: IItemRepository;
export const getItemRepository = () => {
  if(!itemRepository) {
    const itemRepository = new ItemRepositoryOnFirestore(firestore);
  }
  return itemRepository;
};

アプリケーション層を追加

一応、アプリケーション層(アプリケーションサービス)も追加すると、以下のようになります。

vue3-layer-7.drawio.png

Repositoryの代わりにアプリケーションサービスをProvide/Injectしています。

単純なCRUDアプリだとアプリケーションサービスも結局Repositoryへの移譲だけになるので、過剰な感がありますが、ここまで分けておけば、もしビジネスロジックが追加されだしても、アプリケーション層とドメイン層の中で整理できるのではと思います。
もし、バックエンドをFirestore以外に変える、フロントをVue以外に変える、といった場合も、修正箇所がそれなりに限定できるはずです。

ただ、(Vue依存な感はあるものの)composableのuseItemがアプリケーションサービス相当という見方もできそうなので、そう考えるとやはりItemServiceを挟む必要はないのかもしれません。

コード

コードはほぼ割愛しますが、ItemService実装だけ載せておきます。
テストについてもほぼVue固有要素はないので、前述のFakeItemRepositoryをコンストラクタ引数で渡してテスト可能です。

src/usecases/ItemService.ts
import { Ref } from 'vue';

import { IItemService } from './IItemService';
import { IItemRepository } from '../domain/repositories/IItemRepository';
import { Item } from '../domain/models/Item';

export class ItemService implements IItemService {
  private itemRepository: IItemRepository;

  public constructor(itemRepository: IItemRepository) {
    this.itemRepository = itemRepository;
  }

  public subscribeAll(item: Ref<Item[]>): () => void {
    return this.itemRepository.subscribeAll(item);
  }

  public update(item: Item): void {
    this.itemRepository.update(item);
  }
}

おわりに

Vue 3 Composition APIで外部データストアにアクセスする場合の構成について、個人的にある程度のイメージが持てるようにはなりました。基本的にはcomposableを介してコンポーネントと(外界を含む)データ・ロジックを繋ぐものと理解しています。

ただ、フロントのデータ・状態管理でよく話に挙がるFluxパターンについては、勉強・経験不足なこともあり、ここでは触れられていません。
Firestoreでデータ管理していると、リアルタイムアップデートのおかげで、コマンド(Firestoreへの更新)とクエリ(Firestoreのsubscribe)の分離が自然とできるので、「データの流れを単一方向にする」というのがFirestore経由でできており、あまりFluxの必要性を感じなかったのかもしれません。
FluxとDDDの組み合わせやVue 3の場合どうなるかを考え出すと、またハマりそうなのでいったん置いておきます。
 参考記事:https://blog.j5ik2o.me/entry/2016/09/09/200643

そもそもVueやFirestoreを使う時点で、小規模アプリやプロトタイプがほとんどで、そんなに細かいこと考えないのではという気はしつつ、とりあえず吐き出させていただきました。

10
12
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
12