有名テック企業の技術ブログを、ひとつのフィードで。
フィード
32件
はじめに カミナシでエンジニアをしている Shimmy です。今は新規プロダクト開発をしています。 0→1の開発設計では「コードベースの持続可能性」と「短期的なデリバリー速度」の両方が重要です。そのバランスを取りながら、AIの力を最大限活かせるアーキテクチャを考えてきました。 その過程で分かった設計原則というのは、AIを活用する前から変わらないものでした。 この記事では、AIの力を引き出す設計と、その設計を決定論的に守らせる仕組みついて話します。 補足: TanStack Start(フルスタックReactフレームワーク)を利用しており、フロントエンドとバックエンドが同一コードベースにあります。 AIの力を引き出す設計の3つの条件 自分のプロダクトの設計原則は次の3つです。 関心の分離: 関心事ごとにファイルをまとめる。AIのコンテキストに載せやすく、並列開発でもコンフリクトしにくい 価値の高いテスト: テストは数より質。振る舞い(Input→Output)を検証する出力値ベーステストと純粋関数の組み合わせで、モック不要でリファクタリングに強いテストが書ける 依存方向の決定: 層ごとに「何に依存してよいか」が決まっていれば、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つの柱が挙げられています。 退行(リグレッション)に対する保護 リファクタリングへの耐性 迅速なフィードバック 保守しやすさ 退行保護、リファクタリング耐性、迅速なフィードバックの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); }); <span class="synI