Web アプリケーションのドメイン駆動設計
Il ドメイン駆動設計 2003 年に Eric Evans によって導入された (DDD) はアプローチの 1 つです を配置するソフトウェア設計に 事業領域 それぞれの中心に 建築上の決定。フレームワークとライブラリが主流の Web エコシステムにおいて、DDD は 言語と言語を忠実に反映した構造を持つアプリケーションを構築するためのコンパス 彼らが提供するビジネスプロセス。
この記事では、TypeScript を使用して Web コンテキストで DDD を適用する方法を検討します。 戦略レベル (境界コンテキスト、コンテキスト マップ) と戦術レベル (エンティティ、コンテキスト マップ) の両方 値オブジェクト、集計、ドメイン イベント)。例として電子商取引ドメインを使用します それぞれの概念を具体的に説明します。
何を学ぶか
- 戦略的 DDD: 境界付きコンテキスト、コンテキスト マップ、ユビキタス言語
- 戦術的 DDD: エンティティ、値オブジェクト、集計、リポジトリ
- ドメインイベントとコンテキスト間の通信
- TypeScriptでの実践的な実装
- DDD を使用した Angular/Node.js プロジェクトの構造
- ドメインの専門家とのコラボレーション
戦略的DDD
戦略的 DDD は、複雑なシステムをどのように分解するかという全体像を扱います。 管理可能な部分に分割し、これらの部分が相互に通信する方法を説明します。メジャーが持っているレベルです システム アーキテクチャ全体に影響を与えます。
ユビキタス言語
L'ユビキタス言語 それは開発者とドメイン間で共有される語彙です 専門家。各用語は、文脈内で正確かつ固有の意味を持ちます。 コードは、クラス、メソッド、変数の名前という言語を反映する必要があります。 ビジネスで使用される用語と一致する必要があります。
例: 電子商取引
文脈の中で カタログ, 「製品」には名前、説明、カテゴリ、画像があります。 文脈の中で 在庫と同じ物理概念が「記事」になります。 在庫、再注文しきい値、およびそれが属する倉庫。同じ用語を使用する 異なる概念の場合、混乱が生じます。ユビキタス言語はこの曖昧さを解決します コンテキストごとに特定の語彙を定義します。
境界のあるコンテキスト
Un 境界のあるコンテキスト モデルがその範囲内にある明示的な境界です。 ドメインの意味は正確です。各境界コンテキストには独自のユビキタス言語があり、 あなた自身のエンティティ、そして場合によってはあなた自身のデータベースと開発チーム。
+------------------+ +------------------+ +------------------+
| CATALOGO | | ORDINI | | SPEDIZIONI |
| | | | | |
| - Prodotto | | - Ordine | | - Spedizione |
| - Categoria | | - RigaOrdine | | - Pacco |
| - Prezzo | | - Cliente | | - Corriere |
| - Recensione | | - Pagamento | | - Tracking |
| | | | | |
+--------+---------+ +--------+---------+ +--------+---------+
| | |
+-------------------------+-------------------------+
|
Context Map (relazioni)
コンテキストマップ
La コンテキストマップ では、境界付きコンテキスト間の関係について説明します。定義する コンテキストがどのように通信するか、誰が誰に依存するか、どのような統合パターンが得られるか 使用されています。
| 関係 | 説明 | Esempio |
|---|---|---|
| 共有カーネル | 2 つのコンテキストがモデルのサブセットを共有する | カタログと注文は ProductId を共有します |
| 顧客とサプライヤー | 1 つのコンテキストがデータを提供し、もう 1 つのコンテキストがそれを消費します | カタログ (サプライヤー) から注文 (顧客) に製品情報が提供されます。 |
| 腐敗防止層 | 翻訳層は外部モデルからコンテキストを保護します | 外部決済ゲートウェイとの統合 |
| 出版言語 | コンテキスト間の通信のための標準フォーマット | 定義されたスキーマを使用して JSON でシリアル化されたドメイン イベント |
| 適合者 | あるコンテキストが別のコンテキストのモデルを翻訳せずに採用する | 注文モデルを直接使用するレポートコンテキスト |
DDD戦術
戦術的な DDD が提供するのは、 ビルディングブロック モデルを実装するには 境界コンテキスト内のドメイン。これらのパターンは、構造化の方法を定義します。 エンティティ、値、集計、およびサービス。
エンティティ
あ'実在物 それは、その属性ではなく、そのアイデンティティによって定義されるオブジェクトです。 データは同じだが ID が異なる 2 つのエンティティは、別個のオブジェクトです。アイデンティティ 属性が変化しても時間の経過とともに持続します。
// domain/entities/order.entity.ts
export class OrderId {
constructor(public readonly value: string) {
if (!value || value.trim().length === 0) {
throw new Error('OrderId non può essere vuoto');
}
}
equals(other: OrderId): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
export class Order {
private _items: OrderLine[] = [];
private _domainEvents: DomainEvent[] = [];
constructor(
public readonly id: OrderId,
public readonly customerId: CustomerId,
private _status: OrderStatus = OrderStatus.CREATED,
private _createdAt: Date = new Date()
) {}
get status(): OrderStatus {
return this._status;
}
get items(): ReadonlyArray<OrderLine> {
return [...this._items];
}
get totalAmount(): Money {
return this._items.reduce(
(total, line) => total.add(line.subtotal),
Money.zero('EUR')
);
}
addLine(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
if (this._status !== OrderStatus.CREATED) {
throw new DomainError('Impossibile aggiungere righe a un ordine confermato');
}
const existingLine = this._items.find(
line => line.productId.equals(productId)
);
if (existingLine) {
throw new DomainError('Prodotto già presente nell ordine');
}
this._items.push(new OrderLine(productId, quantity, unitPrice));
}
confirm(): void {
if (this._items.length === 0) {
throw new DomainError('Impossibile confermare un ordine vuoto');
}
if (this._status !== OrderStatus.CREATED) {
throw new DomainError('L ordine è già stato confermato');
}
this._status = OrderStatus.CONFIRMED;
this._domainEvents.push(
new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount)
);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
値オブジェクト
I 値オブジェクト これらは、その値によって完全に定義される不変オブジェクトです。 これらには独自のアイデンティティはありません。同じ属性を持つ 2 つの値オブジェクトは同一です。 これらは、検証ルールと、それが表す値にリンクされた動作をカプセル化します。
// domain/value-objects/money.ts
export class Money {
private constructor(
public readonly amount: number,
public readonly currency: string
) {
if (amount < 0) {
throw new DomainError('L importo non può essere negativo');
}
}
static of(amount: number, currency: string): Money {
return new Money(Math.round(amount * 100) / 100, currency);
}
static zero(currency: string): Money {
return new Money(0, currency);
}
add(other: Money): Money {
this.ensureSameCurrency(other);
return Money.of(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return Money.of(this.amount * factor, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount
&& this.currency === other.currency;
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new DomainError(
`Valute diverse: ${this.currency} vs ${other.currency}`
);
}
}
toString(): string {
return `${this.amount.toFixed(2)} ${this.currency}`;
}
}
// domain/value-objects/quantity.ts
export class Quantity {
constructor(public readonly value: number) {
if (!Number.isInteger(value) || value <= 0) {
throw new DomainError('La quantità deve essere un intero positivo');
}
}
add(other: Quantity): Quantity {
return new Quantity(this.value + other.value);
}
equals(other: Quantity): boolean {
return this.value === other.value;
}
}
集合体
Un 集計 エンティティと値オブジェクトのクラスターが 1 つとして扱われます データ変更操作を単一のユニットで実行します。各集合体には、 集約ルート: 外界が通過できる唯一の存在 集合体と対話します。内部の複数のオブジェクトを含むビジネス ルール 集約のはルートによって保証されます。
集計ルール
- 外部アクセスは常に集約ルート経由でのみ発生します
- ビジネスの不変条件は集計境界内に維持されます
- 永続化操作により、アグリゲート全体がアトミックに保存されます。
- 集約間の参照は、直接参照ではなく、ID (ID) を介して発生します。
- 凝集体はできるだけ小さくなければなりません
// domain/aggregates/cart.aggregate.ts
export class Cart {
private _items: Map<string, CartItem> = new Map();
private _domainEvents: DomainEvent[] = [];
constructor(
public readonly id: CartId,
public readonly customerId: CustomerId
) {}
get items(): CartItem[] {
return Array.from(this._items.values());
}
get itemCount(): number {
return this._items.size;
}
get totalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.subtotal),
Money.zero('EUR')
);
}
addProduct(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
const key = productId.value;
if (this._items.has(key)) {
const existing = this._items.get(key)!;
this._items.set(key, existing.increaseQuantity(quantity));
} else {
this._items.set(key, new CartItem(productId, quantity, unitPrice));
}
this._domainEvents.push(
new ProductAddedToCartEvent(this.id, productId, quantity)
);
}
removeProduct(productId: ProductId): void {
const key = productId.value;
if (!this._items.has(key)) {
throw new DomainError('Prodotto non presente nel carrello');
}
this._items.delete(key);
this._domainEvents.push(
new ProductRemovedFromCartEvent(this.id, productId)
);
}
checkout(): Order {
if (this._items.size === 0) {
throw new DomainError('Il carrello è vuoto');
}
const orderId = new OrderId(crypto.randomUUID());
const order = new Order(orderId, this.customerId);
for (const item of this.items) {
order.addLine(item.productId, item.quantity, item.unitPrice);
}
this._items.clear();
this._domainEvents.push(new CartCheckedOutEvent(this.id, orderId));
return order;
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
ドメインイベント
I ドメインイベント それらはドメイン内で起こった重要な事実を表しています。 それらは不変であり、日付があり、変更を伝えるために必要な情報を保持します。 システムの他のコンテキストまたはコンポーネントに。
// domain/events/domain-event.ts
export interface DomainEvent {
readonly eventType: string;
readonly occurredOn: Date;
readonly aggregateId: string;
}
// domain/events/order-confirmed.event.ts
export class OrderConfirmedEvent implements DomainEvent {
readonly eventType = 'OrderConfirmed';
readonly occurredOn = new Date();
constructor(
public readonly aggregateId: string,
public readonly orderId: OrderId,
public readonly customerId: CustomerId,
public readonly totalAmount: Money
) {
this.aggregateId = orderId.value;
}
}
// domain/events/product-added-to-cart.event.ts
export class ProductAddedToCartEvent implements DomainEvent {
readonly eventType = 'ProductAddedToCart';
readonly occurredOn = new Date();
constructor(
public readonly cartId: CartId,
public readonly productId: ProductId,
public readonly quantity: Quantity
) {}
get aggregateId(): string {
return this.cartId.value;
}
}
リポジトリ
Il リポジトリ 集約永続性の抽象化を提供します。 個々の内部エンティティではなく、常に集合体全体で動作します。
// domain/repositories/order.repository.ts
export interface OrderRepository {
nextId(): OrderId;
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
save(order: Order): Promise<void>;
}
// infrastructure/repositories/order-api.repository.ts
export class OrderApiRepository implements OrderRepository {
constructor(private readonly http: HttpClient) {}
nextId(): OrderId {
return new OrderId(crypto.randomUUID());
}
async findById(id: OrderId): Promise<Order | null> {
const response = await firstValueFrom(
this.http.get<OrderApiDto>(`/api/orders/${id.value}`)
);
return response ? OrderApiMapper.toDomain(response) : null;
}
async findByCustomer(customerId: CustomerId): Promise<Order[]> {
const response = await firstValueFrom(
this.http.get<OrderApiDto[]>(
`/api/orders?customer=${customerId.value}`
)
);
return response.map(dto => OrderApiMapper.toDomain(dto));
}
async save(order: Order): Promise<void> {
const dto = OrderApiMapper.toApi(order);
await firstValueFrom(
this.http.put(`/api/orders/${order.id.value}`, dto)
);
}
}
ドメインサービス
I ドメインサービス 属さないビジネス ロジックが含まれている 当然、エンティティや値オブジェクトには適用されません。彼らは多くの場合、次のような作戦を調整します。 複数の集合体が関係します。
// domain/services/pricing.service.ts
export class PricingService {
calculateDiscount(
order: Order,
customerTier: CustomerTier
): Money {
const total = order.totalAmount;
if (customerTier === CustomerTier.PREMIUM && total.amount > 100) {
return total.multiply(0.15); // 15% di sconto per clienti premium
}
if (total.amount > 200) {
return total.multiply(0.10); // 10% per ordini sopra 200 EUR
}
return Money.zero(total.currency);
}
applyTax(amount: Money, taxRate: number): Money {
return amount.multiply(1 + taxRate);
}
}
DDD を使用したプロジェクト構造
フォルダー構造は、境界付きコンテキストと戦術的な構成要素を反映しています。 各コンテキストは自律しており、機能するために必要なものがすべて含まれています。
src/
bounded-contexts/
catalog/
domain/
entities/
product.entity.ts
value-objects/
product-name.ts
price.ts
repositories/
product.repository.ts
events/
product-created.event.ts
application/
commands/
create-product.command.ts
queries/
get-product.query.ts
infrastructure/
repositories/
product-api.repository.ts
mappers/
product-api.mapper.ts
orders/
domain/
entities/
order.entity.ts
order-line.entity.ts
value-objects/
money.ts
quantity.ts
order-id.ts
aggregates/
order.aggregate.ts
repositories/
order.repository.ts
services/
pricing.service.ts
events/
order-confirmed.event.ts
application/
commands/
create-order.command.ts
confirm-order.command.ts
queries/
get-order-by-id.query.ts
infrastructure/
repositories/
order-api.repository.ts
event-handlers/
send-confirmation-email.handler.ts
shipping/
domain/
...
application/
...
infrastructure/
...
shared-kernel/
domain/
value-objects/
email-address.ts
entity-id.ts
infrastructure/
event-bus/
event-bus.ts
腐敗防止層
外部システム (サードパーティ API、レガシー システム) と統合する場合、汚職防止 レイヤー (ACL) は外部モデルをドメイン言語に変換し、保護します 内部モデルの汚れ。
// infrastructure/acl/payment-gateway.adapter.ts
// Modello esterno del gateway di pagamento
interface StripePaymentResponse {
id: string;
amount: number; // in centesimi
currency: string;
status: 'succeeded' | 'pending' | 'failed';
created: number; // timestamp Unix
}
// Il nostro modello di dominio
export class PaymentResult {
constructor(
public readonly transactionId: string,
public readonly amount: Money,
public readonly status: PaymentStatus,
public readonly processedAt: Date
) {}
}
// Anti-Corruption Layer: traduce tra i due modelli
export class PaymentGatewayAdapter {
constructor(private readonly stripeClient: StripeClient) {}
async processPayment(
orderId: OrderId,
amount: Money
): Promise<PaymentResult> {
// Traduce dal nostro modello a quello di Stripe
const stripeResponse = await this.stripeClient.charge({
amount: Math.round(amount.amount * 100), // Stripe usa centesimi
currency: amount.currency.toLowerCase(),
metadata: { order_id: orderId.value },
});
// Traduce dalla risposta di Stripe al nostro dominio
return this.toDomain(stripeResponse);
}
private toDomain(response: StripePaymentResponse): PaymentResult {
return new PaymentResult(
response.id,
Money.of(response.amount / 100, response.currency.toUpperCase()),
this.mapStatus(response.status),
new Date(response.created * 1000)
);
}
private mapStatus(stripeStatus: string): PaymentStatus {
const statusMap: Record<string, PaymentStatus> = {
'succeeded': PaymentStatus.COMPLETED,
'pending': PaymentStatus.PENDING,
'failed': PaymentStatus.FAILED,
};
return statusMap[stripeStatus] ?? PaymentStatus.UNKNOWN;
}
}
境界のあるコンテキスト間の通信
境界付けられたコンテキストは、以下を介して相互に通信します。 ドメインイベント そして イベントバス。この非同期通信により、コンテキストが分離された状態に保たれます そして誰もが独立して進化できるようにします。
// shared-kernel/infrastructure/event-bus.ts
type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>;
export class InMemoryEventBus {
private handlers = new Map<string, EventHandler<any>[]>();
subscribe<T extends DomainEvent>(
eventType: string,
handler: EventHandler<T>
): void {
const existing = this.handlers.get(eventType) || [];
existing.push(handler);
this.handlers.set(eventType, existing);
}
async publish(event: DomainEvent): Promise<void> {
const handlers = this.handlers.get(event.eventType) || [];
await Promise.all(
handlers.map(handler => handler(event))
);
}
async publishAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.publish(event);
}
}
}
// Esempio: quando un ordine è confermato, il contesto Spedizioni reagisce
// shipping/application/event-handlers/on-order-confirmed.handler.ts
export class OnOrderConfirmedHandler {
constructor(private readonly shippingService: ShippingService) {}
async handle(event: OrderConfirmedEvent): Promise<void> {
await this.shippingService.createShipment(
event.orderId,
event.customerId
);
}
}
DDD 導入におけるよくある間違い
| 間違い | 結果 | 救済策 |
|---|---|---|
| 戦略なしで戦術から始める | 境界付きコンテキストが不十分に定義され、混乱を招くモデル | 常に専門家とのドメイン分析から始める |
| 集合体が大きすぎる | 競争上の衝突、パフォーマンスの低下 | ルールに従ってください: 集計はできるだけ小さくする |
| ユビキタス言語の軽視 | コードとビジネスの不整合、コミュニケーションの困難 | 共有用語集、名前に焦点を当てたコードレビュー |
| 単純な CRUD であっても、どこにでも DDD が存在します | 過剰なエンジニアリング、不必要な複雑さ | 複雑なロジックを含むコンテキストにのみ DDD を適用する |
| 集計間の直接参照 | 交尾、登頂不能 | 相互集計参照には常にアイデンティティ (ID) を使用する |
DDD を使用する場合
意思決定ガイド
| シナリオ | 戦略的DDD | DDD戦術 |
|---|---|---|
| シンプルなドメイン、少数のビジネスルール | コミュニケーションに役立つ | 必要ありません |
| 適度な優位性、意味のあるロジック | アドバイス | 選択的 (複雑なコンテキストのみ) |
| 複雑なドメイン、多くの相互依存ルール | 不可欠 | 不可欠 |
| レガシーシステムを最新化する | 分譲には必須 | プログレッシブ、コンテキストごとに |
結論
ドメイン駆動設計はフレームワークやライブラリではありません。ソフトウェアについての考え方です。 から始まる ビジネス上の問題 テクノロジーというよりも。戦略的な DDD これは、システムの各部分間の明確な境界と関係を定義するのに役立ちます。戦術的な DDD が提供するもの 表現力豊かで保守可能な方法でドメイン ロジックをモデル化するための具体的なツール。
DDD を TypeScript と Angular に適用すると、プロジェクト構造が明確になり、改善されます。 技術チームと利害関係者との間のコミュニケーションを確立し、それを忠実に反映したコードを生成します ビジネスプロセス。他のアーキテクチャアプローチと同様に、初期投資が必要です 分析と設計の面では利点がありますが、システムの保守性と進化の面では利点があります これらは中程度および高度の複雑さのプロジェクトにとって重要です。







