DDD 이론 12: Event Sourcing과 DDD 실천
Event Sourcing의 원리와 구현, Event Storming 워크샵, DDD 아키텍처 패턴, 그리고 점진적 도입 전략을 학습합니다.
- • Event Sourcing의 원리와 구현 방법을 이해한다
- • Snapshot과 Projection을 구현할 수 있다
- • Event Storming 워크샵을 진행할 수 있다
- • Hexagonal/Clean Architecture를 DDD와 함께 적용할 수 있다
- • 레거시 시스템에 DDD를 점진적으로 도입할 수 있다
- • 12개 세션의 DDD 핵심 개념을 종합적으로 이해한다
1. Event Sourcing의 본질
"현재 상태를 저장하는 대신, 상태 변경을 일으킨 이벤트들의 시퀀스를 저장한다. 현재 상태는 이벤트들을 재생하여 도출한다."
— Martin Fowler
1.1 State Sourcing vs Event Sourcing
State Sourcing (전통적)
Account Table: | id | balance | updated_at | | 1 | 1000 | 2024-01-15 | → 현재 잔액만 알 수 있음 → 어떻게 1000이 되었는지 모름 → 이력 추적 불가
- • 현재 상태만 저장
- • UPDATE로 덮어쓰기
- • 이력 손실
Event Sourcing
Event Store: | AccountOpened(id=1, initial=0) | MoneyDeposited(id=1, amount=500) | MoneyDeposited(id=1, amount=700) | MoneyWithdrawn(id=1, amount=200) → 재생: 0+500+700-200 = 1000 → 전체 이력 보존
- • 이벤트 시퀀스 저장
- • Append-only
- • 완전한 감사 로그
1.2 Event Sourcing 구현
// Event Sourced Aggregate
abstract class EventSourcedAggregate<TId> {
private _uncommittedEvents: DomainEvent[] = [];
private _version: number = 0;
get uncommittedEvents(): ReadonlyArray<DomainEvent> {
return this._uncommittedEvents;
}
get version(): number {
return this._version;
}
// 이벤트 적용 (상태 변경)
protected apply(event: DomainEvent): void {
this.when(event);
this._uncommittedEvents.push(event);
}
// 이벤트 재생 (복원)
loadFromHistory(events: DomainEvent[]): void {
for (const event of events) {
this.when(event);
this._version++;
}
}
// 이벤트별 상태 변경 로직 (하위 클래스에서 구현)
protected abstract when(event: DomainEvent): void;
clearUncommittedEvents(): void {
this._uncommittedEvents = [];
}
}
// 구체적인 Event Sourced Aggregate
class Account extends EventSourcedAggregate<AccountId> {
private _id!: AccountId;
private _balance: Money = Money.zero();
private _status: AccountStatus = AccountStatus.ACTIVE;
get id(): AccountId { return this._id; }
get balance(): Money { return this._balance; }
// Factory Method
static open(id: AccountId, initialDeposit: Money): Account {
const account = new Account();
account.apply(new AccountOpenedEvent(id, initialDeposit, new Date()));
return account;
}
// Command Methods
deposit(amount: Money): void {
if (this._status !== AccountStatus.ACTIVE) {
throw new AccountNotActiveError();
}
this.apply(new MoneyDepositedEvent(this._id, amount, new Date()));
}
withdraw(amount: Money): void {
if (this._status !== AccountStatus.ACTIVE) {
throw new AccountNotActiveError();
}
if (this._balance.isLessThan(amount)) {
throw new InsufficientFundsError();
}
this.apply(new MoneyWithdrawnEvent(this._id, amount, new Date()));
}
close(): void {
if (!this._balance.isZero()) {
throw new AccountHasBalanceError();
}
this.apply(new AccountClosedEvent(this._id, new Date()));
}
// Event Handlers (상태 변경)
protected when(event: DomainEvent): void {
if (event instanceof AccountOpenedEvent) {
this._id = event.accountId;
this._balance = event.initialDeposit;
this._status = AccountStatus.ACTIVE;
} else if (event instanceof MoneyDepositedEvent) {
this._balance = this._balance.add(event.amount);
} else if (event instanceof MoneyWithdrawnEvent) {
this._balance = this._balance.subtract(event.amount);
} else if (event instanceof AccountClosedEvent) {
this._status = AccountStatus.CLOSED;
}
}
}1.3 Event Store
// Event Store Interface
interface EventStore {
// 이벤트 저장
append(
streamId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<void>;
// 이벤트 조회
getEvents(
streamId: string,
fromVersion?: number
): Promise<DomainEvent[]>;
// 전체 이벤트 스트림 (Projection용)
getAllEvents(
fromPosition?: number,
limit?: number
): Promise<{ events: DomainEvent[]; lastPosition: number }>;
}
// PostgreSQL 기반 Event Store 구현
class PostgresEventStore implements EventStore {
async append(
streamId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<void> {
await this.db.transaction(async (tx) => {
// Optimistic Concurrency Check
const currentVersion = await tx.query(
'SELECT MAX(version) as version FROM events WHERE stream_id = $1',
[streamId]
);
if (currentVersion.rows[0].version !== expectedVersion) {
throw new ConcurrencyError(
`Expected version ${expectedVersion}, but was ${currentVersion.rows[0].version}`
);
}
// Append Events
let version = expectedVersion;
for (const event of events) {
version++;
await tx.query(`
INSERT INTO events (
event_id, stream_id, version, event_type,
payload, metadata, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
`, [
event.eventId,
streamId,
version,
event.constructor.name,
JSON.stringify(event),
JSON.stringify({ correlationId: event.correlationId }),
event.occurredAt
]);
}
});
}
async getEvents(streamId: string, fromVersion: number = 0): Promise<DomainEvent[]> {
const result = await this.db.query(`
SELECT event_type, payload
FROM events
WHERE stream_id = $1 AND version > $2
ORDER BY version ASC
`, [streamId, fromVersion]);
return result.rows.map(row =>
this.deserialize(row.event_type, row.payload)
);
}
}1.4 Event Sourcing 장단점
장점
- ✓ 완전한 감사 로그
- ✓ 시간 여행 (특정 시점 상태 복원)
- ✓ 이벤트 재생으로 버그 재현
- ✓ 새로운 Read Model 추가 용이
- ✓ 도메인 이벤트 자연스럽게 도출
단점
- ✗ 학습 곡선
- ✗ 이벤트 스키마 진화 복잡
- ✗ 조회 시 재생 비용 (Snapshot 필요)
- ✗ 삭제 어려움 (GDPR 대응)
- ✗ 디버깅 복잡도
2. Snapshot과 Projection
2.1 Snapshot 패턴
이벤트가 많아지면 재생 시간이 길어집니다. Snapshot은 특정 시점의 상태를 저장하여 재생 시작점을 앞당깁니다.
// Snapshot 저장소
interface SnapshotStore {
save(aggregateId: string, snapshot: Snapshot): Promise<void>;
get(aggregateId: string): Promise<Snapshot | null>;
}
interface Snapshot {
aggregateId: string;
version: number;
state: any;
createdAt: Date;
}
// Snapshot을 활용한 Repository
class EventSourcedAccountRepository {
constructor(
private readonly eventStore: EventStore,
private readonly snapshotStore: SnapshotStore,
private readonly snapshotFrequency: number = 100 // 100 이벤트마다 스냅샷
) {}
async findById(id: AccountId): Promise<Account | null> {
const streamId = `account-${id.value}`;
// 1. 스냅샷 조회
const snapshot = await this.snapshotStore.get(streamId);
// 2. 스냅샷 이후 이벤트만 조회
const fromVersion = snapshot?.version ?? 0;
const events = await this.eventStore.getEvents(streamId, fromVersion);
if (!snapshot && events.length === 0) {
return null;
}
// 3. Aggregate 복원
const account = new Account();
if (snapshot) {
account.restoreFromSnapshot(snapshot.state);
}
account.loadFromHistory(events);
return account;
}
async save(account: Account): Promise<void> {
const streamId = `account-${account.id.value}`;
const events = account.uncommittedEvents;
if (events.length === 0) return;
// 1. 이벤트 저장
await this.eventStore.append(streamId, [...events], account.version);
// 2. 스냅샷 저장 (주기적으로)
const newVersion = account.version + events.length;
if (newVersion % this.snapshotFrequency === 0) {
await this.snapshotStore.save(streamId, {
aggregateId: streamId,
version: newVersion,
state: account.toSnapshot(),
createdAt: new Date()
});
}
account.clearUncommittedEvents();
}
}
// Aggregate에 Snapshot 지원 추가
class Account extends EventSourcedAggregate<AccountId> {
// ... 기존 코드 ...
toSnapshot(): AccountSnapshot {
return {
id: this._id.value,
balance: this._balance.amount,
currency: this._balance.currency,
status: this._status
};
}
restoreFromSnapshot(snapshot: AccountSnapshot): void {
this._id = AccountId.from(snapshot.id);
this._balance = Money.of(snapshot.balance, snapshot.currency);
this._status = snapshot.status;
}
}2.2 Projection 구현
// Projection: 이벤트 스트림을 Read Model로 변환
interface Projection {
name: string;
handle(event: DomainEvent, position: number): Promise<void>;
}
// Account Balance Projection
class AccountBalanceProjection implements Projection {
name = 'account-balance';
constructor(private readonly readDb: ReadDatabase) {}
async handle(event: DomainEvent, position: number): Promise<void> {
if (event instanceof AccountOpenedEvent) {
await this.readDb.execute(`
INSERT INTO account_balances (account_id, balance, currency, status, last_updated)
VALUES ($1, $2, $3, 'ACTIVE', $4)
`, [event.accountId.value, event.initialDeposit.amount,
event.initialDeposit.currency, event.occurredAt]);
}
if (event instanceof MoneyDepositedEvent) {
await this.readDb.execute(`
UPDATE account_balances
SET balance = balance + $2, last_updated = $3
WHERE account_id = $1
`, [event.accountId.value, event.amount.amount, event.occurredAt]);
}
if (event instanceof MoneyWithdrawnEvent) {
await this.readDb.execute(`
UPDATE account_balances
SET balance = balance - $2, last_updated = $3
WHERE account_id = $1
`, [event.accountId.value, event.amount.amount, event.occurredAt]);
}
}
}
// Transaction History Projection
class TransactionHistoryProjection implements Projection {
name = 'transaction-history';
constructor(private readonly readDb: ReadDatabase) {}
async handle(event: DomainEvent, position: number): Promise<void> {
if (event instanceof MoneyDepositedEvent) {
await this.readDb.execute(`
INSERT INTO transactions (id, account_id, type, amount, occurred_at)
VALUES ($1, $2, 'DEPOSIT', $3, $4)
`, [event.eventId, event.accountId.value, event.amount.amount, event.occurredAt]);
}
if (event instanceof MoneyWithdrawnEvent) {
await this.readDb.execute(`
INSERT INTO transactions (id, account_id, type, amount, occurred_at)
VALUES ($1, $2, 'WITHDRAWAL', $3, $4)
`, [event.eventId, event.accountId.value, event.amount.amount, event.occurredAt]);
}
}
}
// Projection Manager
class ProjectionManager {
private projections: Projection[] = [];
private checkpointStore: CheckpointStore;
register(projection: Projection): void {
this.projections.push(projection);
}
async run(): Promise<void> {
while (true) {
for (const projection of this.projections) {
await this.processProjection(projection);
}
await this.delay(100); // Polling interval
}
}
private async processProjection(projection: Projection): Promise<void> {
const checkpoint = await this.checkpointStore.get(projection.name);
const { events, lastPosition } = await this.eventStore.getAllEvents(
checkpoint?.position ?? 0,
100 // Batch size
);
for (const event of events) {
await projection.handle(event, lastPosition);
}
if (events.length > 0) {
await this.checkpointStore.save(projection.name, lastPosition);
}
}
}2.3 이벤트 스키마 진화
// 이벤트 버전 관리
interface VersionedEvent {
version: number;
}
// V1: 초기 버전
interface MoneyDepositedEventV1 extends VersionedEvent {
version: 1;
accountId: string;
amount: number; // 단일 통화 가정
}
// V2: 다중 통화 지원
interface MoneyDepositedEventV2 extends VersionedEvent {
version: 2;
accountId: string;
amount: number;
currency: string; // 새 필드 추가
}
// Upcaster: 구버전 → 신버전 변환
interface EventUpcaster<TFrom, TTo> {
canUpcast(event: any): boolean;
upcast(event: TFrom): TTo;
}
class MoneyDepositedV1ToV2Upcaster implements EventUpcaster<MoneyDepositedEventV1, MoneyDepositedEventV2> {
canUpcast(event: any): boolean {
return event.type === 'MoneyDeposited' && event.version === 1;
}
upcast(event: MoneyDepositedEventV1): MoneyDepositedEventV2 {
return {
...event,
version: 2,
currency: 'KRW' // 기본값 설정
};
}
}
// Event Store에서 Upcasting 적용
class UpcastingEventStore implements EventStore {
constructor(
private readonly inner: EventStore,
private readonly upcasters: EventUpcaster<any, any>[]
) {}
async getEvents(streamId: string, fromVersion?: number): Promise<DomainEvent[]> {
const events = await this.inner.getEvents(streamId, fromVersion);
return events.map(event => this.upcast(event));
}
private upcast(event: any): DomainEvent {
let current = event;
for (const upcaster of this.upcasters) {
if (upcaster.canUpcast(current)) {
current = upcaster.upcast(current);
}
}
return current;
}
}3. Event Storming
"Event Storming은 복잡한 비즈니스 도메인을 빠르게 탐색하는 워크샵 기반 방법이다. 개발자와 도메인 전문가가 함께 큰 그림을 그린다."
— Alberto Brandolini, Event Storming 창시자
3.1 Event Storming 진행 방법
Domain Events 나열 (오렌지)
과거형으로 "~되었다" 형태. 시간순으로 배치.
Commands 식별 (파란색)
이벤트를 발생시키는 명령. 명령형으로 작성.
Aggregates 식별 (노란색)
명령을 받고 이벤트를 발생시키는 주체.
Bounded Context 경계 그리기
관련된 Aggregate들을 묶어 컨텍스트 경계 식별.
3.2 Event Storming 결과물
┌─────────────────────────────────────────────────────────────────────────────┐ │ 이커머스 Event Storming 결과 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ Order Context (주문 컨텍스트) │ │ │ │ │ │ │ │ [고객]──▶[주문하기]──▶[주문]──▶[주문이 생성되었다] │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ [주문 확정하기]──▶[주문이 확정되었다] │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ │ 주문이 생성되었다 │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ Payment Context (결제 컨텍스트) │ │ │ │ │ │ │ │ [결제하기]──▶[결제]──▶[결제가 완료되었다] / [결제가 실패했다] │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ │ 결제가 완료되었다 │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ Inventory Context (재고 컨텍스트) │ │ │ │ │ │ │ │ [재고 예약하기]──▶[재고]──▶[재고가 예약되었다] / [재고 부족] │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ │ 재고가 예약되었다 │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ Shipping Context (배송 컨텍스트) │ │ │ │ │ │ │ │ [배송 시작하기]──▶[배송]──▶[배송이 시작되었다]──▶[배송이 완료되었다] │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ 범례: [Actor] [Command] [Aggregate] [Domain Event] │ │ │ └─────────────────────────────────────────────────────────────────────────────┘
3.3 Event Storming 팁
✅ Do
- • 도메인 전문가 반드시 참여
- • 큰 벽면/화이트보드 사용
- • 포스트잇 색상 규칙 준수
- • 질문과 문제점 빨간 포스트잇으로 표시
- • 타임박싱 (2-4시간)
❌ Don't
- • 기술적 세부사항에 빠지지 않기
- • 완벽함 추구하지 않기
- • 한 사람이 독점하지 않기
- • 처음부터 코드 생각하지 않기
- • 너무 작은 이벤트 나열하지 않기
4. DDD와 아키텍처
4.1 Hexagonal Architecture (Ports & Adapters)
┌─────────────────────────────────────────────────────────────────────────┐ │ Hexagonal Architecture │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────┐ │ │ REST API ──────▶│ │◀────── CLI │ │ │ Application │ │ │ GraphQL ───────▶│ (Ports) │◀────── Message Queue │ │ │ │ │ │ │ ┌─────────────────┐ │ │ │ │ │ │ │ │ │ │ │ Domain │ │ │ │ │ │ (Core Logic) │ │ │ │ │ │ │ │ │ │ │ └─────────────────┘ │ │ │ │ │ │ │ │ Infrastructure │ │ │ │ (Adapters) │ │ │ └───────────┬─────────────┘ │ │ │ │ │ ┌─────────────────┼─────────────────┐ │ │ ▼ ▼ ▼ │ │ PostgreSQL Redis Kafka │ │ │ └─────────────────────────────────────────────────────────────────────────┘
// Hexagonal Architecture 구조
src/
├── domain/ # 핵심 도메인 (의존성 없음)
│ ├── order/
│ │ ├── Order.ts # Aggregate
│ │ ├── OrderItem.ts # Entity
│ │ ├── OrderStatus.ts # Value Object
│ │ └── events/
│ │ └── OrderCreated.ts
│ └── shared/
│ └── ValueObjects.ts
│
├── application/ # 유스케이스 (Port 정의)
│ ├── ports/
│ │ ├── inbound/ # Driving Ports (외부 → 내부)
│ │ │ ├── CreateOrderUseCase.ts
│ │ │ └── GetOrderUseCase.ts
│ │ └── outbound/ # Driven Ports (내부 → 외부)
│ │ ├── OrderRepository.ts
│ │ ├── PaymentGateway.ts
│ │ └── EventPublisher.ts
│ └── services/
│ ├── CreateOrderService.ts
│ └── GetOrderService.ts
│
└── infrastructure/ # Adapters (구현체)
├── adapters/
│ ├── inbound/ # Driving Adapters
│ │ ├── rest/
│ │ │ └── OrderController.ts
│ │ └── graphql/
│ │ └── OrderResolver.ts
│ └── outbound/ # Driven Adapters
│ ├── persistence/
│ │ └── TypeOrmOrderRepository.ts
│ ├── payment/
│ │ └── StripePaymentGateway.ts
│ └── messaging/
│ └── KafkaEventPublisher.ts
└── config/
└── DependencyInjection.ts4.2 Clean Architecture
Entities (가장 안쪽)
핵심 비즈니스 규칙. 외부 변화에 영향받지 않음.
Use Cases
애플리케이션 비즈니스 규칙. 유스케이스 조율.
Interface Adapters
데이터 변환. Controller, Presenter, Gateway.
Frameworks (가장 바깥)
DB, Web Framework, UI. 가장 변경 가능성 높음.
의존성 규칙: 안쪽 레이어는 바깥쪽 레이어를 알지 못함. 의존성은 항상 안쪽을 향함.
4.3 Layered Architecture와 비교
| 특성 | Layered | Hexagonal | Clean |
|---|---|---|---|
| 의존성 방향 | 위 → 아래 | 바깥 → 안 | 바깥 → 안 |
| 도메인 위치 | 중간 레이어 | 중심 | 중심 |
| 인프라 교체 | 어려움 | 쉬움 | 쉬움 |
| 테스트 용이성 | 보통 | 높음 | 높음 |
4.4 팀 토폴로지와 Bounded Context
Conway's Law: 시스템 구조는 조직 구조를 반영한다. Bounded Context는 팀 경계와 일치시키는 것이 이상적입니다.
┌─────────────────────────────────────────────────────────────────────────┐ │ Team Topology & Bounded Context │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ │ Order Team │ │ Payment Team │ │ Shipping Team │ │ │ │ │ │ │ │ │ │ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ │ │ │ Order │ │ │ │ Payment │ │ │ │ Shipping │ │ │ │ │ │ Context │ │ │ │ Context │ │ │ │ Context │ │ │ │ │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ - Order API │ │ - Payment API │ │ - Shipping API │ │ │ │ - Order DB │ │ - Payment DB │ │ - Shipping DB │ │ │ │ - Order Events │ │ - Payment Events│ │ - Shipping Events│ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ │ │ │ │ │ └─────────────────────┼──────────────────────┘ │ │ │ │ │ ┌────────────▼────────────┐ │ │ │ Platform Team │ │ │ │ (Enabling Team) │ │ │ │ │ │ │ │ - Message Broker │ │ │ │ - API Gateway │ │ │ │ - Observability │ │ │ └─────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘
5. DDD 점진적 도입 전략
5.1 도입 단계
Ubiquitous Language 구축
도메인 전문가와 용어 정리. 용어집 작성. 코드에 반영.
Value Object 도입
Primitive Obsession 제거. Money, Email, Address 등 도입.
Entity와 Aggregate 식별
핵심 도메인부터 Aggregate 경계 설정. 불변식 정의.
Repository 패턴 적용
Aggregate 단위로 Repository 구현. 인프라 분리.
Domain Events 도입
Aggregate 간 통신을 이벤트로. 느슨한 결합.
CQRS / Event Sourcing (선택)
필요한 경우에만. 복잡도 대비 이점 평가.
5.2 레거시 시스템에서의 DDD
Strangler Fig 패턴
레거시 시스템을 점진적으로 교체. 새 기능은 DDD로 구현하고, 기존 기능을 하나씩 마이그레이션.
Anti-Corruption Layer
레거시와 새 시스템 사이에 번역 계층 구축. 레거시 모델이 새 도메인 모델을 오염시키지 않도록 보호.
Bubble Context
레거시 내에 작은 DDD 영역 생성. 성공 사례를 만들고 점진적으로 확장.
5.3 DDD 적용 체크리스트
6. FAQ 및 DDD 시리즈 총정리
완전한 감사 로그가 필요하거나, 시간 여행(특정 시점 상태 복원)이 필요하거나, 복잡한 비즈니스 이벤트 흐름이 있는 경우에 적합합니다. 단순 CRUD에는 과도한 복잡도를 추가합니다.
아니요. DDD는 모놀리식에서도 유용합니다. Bounded Context는 모듈 경계로 사용할 수 있고, 나중에 마이크로서비스로 분리할 때 자연스러운 경계가 됩니다.
전체 DDD를 적용할 필요는 없습니다. Value Object, Ubiquitous Language 같은 전술적 패턴은 작은 프로젝트에서도 유용합니다. 복잡도에 맞게 선택적으로 적용하세요.
실제 프로젝트에 적용해보세요. Event Storming 워크샵을 진행하고, 작은 Bounded Context부터 시작하세요. "Implementing Domain-Driven Design" (Vaughn Vernon) 책을 추천합니다.
DDD 시리즈 총정리
| 세션 | 주제 | 핵심 개념 |
|---|---|---|
| 01 | DDD 소개 | 복잡성 관리, 전략적/전술적 설계 |
| 02 | Ubiquitous Language | 공통 언어, 도메인 전문가 협업 |
| 03 | 도메인과 서브도메인 | Core/Supporting/Generic 도메인 |
| 04 | Bounded Context | 컨텍스트 경계, 모델 분리 |
| 05 | Context Mapping | 컨텍스트 간 관계, 통합 패턴 |
| 06 | Entity와 Value Object | 식별성, 불변성, Primitive Obsession |
| 07 | Aggregate | 일관성 경계, Aggregate Root |
| 08 | Repository, Factory, Module | 영속성 추상화, 생성 패턴 |
| 09 | Service와 Supple Design | Domain/Application Service, 유연한 설계 |
| 10 | Domain Events | 이벤트 발행, Saga, Outbox 패턴 |
| 11 | CQRS | Command/Query 분리, Read Model |
| 12 | Event Sourcing과 실천 | 이벤트 저장, Event Storming, 아키텍처 |
- • Ubiquitous Language
- • Bounded Context
- • Context Mapping
- • 서브도메인 분류
- • Entity & Value Object
- • Aggregate
- • Repository & Factory
- • Domain Service
- • Domain Events
- • CQRS
- • Event Sourcing
- • Saga
"DDD는 기술이 아니라 사고방식이다.
도메인을 이해하고, 그 이해를 코드에 반영하는 것이 핵심이다."
— Eric Evans