🏗️

Nuxt の Layers を用いてモジュラモノリス構造を作る

2024/01/03に公開

はじめに

モジュラモノリスはモノリスなアプリケーションを適切なモジュールに分割した構造を指す。
アプリケーションを分割するアプローチにはマイクロサービスがあるが、アプリケーションが独立することで発生する特有の複雑さがある。

モジュラモノリスはアプリケーションが独立することで得られるいくつかのメリット (デプロイの独立性など) が必要ないときに、単一のアプリケーションを分割する有力なアプローチになる。
また、モジュラモノリスの構造が適当なものであれば、そこから独立したアプリケーションに分割することも比較的容易となる。
つまり、モジュラモノリスはそれ自体がアプリケーションの複雑性を低減するうえで有効な上に、アプリケーションを分割するうえでのステップにもなりうる。

Nuxt は Vue のメタフレームワーク。決められた構造に沿ってファイルを配置することでアプリケーションのルーティングを形成できるなどの機能を持つ。
この「決められた構造」はアプリケーションを適切なモジュールに分割するうえで障害になることがある。

コロケーションは最近流行っている言葉だが、これは上記のような状態に対応するために発生したものだと考えている。
つまり、アプリケーションのURL構造に沿ってファイルを配置する必要があることを前提として、概念的に関連があるファイルが分散しないように近くに置く事で、モジュール分割ができない問題を低減しようとしている。

Nuxt Layers は独立して定義した Nuxt アプリケーションをマージするような機能。
それぞれのアプリケーションは独立して動作するので、前述の問題を解決し高い凝集性のモジュールを定義できることが期待できる。

この記事では Nuxt Layers を用いてモジュラモノリス構造を作る例について記載する。

実装例は以下にある。
https://github.com/sterashima78/nuxt-modular-monolith/tree/main/auto-import-all

例とするアプリケーションの基本情報

以下の領域があるとする

  • domain1
  • domain2
  • domain3

それぞれの領域ごとにモジュールに分割する。
これに加えて、横断的な興味をもつ以下のモジュールを定義する。

  • base
    • 各領域共通で用いるようなコンポーネント・ロジックを定義する
  • styles
    • アプリケーション全体で用いるスタイルルールを定義する
      • この例では tailwindcss を用いる
      • 開発モードでは tailwind viewer を表示する

また、アプリケーションのルートに相当する app というモジュールを定義する。

これら合計 6 つのモジュールは以下のような構造を持つ

これに基づいてモジュラーモノリスの構造を定義する。

実装例

以降、パッケージマネージャには pnpm を用いる。

root

まずプロジェクトルートに package.json を配置する。依存パッケージはいずれのモジュールでも利用するもの。

{
  "private": true,
  "devDependencies": {
    "nuxt": "3.9.0",
    "vue": "3.4.3",
    "vue-router": "4.2.5"
  }
}

各モジュールは monorepo として定義するための pnpm-workspace.yaml を定義する

packages:
  - 'packages/*'

各モジュールを定義していく。

styles

このモジュールでは tailwindcss の設定と開発モード時に tailwind config viewer を起動するような設定を行う。

ここでは @nuxtjs/tailwindcss を用いる。

以下の初期化コマンドを実行して不要ファイルなどを整理するか、

pnpm dlx nuxi@latest init

以下に示すファイルを配置する。

package.json

{
  "name": "styles",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "nuxt dev",
    "postinstall": "nuxt prepare"
  },
  "exports": {
    ".": "./nuxt.config.ts"
  },
  "devDependencies": {
    "@nuxtjs/tailwindcss": "6.10.3"
  }
}

nuxt.config.ts

export default defineNuxtConfig({
  modules: ['@nuxtjs/tailwindcss']
})

tsconfig.json

{
  "extends": "./.nuxt/tsconfig.json"
}

base

base はアプリケーション横断的に用いられるコンポーネントやロジックを定義する。
コンポーネントの実装やテストのために storybook を利用可能にする。

このモジュールは styles モジュールに依存することに注意する

以下のファイルを配置する。

packahe.json

{
  "name": "base",
  "private": true,
  "type": "module",
  "scripts": {
    "postinstall": "nuxt prepare",
    "storybook:dev": "storybook dev --port 6006",
    "storybook:build": "storybook build"
  },
  "devDependencies": {
    "@storybook-vue/nuxt": "0.2.0",
    "@storybook/addon-links": "7.6.6",
    "@storybook/addon-essentials": "7.6.6",
    "@storybook/addon-interactions": "7.6.6",
    "@storybook/test": "7.6.6",
    "@storybook/blocks": "7.6.6",
    "storybook": "7.6.6",
    "styles": "workspace:*"
  },
  "exports": {
    ".": "./nuxt.config.ts"
  }
}

nuxt.config.ts

export default defineNuxtConfig({
  extends: [
    "styles"
  ],
})

tsconfig.json

styles と同じ

.storybook/main.ts

import type { StorybookConfig } from "@storybook-vue/nuxt";

const config: StorybookConfig = {
  stories: [
    "../stories/**/*.mdx",
    "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
  ],
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
  ],
  framework: {
    name: "@storybook-vue/nuxt",
    options: {},
  },
  docs: {
    autodocs: "tag",
  },
};
export default config;

その他

各種例に用いる実装など

components/BaseButton.vue

<template>
    <button class="rounded-md border-2 border-gray-300 bg-white"><slot></slot></button>
</template>

stories/BaseButton.stories.ts

import type { Meta, StoryObj } from '@storybook-vue/nuxt'

import BaseButton from './../components/BaseButton.vue'

const meta = {
  title: 'BaseButton',
  component: BaseButton,
  tags: ['autodocs'],
  render: () => ({
    components: {BaseButton},
    template: `<BaseButton>Button</BaseButton>`
  })

} satisfies Meta<typeof BaseButton>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
  args: {},
}

composables/counter.ts

export const useCounter = (init: number) => {
    const c = ref(init)
    return {
        counter: readonly(c),
        increment: (delta = 1) => {
            c.value += delta
        },
        decrement: (delta = 1) => {
            c.value -= delta
        }
    }
}

utils/message.ts

export const getMessage = () => "message from base"

domain1

主要なファイルのみを示す。設定などはこれまでの例に沿って定義する

domain1 のページへ別アプリケーションから遷移したい場合に URL を記述しなくてもよくするために機能やページ名など抽象化した名前でリンクできるようにする。

<template>
    <NuxtLink :to="url"><slot></slot></NuxtLink>
</template>
<script setup lang="ts">
type NavigationTarget = "Home"
const props = defineProps<{
    to: NavigationTarget
}>()
const toUrl = (_name: NavigationTarget) => "/domain1/"

const url = computed(()=> toUrl(props.to))
</script>

components/IncrementButton.vue

base モジュールのコンポーネントやコンポーザブルを利用したコンポーネント

<template>
    <BaseButton @click="onClick">increment counter: {{ counter.counter }}</BaseButton>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{ init?: number }>(), { init: 0 })
const emits = defineEmits<{change: [value: number]}>()
const counter = useCounter(props.init)
const onClick = ()=> {
    counter.increment()
    emits("change", counter.counter.value)
}
</script>

pages/domain1/index.vue

domain1 が持つページ

<template>
    <div>
        <p>This is Domain1</p>
        <IncrementButton></IncrementButton>
    </div>
</template>

domain2

domain1 と同様に実装する

domain3

domain1 と base に依存している。

pages/domain3/index.vue

<template>
    <div>
        <p class="text-red-200">This is Domain3</p>
        <p>{{ msg }}</p>
        <IncrementButton @change="(v)=> count = v"></IncrementButton>
    </div>
</template>
<script setup lang="ts">
const count = ref(0)
const msg = computed(() => getMessage() + "!".repeat(count.value))
</script>

app

アプリケーションルートのモジュール。base と domain1-3 モジュールに依存している。

app.vue

基本的なレイアウトなどを定義する

<template>
    <div>
        <div class="flex gap-1">
            <NuxtLink to="/">Home</NuxtLink>
            <Domain1Link to="Home">Domain1</Domain1Link>
            <Domain2Link to="Home">Domain2</Domain2Link>
            <Domain3Link to="Home">Domain3</Domain3Link>
        </div>
        <NuxtPage></NuxtPage>
    </div>
</template>

pages/index.vue

<template>
    <p class="text-slate-400">Home</p>
</template>

動作例

pnpm -F="app" dev でアプリケーション全体が動作する。

同様に pnpm -F="domain1" dev で domain1 のみのスコープでアプリケーションが起動する。

app で定義されている、ヘッダーナビゲーションは存在しない。

注意点

Nuxt には auto import という機能がある。これは特定のディレクトリに配置されたファイルから公開された値をどのファイルからでも利用できるというもの。

https://nuxt.com/docs/guide/concepts/auto-imports

Nuxt Layers の仕様上、auto-import の対象となった値は依存している Layers でも利用可能になる。このことは、モジュールの可視性に関係することを意味する。

モジュールから公開する値を適切にコントロールするには、auto-import の対象を公開するもののみに選別するか、auto-import 自体を無効にする必要がある。

簡単のために auto-import を有効にした例を示したが、個人的には auto-import は依存関係を曖昧にするので無効にすることを推奨したい。

終わりに

Nuxt Layers を用いたモジュラモノリスの実装例を示した。
長期間開発したアプリケーションは複数の興味が存在する上に Nuxt で推奨された構造を取るためにそれらが入り混じってしまうこともあると思う。

Nuxt Layers を利用することで各興味ごとにパッケージへと分割できるようになった。
分割されたモジュールが十分に小さいものなら、そのモジュールは素朴に Nuxt のルールに沿った構造になっていても複雑にはなりすぎないことが期待できるし、必要なら更に細かいパッケージへと分割してモジュールやコンポーネントの整理をすれば良い。

また、これら各 Layers はそれぞれ独立させてアプリケーションとして動かすこともできるため、必要に応じて別のアプリケーションとして分割することも視野に入るだろう。

Discussion