ストーリー
Entity(エンティティ)
**同一性(Identity)**を持つオブジェクトです。属性が変わっても「同じもの」として扱います。
class Order {
constructor(
private readonly _id: OrderId, // 同一性を定義するID
private readonly _customerId: string,
private _items: OrderItem[],
private _status: OrderStatus
) {}
// IDが同じなら同一のエンティティ
equals(other: Order): boolean {
return this._id.equals(other._id);
}
// 属性は変わるが、IDが同じなら同じ注文
confirm(): void {
this._status = OrderStatus.CONFIRMED;
}
}
Value Object(値オブジェクト)
値そのもので識別されるオブジェクトです。不変で、自己検証を行います。
class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: string
) {
if (_amount < 0) throw new Error('金額は0以上');
if (!['JPY', 'USD', 'EUR'].includes(_currency)) {
throw new Error('サポートされていない通貨');
}
}
static of(amount: number, currency: string): Money {
return new Money(amount, currency);
}
// 値が同じなら等しい(IDではなく値で比較)
equals(other: Money): boolean {
return this._amount === other._amount && this._currency === other._currency;
}
// 不変: 新しいインスタンスを返す
add(other: Money): Money {
if (this._currency !== other._currency) {
throw new Error('異なる通貨の加算はできません');
}
return new Money(this._amount + other._amount, this._currency);
}
}
class EmailAddress {
private constructor(private readonly _value: string) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(_value)) {
throw new Error('無効なメールアドレスです');
}
}
static of(value: string): EmailAddress {
return new EmailAddress(value);
}
get value(): string { return this._value; }
equals(other: EmailAddress): boolean {
return this._value === other._value;
}
}
EntityとValue Objectの使い分け
| 判断基準 | Entity | Value Object |
|---|---|---|
| 同一性 | IDで識別 | 値で識別 |
| 可変性 | 状態が変わる | 不変 |
| ライフサイクル | 生成→変更→削除 | 生成→破棄(変更は新規生成) |
| 例 | Order, Customer, Product | Money, EmailAddress, DateRange |
Aggregate(集約)
Aggregateは、データの一貫性を保つ単位です。Aggregate Root(集約ルート)を通じてのみアクセスします。
// Order Aggregate
// ┌──────────────────────────────────┐
// │ Order(Aggregate Root) │
// │ ├── OrderItem │
// │ ├── OrderItem │
// │ └── ShippingAddress │
// └──────────────────────────────────┘
class Order {
private constructor(
private readonly _id: OrderId,
private readonly _customerId: string,
private _items: OrderItem[], // Aggregate内のEntity
private _shippingAddress: Address, // Aggregate内のValue Object
private _status: OrderStatus
) {}
// Aggregate Rootを通じてのみ内部を操作する
addItem(productId: string, name: string, qty: number, price: Money): void {
if (this._status !== OrderStatus.PENDING) {
throw new Error('保留中の注文にのみ商品を追加できます');
}
if (this._items.length >= 20) {
throw new Error('1つの注文に含められる商品は20個までです');
}
const existingItem = this._items.find(i => i.productId === productId);
if (existingItem) {
existingItem.increaseQuantity(qty);
} else {
this._items.push(OrderItem.create(productId, name, qty, price));
}
}
removeItem(productId: string): void {
if (this._status !== OrderStatus.PENDING) {
throw new Error('保留中の注文からのみ商品を削除できます');
}
this._items = this._items.filter(i => i.productId !== productId);
if (this._items.length === 0) {
throw new Error('注文には最低1つの商品が必要です');
}
}
}
// NG: Aggregate Rootを経由せずに内部を操作
// order.items[0].quantity = 5; // 直接アクセス禁止
// OK: Aggregate Rootのメソッドを通じて操作
// order.addItem('prod-1', '商品名', 5, Money.of(1000, 'JPY'));
Aggregateの設計ルール
| ルール | 説明 |
|---|---|
| Aggregate Rootを経由 | 外部からはRootのメソッドのみ呼び出す |
| トランザクション境界 | 1つのトランザクションで1つのAggregateのみ更新 |
| 小さく保つ | Aggregateは必要最小限のEntityを含める |
| 他Aggregateの参照 | IDでのみ参照する(オブジェクト参照は持たない) |
// Aggregate間はIDで参照する
class Order {
private readonly _customerId: string; // Customer Aggregateへの参照はIDのみ
// NG: 他のAggregateのオブジェクトを直接保持
// private readonly _customer: Customer;
}
Domain Service(ドメインサービス)
特定のEntityに属さないビジネスロジックを配置します。
// 割引計算は注文にも顧客にも属さない
class DiscountCalculator {
calculate(order: Order, customer: Customer): Money {
let discount = Money.zero('JPY');
// VIP顧客は10%割引
if (customer.isVip()) {
discount = order.totalAmount.multiply(0.1);
}
// 1万円以上の注文は500円割引
if (order.totalAmount.amount >= 10000) {
discount = discount.add(Money.of(500, 'JPY'));
}
return discount;
}
}
Domain Serviceの判断基準
graph TD
Q{{"このロジックはどのEntityに属する?"}}
A["Entityのメソッド"]
B["Domain Service"]
C["Application Service<br/>(Use Case)"]
Q -->|"特定のEntityに自然に属する"| A
Q -->|"複数のEntityにまたがる"| B
Q -->|"外部リソースが必要"| C
Repository(リポジトリ)
Aggregate単位でリポジトリを定義します。
// Aggregate Root 1つ = Repository 1つ
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(id: OrderId): Promise<void>;
}
// NG: Aggregate内のEntityに個別のRepositoryを作らない
// interface OrderItemRepository {
// findById(id: OrderItemId): Promise<OrderItem | null>;
// }
// OrderItemはOrder Aggregateの一部なので、OrderRepositoryを通じてアクセス
ディレクトリ構造(DDD + ヘキサゴナル)
src/
├── ordering/ # 注文 Bounded Context
│ ├── domain/
│ │ ├── entities/
│ │ │ ├── Order.ts # Aggregate Root
│ │ │ └── OrderItem.ts # Aggregate内Entity
│ │ ├── value-objects/
│ │ │ ├── OrderId.ts
│ │ │ ├── Money.ts
│ │ │ └── Address.ts
│ │ ├── services/
│ │ │ └── DiscountCalculator.ts
│ │ └── ports/
│ │ ├── in/
│ │ └── out/OrderRepository.ts
│ ├── application/
│ │ └── CreateOrderUseCase.ts
│ └── adapters/
│ ├── in/OrderController.ts
│ └── out/PrismaOrderRepository.ts
└── inventory/ # 在庫 Bounded Context
├── domain/
│ ├── entities/StockItem.ts
│ └── ...
└── ...
まとめ
| ポイント | 内容 |
|---|---|
| Entity | 同一性(ID)を持つ、状態が変化するオブジェクト |
| Value Object | 値で識別される不変オブジェクト |
| Aggregate | データ一貫性の単位、Rootを通じてのみアクセス |
| Domain Service | 特定のEntityに属さないビジネスロジック |
| Repository | Aggregate Root単位で定義 |
チェックリスト
- EntityとValue Objectの違いを説明できる
- Aggregateの4つのルールを把握した
- Domain Serviceの使いどころを判断できる
- Aggregate Root単位でRepositoryを定義する理由を理解した
次のステップへ
次は「ドメインイベントとイベントストーミング」を学びます。Bounded Context間の連携方法と、ドメインを発見するためのワークショップ手法を身につけましょう。
推定読了時間: 40分