Domain層
ビジネスロジックをカプセル化することで、ClaudeCodeは技術的詳細から独立した純粋なビジネスルールを生成できます。弊社ではドメインエキスパートの言葉をそのままコードに反映させることで、意図が明確な実装を実現します。
弊社の推奨ルール
- 不変性の徹底 - すべてのドメインオブジェクトはimmutableとして実装
- 汎用的な命名 - メソッド名は一般的で再利用しやすい名前を採用(User、execute等)
- 技術的詳細の排除 - DBやAPIの知識をドメイン層から完全に分離
- ルールの集約 - 同じビジネスルールは1箇所だけに記述
- 型による制約 - 値オブジェクトで不正な値の混入を防ぐ
ClaudeCodeでの利用
エンティティの作成依頼
一意性を持つビジネスオブジェクトが必要な場合
ユーザーエンティティを作成して。メールアドレスの変更履歴を保持する機能を含む
値オブジェクトの作成依頼
ビジネスルールを持つ値を型安全に扱いたい場合
金額を表す値オブジェクトを作成して。通貨と金額を持ち、負の値は許可しない
ドメインサービスの実装依頼
複数のエンティティにまたがるビジネスロジックが必要な場合
在庫移動のドメインサービスを実装して。移動元と移動先の在庫数を適切に更新する
集約の設計依頼
関連するエンティティ群を一貫性を保って管理したい場合
注文集約を設計して。注文と注文明細の整合性を保証する
データとロジックの分離ではなくカプセル化を使う
弊社では、データとロジックを別々に管理するのではなく、クラス内に一緒にカプセル化します。ビジネスルールはデータを持つクラス自身に実装することで、コードの凝集度を高めます。
データとロジックが分離(避けるべき)
データを保持するクラスと、そのデータを操作するロジックが別々の場所にあるパターンです。これによりロジックがあちこちに散らばり、保守が困難になります。
// データだけのクラス
export class User {
id: string
email: string
name: string
}
// ロジックが別の場所に散在
export class UserService {
changeEmail(user: User, email: string) {
// バリデーションロジックがサービス層に漏れている
if (!this.isValidEmail(email)) {
throw new Error('無効なメールアドレス')
}
user.email = email
}
}
データとロジックをカプセル化(推奨)
データとそれを操作するロジックを同じクラス内にまとめるパターンです。関連する処理が1箇所に集約され、理解しやすく保守しやすいコードになります。
// ビジネスロジックを持つクラス
export class UserEntity { // 管理者でも顧客でも汎用的にUser
constructor(props: {
id: string
email: EmailEntity
name: string
}) {
Object.assign(this, props)
Object.freeze(this)
}
// 汎用的なメソッド名を使用
withEmail(email: EmailEntity): UserEntity {
// メール変更のビジネスルール
if (this.email.equals(email)) {
throw new Error('同じメールアドレスには変更できません')
}
return new UserEntity({ ...this, email })
}
}
値オブジェクトによる型安全性
プリミティブ型の問題点
弊社では、ビジネス的な意味を持つ値はプリミティブ型ではなく値オブジェクトとして表現します。
// プリミティブ型では制約を表現できない
function transfer(
fromAccountId: string, // どんな文字列でも受け入れてしまう
toAccountId: string, // 引数の順番を間違えやすい
amount: number // 負の値や小数点以下の扱いが不明確
) {
// ビジネスルールが関数内に散在
if (amount <= 0) throw new Error('金額は正の値である必要があります')
if (fromAccountId === toAccountId) throw new Error('同一口座への送金はできません')
}
// 呼び出し時にミスが起きやすい
transfer(toId, fromId, -1000) // 引数の順番間違い、負の値
値オブジェクトによる解決
// 値オブジェクトで制約を表現
export class AccountIdEntity {
constructor(props: { value: string }) {
if (!props.value.match(/^ACC\d{10}$/)) {
throw new Error('口座IDの形式が不正です')
}
Object.assign(this, props)
Object.freeze(this)
}
equals(other: AccountIdEntity): boolean {
return this.value === other.value
}
}
export class MoneyEntity {
constructor(props: {
amount: number
currency: string
}) {
if (props.amount < 0) {
throw new Error('金額は0以上である必要があります')
}
if (!Number.isInteger(props.amount)) {
throw new Error('金額は整数である必要があります')
}
Object.assign(this, props)
Object.freeze(this)
}
add(other: MoneyEntity): MoneyEntity {
if (this.currency !== other.currency) {
throw new Error('異なる通貨の加算はできません')
}
return new MoneyEntity({
amount: this.amount + other.amount,
currency: this.currency
})
}
}
// 型安全で意図が明確
function transfer(from: AccountIdEntity, to: AccountIdEntity, amount: MoneyEntity) {
if (from.equals(to)) {
throw new Error('同一口座への送金はできません')
}
// 値オブジェクトが既にバリデーション済み
}
ドメインサービスの適用基準
エンティティに属さないロジック
複数のエンティティにまたがる処理や、どのエンティティにも属さないビジネスロジックはドメインサービスとして実装します。
// 在庫移動サービス
export class TransferService { // 汎用的な名前
execute(props: { // executeメソッドで統一
from: Location // Warehouseではなく汎用的なLocation
to: Location
item: Item // Productではなく汎用的なItem
quantity: Quantity
}): void {
// 移動元から減算
const updatedFrom = props.from.withDecrease(
props.item,
props.quantity
)
// 移動先に加算
const updatedTo = props.to.withIncrease(
props.item,
props.quantity
)
// 両方の更新が成功する必要がある
return { from: updatedFrom, to: updatedTo }
}
}
ファクトリとしての役割
複雑な生成ロジックを持つ場合、ドメインサービスがファクトリとして機能します。
// 注文生成ファクトリ
export class OrderFactory {
create(props: { // createメソッドで統一
source: Container // Cartではなく汎用的なContainer
user: User // Customerではなく汎用的なUser
address: Address
}): Order {
// 注文アイテムの生成
const items = props.source.items.map(item =>
new OrderItem(item.id, item.quantity)
)
const totalAmount = this.calculateTotal(items, props.user)
const shippingFee = this.calculateShipping(props.address)
return new Order({
userId: props.user.id,
items,
totalAmount: totalAmount.add(shippingFee),
address: props.address
})
}
private calculateTotal(items: OrderItem[], user: User): Money {
// ユーザー属性に応じた割引計算
const subtotal = items.reduce(
(sum, item) => sum.add(item.getAmount()),
Money.zero()
)
return user.applyDiscount(subtotal)
}
}
Entityのフラット構造
弊社では、Entityのプロパティはすべてプリミティブ型や値オブジェクトとし、他のEntityを入れ子にしません。これにより、データ構造がシンプルになり、Repository層での変換も容易になります。
ネストしたEntity(避けるべき)
複雑な入れ子構造は理解しにくく、循環参照のリスクも生じます。
// ❌ 避けるべき:Entityが入れ子になっている
export class UserEntity {
constructor(props: {
id: string
profile: ProfileEntity // 他のEntityを持っている
subscription: SubscriptionEntity // 複雑な入れ子構造
}) {
Object.assign(this, props)
Object.freeze(this)
}
}
フラットなEntity(推奨)
JOINしたデータはフラットに展開し、必要な情報だけをプロパティとして持ちます。
// ✅ 推奨:プロパティがフラットで理解しやすい
export class UserProfileEntity {
constructor(props: {
id: string
name: string
bio: string // Profileのデータをフラットに展開
avatar: string // 必要な情報だけを持つ
}) {
Object.assign(this, props)
Object.freeze(this)
}
}
// ✅ 推奨:集約の目的に応じたフラットな構造
export class UserBillingEntity {
constructor(props: {
id: string
email: string
stripeCustomerId: string
subscriptionPlan: string // Subscriptionのデータをフラットに
subscriptionExpiresAt: Date // 必要な部分だけ展開
}) {
Object.assign(this, props)
Object.freeze(this)
}
}
Repository層での変換
Repository層では、データベースから取得した関連データをフラットなEntityに変換します。
// Repository層での実装例
export class UserProfileRepository {
async findOne(where: { id?: string }): Promise<UserProfileEntity | null> {
const data = await this.prisma.user.findFirst({
where,
include: { profile: true } // JOINでデータ取得
})
if (!data) return null
// フラットなEntityに変換
return new UserProfileEntity({
id: data.id,
name: data.name,
bio: data.profile?.bio || '',
avatar: data.profile?.avatar || ''
})
}
}
よくある問題
ドメイン知識が他層に漏れる
ビジネスルールはすべてドメイン層に集約します。UIやインフラ層でビジネスロジックを実装しないように注意が必要です。
技術的な詳細がドメインに混入
リポジトリのインターフェースはドメイン層に定義しますが、実装はインフラ層に配置します。
エンティティが巨大化する
責務が多すぎる場合は、値オブジェクトやドメインサービスに分割することを検討します。
Entityが入れ子になる
Entityのプロパティは必ずフラットにし、他のEntityを持たせないようにします。関連データが必要な場合は、目的別の集約ルートを作成します。