Zenn
🤖

自分のコーディングスタイル(TDD/DDD/FP)をAIに叩き込む

に公開1
146

AI に自分のスタイルでコードを書かせたい。

自分のコーディングスタイルを端的にまとめると、たぶんこう。

  • TDD でミニマルにはじめるのが好き
  • でも DDD で段階的にドメインモデリングもしたい
  • 実装は関数型ドメインモデリングに寄せる

これをAIに叩き込みたい。資料を読ませてプロンプトを作って、それにそって実装させる。

エヴァンスのDDDと軽量DDDの2つでやらせてみる。

コードはここ
https://github.com/mizchi/ailab

自分のコーディングスタイルに合わせたプロンプトを作成する

MCPエージェントで検索とURL展開を使える状態で次のように指示をした。(自作ディープサーチみたいなもの)

  • インターネットでDDDについて調べさせる
  • インターネットで関数型ドメインモデリングについて調べさせる
  • インターネットでTDDについて調べさせる
  • プロンプトとして使えるように要点を圧縮しろ
  • 端的に圧縮しろ
  • もっと圧縮しろ

で、でてきたのがこれ。これ自体は中間成果物みたいなもので、これを元にコードを生成させる。

# コーディングプラクティス

## 原則

### 関数型アプローチ (FP)
- 純粋関数を優先
- 不変データ構造を使用
- 副作用を分離
- 型安全性を確保

### ドメイン駆動設計 (DDD)
- 値オブジェクトとエンティティを区別
- 集約で整合性を保証
- リポジトリでデータアクセスを抽象化
- 境界付けられたコンテキストを意識

### テスト駆動開発 (TDD)
- Red-Green-Refactorサイクル
- テストを仕様として扱う
- 小さな単位で反復
- 継続的なリファクタリング

## 実装パターン

### 型定義

```typescript
// ブランデッド型で型安全性を確保
type Branded<T, B> = T & { _brand: B };
type Money = Branded<number, "Money">;
type Email = Branded<string, "Email">;
```

### 値オブジェクト

- 不変
- 値に基づく同一性
- 自己検証
- ドメイン操作を持つ

```typescript
// 作成関数はバリデーション付き
function createMoney(amount: number): Result<Money, Error> {
  if (amount < 0) return err(new Error("負の金額不可"));
  return ok(amount as Money);
}
```

### エンティティ

- IDに基づく同一性
- 制御された更新
- 整合性ルールを持つ

### Result型

```typescript
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
```

- 成功/失敗を明示
- 早期リターンパターンを使用
- エラー型を定義

### リポジトリ

- ドメインモデルのみを扱う
- 永続化の詳細を隠蔽
- テスト用のインメモリ実装を提供

### アダプターパターン

- 外部依存を抽象化
- インターフェースは呼び出し側で定義
- テスト時は容易に差し替え可能

## 実装手順

1. **型設計**
   - まず型を定義
   - ドメインの言語を型で表現

2. **純粋関数から実装**
   - 外部依存のない関数を先に
   - テストを先に書く

3. **副作用を分離**
   - IO操作は関数の境界に押し出す
   - 副作用を持つ処理をPromiseでラップ

4. **アダプター実装**
   - 外部サービスやDBへのアクセスを抽象化
   - テスト用モックを用意

## プラクティス

- 小さく始めて段階的に拡張
- 過度な抽象化を避ける
- コードよりも型を重視
- 複雑さに応じてアプローチを調整

## コードスタイル

- 関数優先(クラスは必要な場合のみ)
- 不変更新パターンの活用
- 早期リターンで条件分岐をフラット化
- エラーとユースケースの列挙型定義

## テスト戦略

- 純粋関数の単体テストを優先
- インメモリ実装によるリポジトリテスト
- テスト可能性を設計に組み込む
- アサートファースト:期待結果から逆算

ddd-sample (伝統的DDD)

上のプロンプトは軽量DDDによってるが、そのうえでこういう指示を与えた

エヴァンスのパターンに従ってDDDを実装して

生成されたコード。

$ wc -l **/*.ts                              
   342 application/customerService.ts
   572 application/orderService.ts
    77 core/result.ts
   210 domain/entities/customer.ts
   331 domain/entities/order.ts
   270 domain/entities/product.ts
    72 domain/repositories/customerRepository.ts
   104 domain/repositories/orderRepository.ts
    94 domain/repositories/productRepository.ts
   230 domain/services/orderService.ts
    40 domain/types.ts
    95 domain/valueObjects/email.ts
   127 domain/valueObjects/ids.ts
    95 domain/valueObjects/money.ts
   132 domain/valueObjects/orderLine.ts
    73 domain/valueObjects/productCode.ts
    87 domain/valueObjects/quantity.ts
   176 infrastructure/repositories/inMemoryCustomerRepository.ts
   301 infrastructure/repositories/inMemoryOrderRepository.ts
   252 infrastructure/repositories/inMemoryProductRepository.ts
   100 test/domain/valueObjects/email.test.ts
   233 test/domain/valueObjects/ids.test.ts
   186 test/domain/valueObjects/money.test.ts
   250 test/domain/valueObjects/orderLine.test.ts
   163 test/domain/valueObjects/productCode.test.ts
   203 test/domain/valueObjects/quantity.test.ts
  4815 total

テスト実行結果。

running 8 tests from ./test/domain/valueObjects/email.test.ts
createEmail - 有効なメールアドレスでEmail値オブジェクトを作成できる ... ok (0ms)
createEmail - 無効なメールアドレスでエラーを返す ... ok (0ms)
emailEquals - 同じメールアドレスの等価性を判定できる ... ok (0ms)
emailEquals - 大文字小文字を区別せずに等価性を判定できる ... ok (0ms)
getDomain - メールアドレスのドメイン部分を取得できる ... ok (0ms)
getLocalPart - メールアドレスのローカル部分を取得できる ... ok (0ms)
maskEmail - メールアドレスをマスクできる ... ok (0ms)
maskEmail - 短いローカル部分を持つメールアドレスを適切にマスクできる ... ok (0ms)

// ... 中略 ...

ok | 65 passed | 0 failed (253ms)

まあDDDでよくあるコード例がでてきた。自力でテストが通るまで、2タスクで5分程度。$3ぐらい。

実装対象がどうこうより、パターンで認識出来ているのに強みがありそう。

ddd-sample-light (軽量DDD)

DDD信者には怒られるが、エヴァンス準拠はやっぱ重いので、 実装に寄せた軽量DDDでサンプルを作れ と指示。

$ wc -l **/*.ts
   81 src/adapters/inMemoryTaskRepository.ts
  218 src/app.ts
   45 src/core/result.ts
   18 src/domain/taskRepository.ts
  159 src/domain/task.ts
   40 src/domain/types.ts
  206 test/adapters/inMemoryTaskRepository.test.ts
  311 test/app.test.ts
  244 test/domain/task.test.ts
 1322 total
running 7 tests from ./test/adapters/inMemoryTaskRepository.test.ts
InMemoryTaskRepository - タスクを保存して取得できる ... ok (6ms)
InMemoryTaskRepository - 存在しないIDで検索すると null を返す ... ok (0ms)
InMemoryTaskRepository - 複数のタスクを保存して全件取得できる ... ok (1ms)
InMemoryTaskRepository - タスクを削除できる ... ok (0ms)
InMemoryTaskRepository - フィルターで検索できる ... ok (0ms)
InMemoryTaskRepository - seed機能を使用して複数のタスクを一括設定できる ... ok (0ms)
InMemoryTaskRepository - clear機能ですべてのタスクを削除できる ... ok (0ms)

// ... 中略 ...

ok | 30 passed | 0 failed (116ms)

言っておくが全然実装対象が違うので、比較にならないが、よくできてる。$1.2。

これも虚無みたいなドメインを実装してるが、まあ雛形には使える。

まとめ

AIにとっても(そしてたぶん、人間にとっても)書くのはそんなに負荷にならない。大変なのは読む方。

エヴァンスの DDD を戦術レベルとはいえ真面目にやるのは大変だが、たぶんAI前提なら型に嵌めたほうが効率はよくなる。エヴァンスの、と一言いえばAIには通じる。

とはいえ小さなツールを作るのが多い立場で大変すぎる。普段は軽量DDDプロンプトでやっていくことにする。

以下、AIによる自己説明。


TL;DR(要約)

  • 伝統的なエヴァンス式DDDを関数型プログラミングとTDDで軽量化
  • Result型とブランデッド型で型安全性を高めた実装パターン
  • 純粋関数でドメインロジックを実装し、テスト容易性を向上
  • Adapterパターンを採用して外部依存を抽象化
  • ddd-sampleとddd-sample-lightの2つのアプローチを比較実装

サンプルプロジェクトのテスト実行結果

ddd-sample (伝統的DDD)

running 8 tests from ./test/domain/valueObjects/email.test.ts
createEmail - 有効なメールアドレスでEmail値オブジェクトを作成できる ... ok (0ms)
createEmail - 無効なメールアドレスでエラーを返す ... ok (0ms)
emailEquals - 同じメールアドレスの等価性を判定できる ... ok (0ms)
emailEquals - 大文字小文字を区別せずに等価性を判定できる ... ok (0ms)
getDomain - メールアドレスのドメイン部分を取得できる ... ok (0ms)
getLocalPart - メールアドレスのローカル部分を取得できる ... ok (0ms)
maskEmail - メールアドレスをマスクできる ... ok (0ms)
maskEmail - 短いローカル部分を持つメールアドレスを適切にマスクできる ... ok (0ms)

// ... 中略 ...

ok | 65 passed | 0 failed (253ms)

ddd-sample-light (軽量DDD)

running 7 tests from ./test/adapters/inMemoryTaskRepository.test.ts
InMemoryTaskRepository - タスクを保存して取得できる ... ok (6ms)
InMemoryTaskRepository - 存在しないIDで検索すると null を返す ... ok (0ms)
InMemoryTaskRepository - 複数のタスクを保存して全件取得できる ... ok (1ms)
InMemoryTaskRepository - タスクを削除できる ... ok (0ms)
InMemoryTaskRepository - フィルターで検索できる ... ok (0ms)
InMemoryTaskRepository - seed機能を使用して複数のタスクを一括設定できる ... ok (0ms)
InMemoryTaskRepository - clear機能ですべてのタスクを削除できる ... ok (0ms)

// ... 中略 ...

ok | 30 passed | 0 failed (116ms)

1. DDDと関数型プログラミングの融合

伝統的DDDの課題

エヴァンスのDDDは強力な設計手法だが、実装が複雑になりがちです。ddd-sampleでは伝統的なアプローチを示しています:

// ddd-sample/domain/valueObjects/money.ts からの例(抜粋推測)
export function createMoney(amount: number): Result<Money, ValidationError> {
  if (amount < 0) {
    return err(new ValidationError("金額は0以上である必要があります"));
  }
  // 小数点以下の精度チェックなど
  return ok(amount as Money);
}

これに対して、軽量版のddd-sample-lightでは関数型プログラミングの要素を取り入れています:

// ddd-sample-light/src/domain/task.ts からの例
export function createTask(
  id: TaskId,
  title: string,
  priority: Priority,
  createdBy: UserId,
  description?: string,
): Result<Task, ValidationError> {
  // バリデーション
  if (!title.trim()) {
    return err(new ValidationError("タイトルは必須です"));
  }

  const now = new Date();

  // タスクオブジェクトの作成
  return ok({
    id,
    title,
    description,
    status: "pending" as TaskStatus,
    priority,
    createdBy,
    createdAt: now,
    updatedAt: now,
  });
}

型安全性の向上

ddd-sample-lightではブランデッド型を活用して型安全性を高めています:

// ddd-sample-light/src/domain/types.ts
export type Branded<T, Brand> = T & { readonly _brand: Brand };

export type TaskId = Branded<string, "TaskId">;
export type UserId = Branded<string, "UserId">;

これにより、文字列型の混同を防ぎ、コンパイル時にIDの型間違いを検出できます。

2. Result型によるエラーハンドリング

両実装例で共通して使われているのがResult型です。これは値とエラーを型安全に扱うためのパターンです:

// ddd-sample-light/src/core/result.ts
export type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

export function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

export function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

このパターンにより、戻り値の型から「エラーハンドリングが必要」という情報が明示され、型システムを活用した安全なコードが書けます。

テスト実行結果からも分かるように、各関数の戻り値のチェックが一貫したパターンで行われています:

// ddd-sample-lightのテスト例
TaskApp - タスクを作成できる ... ok (0ms)
TaskApp - タスクステータスを更新できる ... ok (0ms)

3. 不変性を重視した値オブジェクト

ddd-sampleの値オブジェクト実装では、不変性が重視されています:

// ddd-sample/test/domain/valueObjects/orderLine.test.ts からの例
test("changeQuantity - 数量を変更した新しい注文明細を作成できる", () => {
  // ...
  if (orderLineResult.isOk()) {
    const newQuantity = 5;
    const changedResult = changeQuantity(orderLineResult.value, newQuantity);

    // ...
    if (changedResult.isOk()) {
      // 元のオブジェクトは変更されていない(不変性)
      expect(Number(orderLineResult.value.quantity)).toBe(params.quantity);
    }
  }
});

ddd-sample-lightでも同様のパターンが使われています:

// ddd-sample-light/src/domain/task.ts からの例
export function changeTaskStatus(
  task: Task,
  newStatus: TaskStatus,
): Result<Task, ValidationError> {
  // 業務ルール
  if (task.status === "completed" && newStatus !== "cancelled") {
    return err(new ValidationError("完了したタスクは再開できません"));
  }

  // 不変更新パターン: 新しいオブジェクトを返す
  return ok({
    ...task,
    status: newStatus,
    updatedAt: new Date(),
  });
}

テスト実行結果を見ると、この不変性が様々なシナリオでテストされていることが分かります:

// ddd-sample-lightのテスト例
changeTaskStatus - 完了したタスクはキャンセルのみ可能 ... ok (0ms)
changeTaskStatus - キャンセルされたタスクは変更不可 ... ok (0ms)

4. TDDによる実装アプローチ

ddd-sampleの実装では、テストファーストの考え方が反映されています:

// ddd-sample/test/domain/valueObjects/money.test.ts からの例
test("createMoney - 有効な金額でMoney値オブジェクトを作成できる", () => {
  const validAmounts = [0, 1, 10.5, 100.75, 1000, 9999.99];

  for (const amount of validAmounts) {
    const moneyResult = createMoney(amount);
    expect(moneyResult.isOk(), `${amount}は有効な金額のはず`).toBe(true);

    if (moneyResult.isOk()) {
      expect(Number(moneyResult.value)).toBe(amount);
    }
  }
});

各テストケースでは、ドメインの仕様(金額が0以上、小数点以下2桁まで、など)が明示的に表現されています。これによりドメインの制約が明確になり、テスト自体が「生きたドキュメント」として機能します。

テスト結果を見ると、ddd-sampleでは65のテスト、ddd-sample-lightでは30のテストが実行されており、いずれも全てパスしています。これらのテストはドメインルールを明確に表現し、実装の正しさを保証しています。

5. Adapterパターンの活用

ddd-sample-lightではAdapterパターンを採用しています。このパターンはテスト実行結果からも確認できます:

running 7 tests from ./test/adapters/inMemoryTaskRepository.test.ts
InMemoryTaskRepository - タスクを保存して取得できる ... ok (6ms)
InMemoryTaskRepository - 存在しないIDで検索すると null を返す ... ok (0ms)

これは以下のような実装に基づいています:

// ddd-sample-light/src/domain/taskRepository.ts (推測)
export interface TaskRepository {
  findById(id: TaskId): Promise<Result<Task | null, Error>>;
  findAll(): Promise<Result<Task[], Error>>;
  save(task: Task): Promise<Result<Task, Error>>;
  // ...
}

// ddd-sample-light/src/adapters/inMemoryTaskRepository.ts
export class InMemoryTaskRepository implements TaskRepository {
  private tasks: Map<string, Task> = new Map();

  async findById(id: TaskId): Promise<Result<Task | null, Error>> {
    const task = this.tasks.get(String(id)) || null;
    return ok(task);
  }
  // ...
}

このパターンにより、外部依存(データベースなど)からドメインロジックを分離し、テスト時には簡易的なインメモリ実装に差し替えることができます。

6. 伝統的DDDと軽量DDDの比較

ddd-sample(伝統的アプローチ)

  • レイヤー構造が明確(domain, application, infrastructure)
  • エンティティ、値オブジェクト、リポジトリなどDDDの用語に忠実
  • 複雑なドメインを扱うのに適している
  • より厳密な値オブジェクトの実装(Money, Quantity等)
  • テスト数が多い:65テスト

ddd-sample-light(軽量アプローチ)

  • よりフラットな構造(src/domain, src/adapters)
  • 純粋関数でドメインロジックを実装
  • シンプルで理解しやすいコード構造
  • 小〜中規模のプロジェクトに最適
  • テスト数が少ない:30テスト

両方のアプローチでテストは全て成功しており、DDDの原則を守りながらも、それぞれ異なる複雑さと柔軟性のバランスを取っています。

まとめ

関数型ドメインモデリングとTDDを組み合わせることで、DDDの複雑さを軽減しつつ、その恩恵を得ることができます。主なポイントは:

  1. ドメインを純粋関数とイミュータブルなデータで表現
  2. Result型でエラーハンドリングを型安全に実装
  3. テストファーストでドメインの仕様を明確に表現
  4. Adapterパターンで外部依存を抽象化
  5. ブランデッド型で型安全性を向上

これらのアプローチは、特にTypeScriptのような静的型付け言語との相性が良く、堅牢なドメイン駆動設計の実装を助けます。実装の複雑さとドメインの表現力のバランスを考慮し、プロジェクトに最適なアプローチを選択することが重要です。

テスト実行結果が示すように、両方のアプローチともに高い信頼性を持ち、ドメインルールを正確に実装していることが確認できます。これはTDDと関数型アプローチの組み合わせがDDDの実践において効果的であることを示しています。

146

Discussion

ログインするとコメントできます