DDD
전술적 설계
Domain-Driven Design의 빌딩 블록 - Entity, Value Object, Aggregate, Repository, Domain Service, Domain Event
DDD 빌딩 블록
도메인 모델을 구성하는 핵심 패턴들
Entity
고유한 식별자를 가지며, 생명주기 동안 연속성을 유지하는 객체
Value Object
식별자 없이 속성 값으로만 구분되는 불변 객체
Aggregate
일관성 경계 안에서 관련 객체들의 클러스터
Repository
Aggregate의 영속성을 담당하는 컬렉션 추상화
Domain Service
특정 Entity에 속하지 않는 도메인 로직
Domain Event
도메인에서 발생한 중요한 사건을 나타내는 객체
객체가 식별자로 구분되어야 하면 Entity, 속성 값으로 구분되면 Value Object입니다. 여러 객체를 하나의 트랜잭션 경계로 묶어야 하면 Aggregate를 사용합니다.
Entity vs Value Object
두 핵심 패턴의 차이점 이해하기
🆔 Entity
- ✓ 고유한 식별자(ID)를 가짐
- ✓ 생명주기 동안 연속성 유지
- ✓ 속성이 변해도 같은 객체
- ✓ 동등성은 ID로 판단
- ✗ 가변(Mutable) 가능
💎 Value Object
- ✗ 식별자 없음
- ✓ 속성 값으로 동등성 판단
- ✓ 불변(Immutable)
- ✓ 교체 가능(Replaceable)
- ✓ Side-effect 없음
class User {
private readonly id: UserId;
private name: string;
private email: Email;
constructor(id: UserId, name: string, email: Email) {
this.id = id;
this.name = name;
this.email = email;
}
// 이름이 바뀌어도 같은 User
changeName(newName: string): void {
this.name = newName;
}
// 동등성은 ID로 판단
equals(other: User): boolean {
return this.id.equals(other.id);
}
}
class Money {
private readonly amount: number;
private readonly currency: Currency;
private constructor(amount: number, currency: Currency) {
this.amount = amount;
this.currency = currency;
}
static create(amount: number, currency: Currency): Money {
return new Money(amount, currency);
}
// 불변 - 새 객체 반환
add(other: Money): Money {
if (!this.currency.equals(other.currency)) {
throw new Error('Currency mismatch');
}
return Money.create(this.amount + other.amount, this.currency);
}
// 동등성은 값으로 판단
equals(other: Money): boolean {
return this.amount === other.amount &&
this.currency.equals(other.currency);
}
}
Aggregate (집합체)
일관성 경계와 트랜잭션 단위
Aggregate는 데이터 변경의 단위로 취급되는 연관 객체들의 클러스터입니다. 각 Aggregate는 루트 Entity(Aggregate Root)를 가지며, 외부에서는 오직 루트를 통해서만 Aggregate 내부에 접근할 수 있습니다.
Order Aggregate 예시
Order
Aggregate Root- 일관성 경계 — Aggregate 내부는 항상 일관된 상태를 유지해야 함
- 루트를 통한 접근 — 외부에서는 Aggregate Root를 통해서만 접근
- 하나의 트랜잭션 — 하나의 트랜잭션에서 하나의 Aggregate만 수정
- 작게 유지 — Aggregate는 가능한 작게 설계 (성능과 동시성)
- ID로 참조 — 다른 Aggregate는 ID로만 참조 (직접 객체 참조 X)
class Order {
private readonly id: OrderId;
private readonly customerId: CustomerId; // 다른 Aggregate는 ID로 참조
private lines: OrderLine[] = [];
private shippingAddress: Address;
private status: OrderStatus;
// 외부에서는 Order를 통해서만 OrderLine에 접근
addLine(productId: ProductId, quantity: number, price: Money): void {
if (this.status !== OrderStatus.Draft) {
throw new Error('Cannot modify confirmed order');
}
const line = new OrderLine(productId, quantity, price);
this.lines.push(line);
}
removeLine(lineId: OrderLineId): void {
if (this.status !== OrderStatus.Draft) {
throw new Error('Cannot modify confirmed order');
}
this.lines = this.lines.filter(l => !l.id.equals(lineId));
}
confirm(): void {
if (this.lines.length === 0) {
throw new Error('Cannot confirm empty order');
}
this.status = OrderStatus.Confirmed;
// Domain Event 발행
this.addDomainEvent(new OrderConfirmedEvent(this.id));
}
get totalAmount(): Money {
return this.lines.reduce(
(sum, line) => sum.add(line.subtotal),
Money.zero()
);
}
}
Repository (저장소)
Aggregate의 영속성을 추상화하는 패턴
// 도메인 레이어에 정의
interface OrderRepository {
// Aggregate 단위로 조회
findById(id: OrderId): Promise<Order | null>;
findByCustomerId(customerId: CustomerId): Promise<Order[]>;
// Aggregate 단위로 저장
save(order: Order): Promise<void>;
// Aggregate 단위로 삭제
delete(order: Order): Promise<void>;
// 다음 ID 생성 (옵션)
nextId(): OrderId;
}
// 도메인 로직은 구현 세부사항을 모름
class ConfirmOrderUseCase {
constructor(private orderRepo: OrderRepository) {}
async execute(orderId: string): Promise<void> {
const order = await this.orderRepo.findById(
new OrderId(orderId)
);
if (!order) throw new Error('Order not found');
order.confirm();
await this.orderRepo.save(order);
}
}
// 인프라 레이어에 구현
class PostgresOrderRepository implements OrderRepository {
constructor(private db: Database) {}
async findById(id: OrderId): Promise<Order | null> {
const row = await this.db.query(
'SELECT * FROM orders WHERE id = $1',
[id.value]
);
if (!row) return null;
// DB 모델 → 도메인 모델 변환
return this.toDomain(row);
}
async save(order: Order): Promise<void> {
// 도메인 모델 → DB 모델 변환
const data = this.toPersistence(order);
await this.db.query(
`INSERT INTO orders (...) VALUES (...)
ON CONFLICT (id) DO UPDATE SET ...`,
[data]
);
// OrderLine도 함께 저장 (Aggregate 단위)
await this.saveOrderLines(order);
}
private toDomain(row: any): Order { ... }
private toPersistence(order: Order): any { ... }
}
Repository는 Aggregate Root 단위로 동작합니다. Order Aggregate를 저장하면 OrderLine도 함께 저장됩니다. 인터페이스는 도메인 레이어에, 구현은 인프라 레이어에 위치하여 의존성 역전을 달성합니다.
Domain Service
특정 Entity에 속하지 않는 도메인 로직
도메인 로직이 여러 Aggregate에 걸쳐있거나, 특정 Entity의 책임으로 보기 어려울 때 사용합니다. 무상태(Stateless)이며, 순수한 도메인 로직만 포함합니다.
// 여러 Aggregate(Order, Customer, Product)에 걸친 로직
class PricingService {
calculateOrderPrice(
order: Order,
customer: Customer,
discounts: Discount[]
): Money {
let totalPrice = order.subtotal;
// 고객 등급에 따른 할인
if (customer.isVIP()) {
totalPrice = totalPrice.multiply(0.9);
}
// 프로모션 할인 적용
for (const discount of discounts) {
if (discount.isApplicableTo(order)) {
totalPrice = discount.apply(totalPrice);
}
}
// 최소 금액 보장
return Money.max(totalPrice, Money.zero());
}
}
// 배송비 계산 - Order도 Address도 아닌 별도 로직
class ShippingCostCalculator {
calculate(
address: Address,
items: OrderLine[],
shippingMethod: ShippingMethod
): Money {
const weight = items.reduce((sum, item) =>
sum + item.weight, 0
);
const distance = this.calculateDistance(address);
return shippingMethod.calculateCost(weight, distance);
}
}
- Domain Service — 순수한 도메인 로직, 인프라 의존 없음
- Application Service — Use Case 조율, 트랜잭션, 인프라 호출
- Domain Service는 비즈니스 규칙을 표현
- Application Service는 워크플로우를 조율
Domain Event
도메인에서 발생한 중요한 사건
OrderCreatedEvent
새로운 주문이 생성되었을 때 발행. 재고 예약, 알림 전송 등을 트리거.
OrderConfirmedEvent
주문이 확정되었을 때 발행. 결제 처리, 배송 준비 시작.
PaymentCompletedEvent
결제가 완료되었을 때 발행. 주문 상태 업데이트, 영수증 발행.
OrderShippedEvent
배송이 시작되었을 때 발행. 고객에게 배송 알림 전송.
// 불변의 이벤트 객체
class OrderConfirmedEvent implements DomainEvent {
readonly occurredOn: Date;
readonly orderId: string;
readonly customerId: string;
readonly totalAmount: number;
constructor(order: Order) {
this.occurredOn = new Date();
this.orderId = order.id.value;
this.customerId = order.customerId.value;
this.totalAmount = order.totalAmount.value;
}
get eventType(): string {
return 'order.confirmed';
}
}
// Aggregate에서 이벤트 발행
class Order extends AggregateRoot {
confirm(): void {
// 비즈니스 규칙 검증
if (this.lines.length === 0) {
throw new Error('Cannot confirm empty order');
}
this.status = OrderStatus.Confirmed;
// 도메인 이벤트 등록
this.addDomainEvent(new OrderConfirmedEvent(this));
}
}
// 이벤트 핸들러 (Application Layer)
class SendOrderConfirmationEmail
implements DomainEventHandler<OrderConfirmedEvent> {
constructor(
private emailService: EmailService,
private customerRepo: CustomerRepository
) {}
async handle(event: OrderConfirmedEvent): Promise<void> {
const customer = await this.customerRepo.findById(
new CustomerId(event.customerId)
);
if (!customer) return;
await this.emailService.send({
to: customer.email,
subject: '주문이 확정되었습니다',
body: `주문번호: ${event.orderId}`
});
}
}
// 다른 Bounded Context로 이벤트 전파
class ReserveInventoryOnOrderConfirmed
implements DomainEventHandler<OrderConfirmedEvent> {
constructor(private inventoryService: InventoryService) {}
async handle(event: OrderConfirmedEvent): Promise<void> {
await this.inventoryService.reserve(event.orderId);
}
}
느슨한 결합 — Aggregate 간 직접 참조 없이 통신 가능
감사 추적 — 시스템에서 발생한 모든 중요 사건 기록
CQRS/ES 기반 — Event Sourcing과 자연스럽게 연결
핵심 요약
- Entity — 식별자로 구분되며, 생명주기 동안 연속성을 유지
- Value Object — 속성 값으로 동등성 판단, 불변 객체
- Aggregate — 일관성 경계, Root를 통해서만 접근, 하나의 트랜잭션 단위
- Repository — Aggregate 영속성 추상화, 인터페이스는 도메인에 정의
- Domain Service — 여러 Aggregate에 걸친 도메인 로직, 무상태
- Domain Event — 도메인의 중요한 사건, 느슨한 결합과 감사 추적