カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

AIのための特別なアーキテクチャはいらない ― 0→1開発で実践した設計原則とガードレール

はじめに

カミナシでエンジニアをしている Shimmy です。今は新規プロダクト開発をしています。 0→1の開発設計では「コードベースの持続可能性」と「短期的なデリバリー速度」の両方が重要です。そのバランスを取りながら、AIの力を最大限活かせるアーキテクチャを考えてきました。

その過程で分かった設計原則というのは、AIを活用する前から変わらないものでした。 この記事では、AIの力を引き出す設計と、その設計を決定論的に守らせる仕組みついて話します。

補足: TanStack Start(フルスタックReactフレームワーク)を利用しており、フロントエンドとバックエンドが同一コードベースにあります。

AIの力を引き出す設計の3つの条件

自分のプロダクトの設計原則は次の3つです。

  1. 関心の分離: 関心事ごとにファイルをまとめる。AIのコンテキストに載せやすく、並列開発でもコンフリクトしにくい
  2. 価値の高いテスト: テストは数より質。振る舞い(Input→Output)を検証する出力値ベーステストと純粋関数の組み合わせで、モック不要でリファクタリングに強いテストが書ける
  3. 依存方向の決定: 層ごとに「何に依存してよいか」が決まっていれば、AIは迷わない。さらに静的解析で強制できる

上記の条件は、AIのために特別に取り入れた考えではありません。今まで良い設計とされていたものが、結果的にAIとの協働でさらに力を発揮するようになりました。 ここからは、各条件を深掘りして、採用した設計パターンと具体的な構成を見ていきます。

関心の分離

AIとの相性

関連ファイルが1つのディレクトリに集約されていると、AIのコンテキストに載せやすくなります。「このディレクトリを読んで、こう修正して」で済みます。散らばっていると、AIは修正箇所を探し回ってコンテキストウィンドウを無駄に消費します。 並列開発でもコンフリクトしにくいです。git worktree で複数のAIエージェントを同時に走らせても、関心事が分かれていれば触るファイルが重ならず、安全にマージできます。

Feature-Firstの構成

結合は悪ではありません。結合の強さと距離のバランスを取ることが大切です。結合が強いなら距離を短くし、距離が長いなら結合を弱くする。この考え方を Feature-First の構成に落とし込んでいます。

features/
├── 関心事A/
│   ├── domain/           # Functional Core: 純粋関数のみ
│   ├── infrastructure/   # Imperative Shell: DBアクセスなどI/O
│   ├── server/           # API層: domainとinfrastructureを組み立てるエンドポイント
│   ├── components/       # 複数ページで再利用するUI
│   ├── hooks/            # カスタムフック
│   └── index.ts          # Public API
├── 関心事B/
│   ├── domain/
│   ├── infrastructure/
│   ├── server/
│   └── index.ts
└── ...
  • Feature内部 → 高凝集: 同じ関心事に関するコード(ビジネスロジック、DB操作、APIエンドポイント)が1つのディレクトリにまとまっています。統合強度は高いが、距離が短いので問題にならないです。
  • Feature間 → 疎結合: 各featureは index.ts を通じてPublic APIだけを公開し、domain/やinfrastructure/の内部構造は隠蔽する。外部からは index.ts 経由でのみアクセスするので、統合強度はコントラクト結合に留まる。距離が長い分、結合を最小限に抑えています。

domain、infrastructure、serverがそれぞれ1つのFeature内にあります。関心事Aに関するコードはすべてこのディレクトリに集約されているので、AIに「この機能を修正して」とfeatureを渡せば、そのディレクトリで完結します。

参考: 『ソフトウェア設計の結合バランス』(Vlad Khononov)

Public API境界

各featureの index.ts は、外部に公開するものだけをexportします。

// features/xxx/index.ts

// 外部から使う必要があるものだけを公開
export { calculateSomething, type Quantity } from "./domain/calculations";
export { useSaveRecord } from "./hooks/use-save-record";
export { recordsQueryOptions } from "./queries";

// 外部から使わないものは公開しない
// domain/の内部ヘルパー、infrastructure/のDB操作詳細 など

実装の詳細は外部に公開していません。外部からはこの index.ts 経由でのみアクセスできます。featureの内部をどれだけリファクタリングしても、このPublic APIのシグネチャが変わらなければ外部のコードは影響を受けません。

featureを横断するケース

Feature-Firstで分離したときに、最も考えるべきなのは複数のfeatureにまたがるケースについてです。横断が必要なケースは2つのパターンで対応しています。

パターン1: shared/ — 共通のビジネスロジックが必要な場合

複数のfeatureが使う計算ロジックは shared/lib/ に純粋関数として切り出します。

src/
├── features/
│   ├── 関心事A/
│   ├── 関心事B/
└── shared/
    └── lib/date.ts   # 複数featureが使う共通の純粋関数

shared/features/ に依存しません。依存の方向は常に features/shared/ の一方向です。あくまで共通の純粋関数を提供するだけの層であり、shared/ が特定のfeatureの内部を知ることはありません。

パターン2: routes/ - 1つのページで複数featureが必要な場合

複数のfeatureのデータを組み合わせて1つのページを作るケースはどうするのか。ここで routes/ 層が登場します。TanStack Startの routes/ は、サーバーサイドでのデータ取得とUI描画の両方を1つのページとしてまとめる層です。各ページのコンポーネントやページ固有のロジックを持ちます。 (Next.jsの app/ ディレクトリでも同様の構成が取れるはずです。)

src/
├── features/
│   ├── 関心事A/
│   │   └── index.ts
│   ├── 関心事B/
│   │   └── index.ts
└── routes/
    └── xxx/
        └── $id/
            ├── index.tsx      # ページコンポーネント
            ├── -lib/          # このページのロジック
            ├── -components/   # このページのUI
            └── -hooks/        # このページのフック

先ほど話したように、各featureは index.ts を通じてPublic APIだけを公開し、互いに直接依存しません。 routes層でそれらを組み合わせます。ページコンポーネントが各featureからデータを取得し、組み合わせてUIを描画します。

// routes/xxx/$id/index.tsx

// 各featureが公開するデータ取得関数を使って並行取得
const [recordsA, recordsB, recordsC] = await Promise.all([
  fetchFeatureA(id, date),
  fetchFeatureB(id, date),
  fetchFeatureC(id),
]);

// 複数featureのデータを組み合わせてUIを描画
const rows = buildRowData(recordsA, recordsB, recordsC);
return <DataGrid rows={rows} ... />;

各featureは互いの存在を知りません。どのfeatureを組み合わせるかを決めるのはroutes層(ページ)の責務です。この構造により、Feature-Firstの疎結合を維持したまま、横断的なページを柔軟に構築できています。

価値の高いテスト

価値の高い単体テストとは

ここでは単体テストに絞って話します。テストは数より質が重要です。

「単体テストの考え方/使い方」では価値の高い単体テストの条件として4つの柱が挙げられています。

  1. 退行(リグレッション)に対する保護
  2. リファクタリングへの耐性
  3. 迅速なフィードバック
  4. 保守しやすさ

退行保護、リファクタリング耐性、迅速なフィードバックの3つはトレードオフの関係にあり、同時に最大化できません。ただしリファクタリング耐性は「あるかないか」の二値なので、まずこれを確保した上で残りのバランスを取ります。リファクタリング耐性がないテスト(偽陽性が多いテスト)はテストへの信頼を損ない、やがて無視されるようになるからです。

参考: 『単体テストの考え方/使い方』(Vladimir Khorikov)

出力値ベーステスト

リファクタリング耐性を確保するために関数の内部実装ではなく振る舞い(Input→Output)を検証するテストを行っています。これが出力値ベーステストです。関数にInputを入れて、返ってきたOutputを検証する。テストが検証するのは「関数が内部でどう動いているか」ではなく「どんな入力に対してどんな出力を返すか」です。内部実装に依存しないので、振る舞いが変わらない限りリファクタリングしてもテストは通り続けます。

domain層の純粋関数(補足: コードは抽象/単純化しています

// features/xxx/domain/calculations.ts

/** 進捗率を計算する純粋関数 */
const progressRate = (
  actualTotal: number,
  targetQuantity: number,
): ProgressRate | null => {
  if (targetQuantity === 0) return parseProgressRate(0);

  const ratio = actualTotal / targetQuantity;
  const percentage = roundToOneDecimal(ratio * 100);

  return parseProgressRate(percentage);
};

この関数に対する出力値ベーステスト:

describe("progressRate", () => {
  it("実績と目標から進捗率を返す", () => {
    expect(progressRate(75, 100)).toBe(75.0);
  });

  it("100%を超える場合も正しく計算する", () => {
    expect(progressRate(120, 100)).toBe(120.0);
  });

  it("目標が0のとき0を返す", () => {
    expect(progressRate(50, 0)).toBe(0);
  });
});

DBを初期化する必要もなければ、クリーンアップする必要もありません。モックの設定もDIの構築もありません。テストの本質である振る舞い(Input→Output)に集中できます。

Functional Core, Imperative Shell(FCIS)

ただし、出力値ベーステストを自然に書くには、テスト対象が純粋関数である必要があります。モックやDIが必要な関数は、それだけで内部実装への依存が生まれるからです。そこでdomain層を純粋関数だけで構成するために「Functional Core, Imperative Shell(FCIS)」パターンを採用しています。純粋関数でビジネスロジックを書き(Functional Core)、I/O操作はシェル側に追いやる(Imperative Shell)という考え方です。

Functional Core, Imperative Shell

このプロダクトの設計では、 FCISを以下の3層で実現しています。

  • domain/ : Functional Core。純粋関数のみで構成。
  • infrastructure/: Imperative Shell。DBアクセスなどI/O操作を担当。domainに依存してよい
  • server/: API層。domainとinfrastructureを組み立ててエンドポイントを提供する。両方に依存してよい

domain層を純粋に保つことで、ビジネスロジックのテストからI/Oを排除でき、モックやDIコンテナが不要になります。

参考: 「Boundaries」(Gary Bernhardt) , 『Domain Modeling Made Functional』(Scott Wlaschin)

まとめ

純粋関数でdomain層を構成すれば、テスト対象に外部依存がないのでモックやDIの設定が不要になり、出力値ベーステストを書きやすい構造が整います。リファクタリングを行っても、既存のテストがそのまま正しさの検証として機能します。テストの実行もDBやネットワークに依存しないのですぐに終わるので、試行錯誤ループを高速に回せます。

設計を決定論的に守らせる

3つの設計原則を見てきましたが、AIに実装を任せると、これらの原則を無視したコードを生成することがあります。CLAUDE.mdやAGENT.mdにルールを書けば、AIはある程度従います。しかし、これは確率的です。100%守るとは限りません。では、人間やAIのレビューで指摘すればいいかというと、それでは遅いです。レビューの時点で設計違反に気づいても、書き直しのコストが発生します。

それに対して、静的解析なら決定的でルールに違反していれば必ずエラーになります。さらに、git commitの時点で弾ければ、AIは即座にエラーメッセージを読んで修正し、再試行できます。フィードバックをなるべく開発者の手元に近いところで返す。これはShift-Left Testingの考え方です。

Why Does DevOps Recommend Shift-Left Testing Principles?

dependency-cruiserで依存方向を強制する

github.com

dependency-cruiser は、import文を静的解析して依存関係のルール違反を検出するツールです。前述のFCISの依存方向や、Feature間の疎結合をdependency-cruiserのルールとして定義しています。私のプロダクトでは13個のルールを定義していますが、今回は設計原則を表現している4つのルールについて説明します。

ルール1: 純粋なdomain層

domain/からinfrastructure/やserver/をimportするとエラーになります。

// .dependency-cruiser.cjs
{
  name: "domain-no-external-dependencies",
  comment: "domain/は純粋関数のみで構成され、infrastructure/やserver/に依存してはいけない",
  severity: "error",
  from: { path: "^src/features/[^/]+/domain/" },
  to: {
    path: [
      "^src/features/[^/]+/infrastructure/",
      "^src/features/[^/]+/server/",
    ],
  },
},

さらに、domain層がI/O系ライブラリに直接依存することも禁止しています。 私のプロダクトでは ORMに drizzle-orm を利用しており、そこへの依存を禁止しています。

{
  name: "domain-no-external-io",
  comment: "domain/はI/O操作を行うライブラリに依存してはいけない",
  severity: "error",
  from: { path: "^src/features/[^/]+/domain/" },
  to: { path: ["node_modules/(drizzle-orm|@tanstack/start)"] },
},

ルール2: Feature間の疎結合

あるfeatureから別のfeatureを直接importするとエラーになります。

{
  name: "no-cross-feature-imports",
  comment: "features間の直接依存は禁止。shared/経由で共有すること。",
  severity: "error",
  from: { path: "^src/features/([^/]+)/" },
  to: {
    path: "^src/features/([^/]+)/",
    pathNot: "$1", // 同一feature内はOK
  },
},

ここでは同一feature内の参照は許可しつつ、別featureへの参照だけを禁止しています。

ルール3: Public API境界

featureの外部から、index.tsを通さずに内部ディレクトリを直接参照するとエラーになります。ここでは、ページを定義するroutes/層からの参照を制限しています。

{
  name: "features-import-via-index",
  comment: "featureの外部からはindex.ts経由のみ許可",
  severity: "error",
  from: { path: "^src/routes/" },  // ページ定義層
  to: {
    path: "^src/features/[^/]+/(?!index\\\\.ts$)",
    dependencyTypesNot: ["type-only"],
  },
},

ルール4: shared/ の独立性

shared/からfeatures/をimportするとエラーになります。依存の方向は常に features/shared/ の一方向です。

{
  name: "shared-no-feature-dependency",
  comment: "shared/はfeatures/に依存してはいけない",
  severity: "error",
  from: { path: "^src/shared/" },
  to: { path: "^src/features/" },
},

これらの4ルールに加えて、infrastructure層の制約や、共有されるshared/の循環依存防止、server層のRLSバイパス防止など、全部で13個のルールを定義しています。設計思想をドキュメントではなく「静的解析のルール」として表現しています。

knipとBiome

dependency-cruiserだけでは足りない部分があるので、別のツールで補完しています。

knip

knip.dev

未使用コードを検出するツールです。AIにリファクタリングさせると、使われなくなったexportやファイルが残りがちです。人間なら「これもう使ってないな」と気づくかもしれませんが、毎回全ファイルをチェックするのは現実的ではないので、knipに検出させています。

Biome

biomejs.dev

Linter & Formatterです。AIが生成しがちな any 型の混入、非 null アサーション、await されていない Promise などを弾いています。AIは動くコードを書きますが、型安全性を犠牲にして any で逃げることがあるのでBiomeで検出しています。

Git hooksで統合する

github.com

これらのツールをlefthookでGit hooksに統合しています。

# lefthook.yml

pre-commit:
  parallel: true
  commands:
    biome:
      run: pnpm check
      stage_fixed: true
    depcruise:
      root: "apps/web/"
      run: pnpm --filter web depcruise

pre-push:
  parallel: true
  commands:
    types:
      run: pnpm check-types
    knip:
      run: pnpm knip
    test:
      run: pnpm test

開発フロー

全体の開発フローは次のようになります。

開発フロー

  • pre-commit: Biome による lint/format と dependency-cruiser による設計ルールチェックを実行します。コミットのたびに走るので、ここは早く終わるツールを置いています。
  • pre-push: TypeScript の型チェック、knip による未使用コード検出、テスト実行を行います。これらは実行に時間がかかるので、pushのタイミングに寄せています。

このプロセスを通ったコードは、設計に沿っていると信頼できます。 アウトプットをレビューで検査するのではなく、プロセスそのものを信頼できるものにする。 これが根底にある考え方です。

まとめ

AIの力を引き出したアーキテクチャを振り返ると、どれもAI以前から大事だと言われていた考えでした。

  • 関心の分離: 関心事ごとにファイルをまとめる → AIのコンテキストに載せやすく、並列開発でもコンフリクトしにくい
  • 価値の高いテスト: domain層を純粋関数で構成し、出力値ベースの単体テストを書く → モック不要でリファクタリングに強い。質の高いテストを書きやすい構造が整う
  • 依存方向の決定: 層ごとのルールを明確にする → AIが迷わず、静的解析で機械的に強制できる

これらはAIのために特別に取り入れた考えではありません。今まで良い設計とされてきたものが、AIと協働することでさらに力を発揮するようになっただけです。 設計をAIに守らせる仕組みも同様です。CLAUDE.mdなどで複雑にルールを作り込むより、dependency-cruiser、knip、Biomeといった責務が明確で Simple なツールをGit hooksで統合するほうが保守性も高いです。フィードバックをできるだけ開発者の手元に近いところに寄せることで、設計は決定論的に守られます。

良い設計をちゃんと守ること、それがAIを最大限活用することにつながります。

おわりに

この記事で説明した新規プロダクトを一緒に開発してくださる方を絶賛募集中です。もっと良い設計があるぞ、という方はぜひカジュアル面談しましょう!

herp.careers

今週木曜 4/16 の「カミナシ Tech Night #3」でこの内容をもとに登壇します。残り2名空き枠があるので、ぜひ来てください!

さらに TSKaigi では「実践TanStack Start: 新規プロダクトを開発して確立した、サーバーとクライアント境界の設計パターン」というタイトルで登壇します! tskaigi.hatenablog.com