メインコンテンツへ

モジュール内部構造

Astar Management のフロントエンドは app/modules/<domain>/ に46個のドメインモジュールを持ちます。1モジュール = 1業務ドメイン(tag, table, document, ai …)です。このページでは、モジュール1つの中身がどう構成されるか、そしてモジュールをまたぐ import に何が許され何が禁止されているかを説明します。

最小完全例: app/modules/tag/

タグ付け機能を担当する tag モジュールは、規約に最も忠実で、かつ小さく完結しています。新しいモジュールを作るときも、既存モジュールの構造を確認するときも、まずここを見てください。

app/modules/tag/
├── index.ts                        # モジュールの公開 API(barrel export)
├── types/
│   └── index.ts                    # 3層の型定義(下記参照)
├── components/
│   ├── index.ts
│   ├── TagChip.vue                 # Presentational: props/emits のみ
│   └── TagPicker.vue               # Wrapper: foundation コンポーネント + ドメインロジック
├── composables/
│   ├── index.ts
│   ├── useTagManagement.ts         # TanStack Query によるタグ CRUD
│   └── useEntityTags.ts            # エンティティへのタグ付け状態
├── repositories/
│   ├── index.ts
│   └── TagRepository.ts            # useApi() + apiRequest() による API 呼び出し
└── utils/
    ├── tagTreeBuilder.ts
    └── __tests__/tagTreeBuilder.test.ts

index.ts は各サブディレクトリを re-export するだけの薄い barrel です。

// app/modules/tag/index.ts
export * from './types'
export * from './components'
export * from './repositories'
export * from './composables'
export * from './utils/tagTreeBuilder'

必須4分類と optional な3分類

どのモジュールも、最低限 components/ composables/ repositories/ types/ の4分類を持ちます。加えて、必要なモジュールだけが持つ optional な分類が3つあります。

ディレクトリ必須/optional役割採用例
components/必須Vue SFC。Presentational(props/emitsのみ)と Wrapper(foundation + ドメインロジック)の2種全モジュール
composables/必須TanStack Query によるサーバー状態管理 + ビジネスロジック全モジュール
repositories/必須useApi() + apiRequest() による API アクセス層全モジュール
types/必須3層の型定義(下記参照)全モジュール
services/optional(46中10)composable と repository の間に挟む追加のビジネスロジック層ai, document, table, mail など
validators/optional(46中17)Zod によるフォーム/入力バリデーションdocument, organize, billing など
stores/optional(46中5)Pinia ストア(defineTenantStore 経由。詳細は後述)document, notification, storage など
utils/optional純粋関数のヘルパーtagtagTreeBuilder.ts など

services/ を持つのは46モジュール中10個だけです。「composable → repository」がほとんどのモジュールの実際のチェーンで、services/ は業務ロジックが複雑なモジュールにだけ追加される中間層だと理解してください(詳しくは API アクセスの一本道)。

3層の型定義

types/index.ts は次の3層で構成します。tag の例:

// Tier 1: OpenAPI 自動生成型(API契約 — 手で書かない)
export type TagResponse = components['schemas']['TagResponse']
export type CreateTagRequest = components['schemas']['CreateTagRequest']

// Tier 2: Repository インターフェース(データアクセス契約)
export interface TagRepository {
  listTags(): Promise<TagResponse[]>
  createTag(request: CreateTagRequest): Promise<TagResponse>
  deleteTag(id: string): Promise<void>
}

// Tier 3: ドメイン型(UI専用、API由来ではない)
export interface TagTreeNode {
  id: string
  name: string
  children: TagTreeNode[]
  level: number
}

手書きの interface で API 型を代替してはいけません。OpenAPI と乖離します。Tier 1 は必ず components['schemas'][...] から取ります。

モジュールをまたぐ import は4つのサブバレル経由のみ

同一モジュール内の import(相対パス)は自由です。しかし 別モジュールから あるモジュールの中身を参照するときは、必ず次の4つのサブバレルのどれかを経由しなければなりません。

対象import 元
types / utils / services / repositories / validators~/modules/<X>
composables~/modules/<X>/composables
Pinia stores~/modules/<X>/stores
Vue SFC~/modules/<X>/components
// Do: サブバレル経由
import { useTagManagement } from '~/modules/tag/composables'
import type { TagResponse } from '~/modules/tag'

// Don't: 深い import(他モジュールからは禁止)
import { useTagManagement } from '~/modules/tag/composables/useTagManagement'

深いパスへの直接 import は SFC ツリー全体を巻き込んで引っ張ってしまい、Vitest 上で Tailwind v4 の @apply 変換が壊れる原因になります。この規約は現在 no-restricted-importswarn で移行中(既存の違反は grandfathered、新規には追加しないこと)。

~/ 以外のエイリアス(@foundation / @modules / @shared / @ui)は2026-06に廃止されています。使うのは ~/ のみです。

モジュール間の循環importを防ぐ Module-graph guard

サブバレル規約とは別に、特定のモジュール間には 静的 import そのものが禁止 されている組み合わせがあります。理由は SFC の循環参照(strongly-connected component)を防ぐためです。screen モジュールはほぼ全モジュールの画面を描画し、逆に document / ai / table のような重量級モジュールが screen を描画し返すと、Vite SSR / Vitest 環境で「部分評価されたモジュール namespace」によるクラッシュや、テストスイート全体のタイムアウトを引き起こしました(2026-07 に実際に発生した障害)。

禁止されている edge の例:

  • 非 screen モジュール → ~/modules/screen/components の静的 import は禁止(screen 側からのみ許可)
  • screen/**~/modules/table/components の静的 import は禁止(RecordListContainer という最重量 SFC subtree を引き込むため)
  • table/**~/modules/document/components の静的 import は禁止(document/ai editor の subtree ごと引き込むため)
  • screen/**~/modules/ai/composables の静的 import は禁止(barrel cycle になるうえ重い)

これらの seam を越える必要があるときは、defineAsyncComponent遅延 import します。

const TableViewRenderer = Object.assign(
  defineAsyncComponent(() =>
    import('~/modules/screen/components').then((m) => m.TableViewRenderer)
  ),
  { name: 'TableViewRenderer' }, // VTU の stubs/findComponent マッチングを維持
)

import type { ... } は型のみでコンパイル時に消えるため、方向を問わず常に許可されます。

この規約は lint ではなく、app/shared/__tests__/module-graph.guard.test.ts という ビルド失敗ゲート(番人テスト)で強制されています。違反するとテストスイートがビルドを落とすので、CI でも確実に検知されます。

foundation/shared/ の使い分け

モジュール以外に、ドメインを持たない共通コードを置く場所が2つあります。役割が違うので混同しないでください。

ディレクトリ役割中身の例
app/foundation/UI プリミティブui/(shadcn-vue を shadcn-nuxt 経由で ~/foundation/components/ui/ に導入したもの。Button, Dialog, Accordion など)と common/(Astar 独自の共通コンポーネント。IconButton, Callout, PageHeader など)
app/shared/技術インフラAPI クライアント(shared/api/)、SSE(shared/sse/)、通知(shared/notifications/)、i18n 基盤(shared/i18n/)、テナントスコープ storage(shared/utils/tenantScopedStorage.ts)など

foundation/common/ のコンポーネントは auto-import され、prefix なし で使えます(shadcn-nuxtui/ も同様)。この2つは名前が衝突しやすいので、新しいコンポーネントを追加するときは既存の命名と重複していないか確認してください。

判断に迷ったら: 「見た目・操作の部品か」→ foundation/。「業務ドメインに属さないロジック・配線か」→ shared/。「特定の業務ドメインに属するか」→ 該当する modules/<domain>/

次の一歩