Interactive

アーキテクチャ

処理の複雑さや重要度に応じて設計パターンを選択することで、ClaudeCodeは適切な実装を生成できます。弊社では処理ごとに最適な設計段階を判断し、過剰設計を避けながら必要な堅牢性を確保します。

設計段階の判断基準

弊社では処理の複雑さ・変更頻度・ビジネス重要度の3つの軸で設計段階を選択します。同一システム内でも処理により設計段階を変えます。迷ったら段階2から始めて必要に応じて調整します。

段階1:直接実装

単純なCRUD処理でビジネスロジックがほぼない場合に使用します。マスタデータの参照や一覧取得が典型的です。

// API層で直接DBアクセス
app.get('/categories', async (c) => {
  const categories = await prisma.category.findMany()
  return c.json(categories)
})

段階2:Service層への分離

複数の処理が関連する場合や、2箇所以上で同じロジックが出現する場合に使用します。HonoのContextをコンストラクタで受け取り、c.varでORM、c.envで環境変数を参照します。

export class UserService {
  constructor(
    private readonly c: Context,
    private readonly deps = {
      email: new EmailAdapter(c),
      repo: new UserRepository(c),
    }
  ) {}

  async execute(props: { email: string; name: string }) {
    const user = await this.deps.repo.write(props)
    await this.deps.email.sendWelcome(user.email)
    return user
  }
}

段階3:Domain層の導入

複雑なビジネスルール、不変条件の保証、整合性の維持が必要な場合に使用します。金銭や権限に関わる重要な処理は必ずこの段階を経由します。

export class Stock {
  constructor(
    private readonly productId: string,
    private readonly quantity: number
  ) {
    if (quantity < 0) {
      throw new Error('在庫数は負の値にできません')
    }
    Object.freeze(this)
  }

  withDecrease(amount: number): Stock {
    if (amount > this.quantity) {
      throw new Error('在庫不足です')
    }
    return new Stock(this.productId, this.quantity - amount)
  }
}

リファクタリングのタイミング

段階1で実装を始め、2箇所以上で重複したらService層に抽出、複雑なルールが追加されたらDomain層を導入します。

5つのレイヤー構成

弊社では処理を以下の5つのレイヤーに分離して実装します。

  • Interface層 — HTTPリクエスト/レスポンスの処理、認証・認可、バリデーション
  • Application層 — 複数のドメインオブジェクトの調整、トランザクション管理
  • Domain層 — ビジネスルールの実装、不変条件の保証
  • Infrastructure層 — データベースアクセス、外部APIとの通信
  • Library層 — 外部APIのラップライブラリ、複雑な計算モジュール

Repositoryの設計

Repositoryは集約ルートごとに作成し、常にEntityを受け取りEntityを返します。複雑な検索メソッドは定義しません。

同じデータベーステーブルに対しても、目的に応じて複数の集約ルートが存在します。各集約ルートは特定のビジネス不変条件を守るために必要なデータをJOINして保持します。

// 認証用の集約ルート
export class UserAuthEntity {
  constructor(props: {
    id: string
    email: string
    hashedPassword: string
    isActive: boolean
  }) {
    Object.assign(this, props)
    Object.freeze(this)
  }

  canLogin(): boolean {
    return this.isActive
  }
}

RepositoryのメソッドはfindOnefindManywritedeleteの4つに限定します。

export class UserAuthRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findOne(
    where: { id?: string; email?: string }
  ): Promise<UserAuthEntity | null> { ... }

  async findMany(
    where: {}
  ): Promise<UserAuthEntity[]> { ... }

  async write(entity: UserAuthEntity): Promise<void> { ... }

  async delete(id: string): Promise<void> { ... }
}

findActiveUsersWithSubscriptionsearchByNameのような複雑な検索メソッドは作りません。検索条件はfindOnefindManywhere引数で表現します。

Contextのバケツリレー

弊社ではHonoのContextをInterface層からApplication層・Infrastructure層へコンストラクタで渡します。c.varにORMインスタンス、c.envに環境変数が入っており、各層はContextを通じてこれらを参照します。DIコンテナは使用しません。

// Interface層: ContextをServiceに渡す
app.post('/users', async (c) => {
  const service = new UserService(c)
  return c.json(await service.execute(data))
})

Application層ではContextをコンストラクタのデフォルト引数でAdapterやRepositoryに渡します。テスト時はモックを注入できます。

export class UserService {
  constructor(
    private readonly c: Context,
    private readonly deps = {
      email: new EmailAdapter(c),
      repo: new UserRepository(c),
    }
  ) {}

  async execute(props: Props) {
    const user = await this.deps.repo.write(props)
    await this.deps.email.sendWelcome(user.email)
    return user
  }
}

Infrastructure層もContextをコンストラクタで受け取り、c.envから環境変数、c.varからORMを参照します。

export class EmailAdapter {
  constructor(private readonly c: Context) {}

  async sendWelcome(email: string) {
    const resend = new Resend(this.c.env.resendApiKey)
    await resend.emails.send({ to: email, ... })
  }
}

Domain層はContextに依存しません。ビジネスルールは純粋な計算として実装し、技術的詳細から独立させます。

エラーの責務

弊社ではすべての関数の戻り値をT | Errorとし、throwしません。エラーは各層でカスタムエラークラスに詰め替えて上位に返します。カスタムエラーはlib/errors.tsに定義し、instanceofで分岐できるようにします。

// lib/errors.ts
export class NetworkError extends Error {
  readonly name = "NetworkError"
}

export class PaymentFailedError extends Error {
  readonly name = "PaymentFailedError"
}

export class RefundFailedError extends Error {
  readonly name = "RefundFailedError"
}

各層のエラーの責務は以下の通りです。

  • Infrastructure層 — 技術的な原因をカスタムエラーにする(NetworkErrorTimeoutError等)
  • Application層 — Infrastructure層のエラーをアプリケーション的な意味に変換する(PaymentFailedErrorRefundFailedError等。何が起きたのか必ずわかるエラーにする)
  • Interface層 — Application層のカスタムエラーをinstanceofで分岐し、ユーザー向けのレスポンスに変換する(HTTPステータスコード、表示するメッセージ、隠す情報の判断)

Infrastructure層のNetworkErrorは、Application層でPaymentFailedErrorに変換され、Interface層でinstanceof PaymentFailedErrorを判定して「お支払いに失敗しました」というレスポンスになります。