アーキテクチャ
処理の複雑さや重要度に応じて設計パターンを選択することで、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のメソッドはfindOne、findMany、write、deleteの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> { ... }
}
findActiveUsersWithSubscriptionやsearchByNameのような複雑な検索メソッドは作りません。検索条件はfindOneやfindManyのwhere引数で表現します。
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層 — 技術的な原因をカスタムエラーにする(
NetworkError、TimeoutError等) - Application層 — Infrastructure層のエラーをアプリケーション的な意味に変換する(
PaymentFailedError、RefundFailedError等。何が起きたのか必ずわかるエラーにする) - Interface層 — Application層のカスタムエラーを
instanceofで分岐し、ユーザー向けのレスポンスに変換する(HTTPステータスコード、表示するメッセージ、隠す情報の判断)
Infrastructure層のNetworkErrorは、Application層でPaymentFailedErrorに変換され、Interface層でinstanceof PaymentFailedErrorを判定して「お支払いに失敗しました」というレスポンスになります。