웹 애플리케이션을 위한 도메인 중심 설계
Il 도메인 중심 설계 (DDD)는 2003년 Eric Evans가 도입한 접근 방식 중 하나입니다. 소프트웨어 설계에 비즈니스 도메인 각각의 중심에 건축 결정. 프레임워크와 라이브러리가 지배하는 웹 생태계에서 DDD는 언어와 언어를 충실하게 반영하는 구조의 애플리케이션을 구축하기 위한 나침반 그들이 제공하는 비즈니스 프로세스.
이 기사에서는 TypeScript를 사용하여 웹 컨텍스트에서 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 |
|---|---|---|
| 공유 커널 | 두 컨텍스트가 모델의 하위 집합을 공유합니다. | 카탈로그 및 주문은 ProductId를 공유합니다. |
| 고객-공급업체 | 한 컨텍스트는 데이터를 제공하고 다른 컨텍스트는 이를 소비합니다. | 카탈로그(공급업체)는 주문(고객)에게 제품 정보를 제공합니다. |
| 부패 방지 레이어 | 번역 레이어는 외부 모델로부터 컨텍스트를 보호합니다. | 외부 결제 게이트웨이와 통합 |
| 출판 언어 | 컨텍스트 간 통신을 위한 표준 형식 | 정의된 스키마를 사용하여 JSON으로 직렬화된 도메인 이벤트 |
| 순응주의자 | 한 컨텍스트는 번역 없이 다른 컨텍스트의 모델을 채택합니다. | 주문 모델을 직접 사용하는 보고 컨텍스트 |
DDD 전술
전술적 DDD는 다음을 제공합니다. 빌딩 블록 모델을 구현하기 위해 제한된 컨텍스트 내의 도메인. 이러한 패턴은 구조화 방법을 정의합니다. 엔터티, 가치, 집계 및 서비스.
엔터티
에이'실재 그것은 속성이 아니라 정체성에 의해 정의되는 객체입니다. 데이터는 동일하지만 ID가 다른 두 엔터티는 서로 다른 개체입니다. 정체성 속성이 변경되더라도 시간이 지나도 지속됩니다.
// 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 값 개체 그것들은 전적으로 그 값에 의해 정의되는 불변 객체입니다. 그들은 자신의 신원이 없습니다. 동일한 속성을 가진 두 개의 값 개체는 동일합니다. 이는 자신이 나타내는 값과 연결된 유효성 검사 규칙과 동작을 캡슐화합니다.
// 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 골재 하나로 취급되는 엔터티와 값 개체의 클러스터입니다. 데이터 수정 작업을 위한 단일 장치입니다. 각 집계에는 집계 루트: 외부 세계를 통과할 수 있는 유일한 실체 집계와 상호 작용합니다. 내부의 여러 개체와 관련된 비즈니스 규칙 집계의 루트에 의해 보장됩니다.
규칙을 집계합니다.
- 외부 액세스는 항상 Aggregate Root를 통해서만 발생합니다.
- 비즈니스 불변성은 집계 경계 내에 유지됩니다.
- 지속성 작업은 전체 집계를 원자적으로 저장합니다.
- 집계 간의 참조는 직접 참조가 아닌 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(ID)를 사용하세요. |
DDD를 사용해야 하는 경우
결정 가이드
| 대본 | 전략적 DDD | DDD 전술 |
|---|---|---|
| 단순한 도메인, 소수의 비즈니스 규칙 | 의사소통에 유용함 | 필요하지 않음 |
| 적당한 지배력, 의미 있는 논리 | 조언 | 선택적(복잡한 컨텍스트에만 해당) |
| 복잡한 도메인, 많은 상호 의존적 규칙 | 필수적인 | 필수적인 |
| 현대화될 기존 시스템 | 세분화에 필수 | 점진적, 상황별 |
결론
도메인 중심 디자인은 프레임워크나 라이브러리가 아니라 소프트웨어에 대한 사고 방식입니다. 에서 시작 비즈니스 문제 기술보다는. 전략적 DDD 이는 시스템 부분 간의 명확한 경계와 관계를 정의하는 데 도움이 됩니다. 전술적 DDD는 우리에게 표현력 있고 유지 관리 가능한 방식으로 도메인 논리를 모델링하는 구체적인 도구입니다.
TypeScript 및 Angular에 적용된 DDD는 프로젝트 구조를 명확하게 하고 기술팀과 이해관계자 간의 소통을 통해 이를 충실히 반영하는 코드를 생성합니다. 비즈니스 프로세스. 다른 아키텍처 접근 방식과 마찬가지로 초기 투자가 필요합니다. 분석 및 설계 측면에서 이점이 있지만 시스템의 유지 관리 및 발전 측면에서 이점이 있습니다. 이는 중간 및 높은 복잡성 프로젝트에 중요합니다.







