Files
2nd/10_Wiki/Topics/Coding/Arch_Domain_Events.md
T
2026-05-09 21:08:02 +09:00

237 lines
6.4 KiB
Markdown

---
id: arch-domain-events
title: Domain Events — 발행 / 처리 / 형식
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [architecture, ddd, events, vibe-coding]
tech_stack: { language: "TS", applicable_to: ["Backend"] }
applied_in: []
aliases: [domain event, integration event, event handler, eventual consistency, in-process events]
---
# Domain Events
> "이미 일어난 fact". **Order Placed, Payment Received**. Aggregate 안 발생 → Repository save 후 발행 → 다른 context / async handler 처리. **In-process vs integration** 분리.
## 📖 핵심 개념
- Domain event: 도메인 안 사실. 과거형 동사 (Placed, Shipped).
- Integration event: context 간 통신 — 보통 더 큰 payload + 안정 schema.
- Synchronous vs Async.
- Outbox 로 publish atomicity.
## 💻 코드 패턴
### Event 정의
```ts
// 과거형 + 명사
abstract class DomainEvent {
readonly id = uuid();
readonly occurredAt = new Date();
abstract readonly _tag: string;
}
class OrderPlaced extends DomainEvent {
readonly _tag = 'OrderPlaced';
constructor(public readonly orderId: OrderId, public readonly userId: UserId, public readonly total: Money) {
super();
}
}
class OrderShipped extends DomainEvent {
readonly _tag = 'OrderShipped';
constructor(public readonly orderId: OrderId, public readonly trackingNumber: string) {
super();
}
}
```
### Aggregate 안 record
```ts
class Order {
private events: DomainEvent[] = [];
static place(userId: UserId, items: OrderItem[]): Order {
const order = new Order(OrderId.generate(), userId, items, 'placed');
order.events.push(new OrderPlaced(order.id, userId, order.total()));
return order;
}
ship(tracking: string) {
if (this.status !== 'paid') throw new Error('cannot ship unpaid');
this.status = 'shipped';
this.events.push(new OrderShipped(this.id, tracking));
}
pullEvents(): DomainEvent[] {
const ev = this.events;
this.events = [];
return ev;
}
}
```
### In-process dispatch (synchronous)
```ts
class EventBus {
private handlers = new Map<string, ((ev: DomainEvent) => void | Promise<void>)[]>();
on<E extends DomainEvent>(tag: string, handler: (ev: E) => void | Promise<void>) {
if (!this.handlers.has(tag)) this.handlers.set(tag, []);
this.handlers.get(tag)!.push(handler as any);
}
async publish(events: DomainEvent[]) {
for (const ev of events) {
const hs = this.handlers.get(ev._tag) ?? [];
for (const h of hs) await h(ev);
}
}
}
// register
bus.on<OrderPlaced>('OrderPlaced', async (ev) => {
await sendOrderConfirmation(ev.userId, ev.orderId);
});
```
### Repository 가 발행
```ts
class OrderRepository {
constructor(private db: Db, private bus: EventBus) {}
async save(order: Order) {
const events = order.pullEvents();
await this.db.transaction(async (tx) => {
await tx.orders.upsert(toRow(order));
// Outbox 패턴 — 같은 트랜잭션
for (const ev of events) {
await tx.outbox.insert({ type: ev._tag, payload: ev });
}
});
// 트랜잭션 후에 in-process publish (best-effort)
await this.bus.publish(events);
}
}
```
### Domain event vs Integration event
```ts
// Domain event — context 안, 작음
class OrderPlaced { orderId; userId; }
// Integration event — context 외, 풍부 + 안정 schema
class OrderPlacedIntegration {
readonly version = '1.0';
readonly _tag = 'order.placed';
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly customerEmail: string, // shipping context 가 필요
public readonly items: { productId: string; qty: number; price: number }[],
public readonly total: { amount: number; currency: string },
public readonly shippingAddress: { ... },
) {}
}
// Domain → Integration 변환
function toIntegration(ev: OrderPlaced, order: Order, user: User): OrderPlacedIntegration {
return { ... };
}
```
→ Integration 가 다른 service 의 contract — 변경에 안정.
### Async handler (background)
```ts
// outbox publisher 가 broker 로
on('OrderPlaced', async (ev: OrderPlacedIntegration) => {
// 다른 service / context
await emailService.send({ template: 'orderConfirmation', to: ev.customerEmail });
});
```
### Sync vs Async 결정
```
Sync (in-process):
- 비즈니스 invariant 에 영향 (이메일 fail 시 order 도 rollback?)
- 같은 context 안
Async (background):
- 외부 통신 (이메일, 알림)
- 다른 context
- 실패 OK (재시도)
```
→ 보통 외부 효과 = async. 더 안전.
### Event versioning
```ts
class OrderPlacedV2 {
readonly _tag = 'order.placed.v2';
// V1 + 새 필드
}
// Consumer 가 둘 다 처리
on('order.placed.v1', (ev) => handle(upgradeToV2(ev)));
on('order.placed.v2', (ev) => handle(ev));
```
→ Schema registry 같이 사용.
### Event sourcing 과 차이
```
Domain event in CRUD:
- DB 가 source of truth (state 저장)
- Event 는 알림 / side effect
Domain event in ES:
- Event = source of truth
- State = events fold
```
이 문서는 CRUD + event 알림 패턴. ES 는 [[Backend_Event_Sourcing]].
### 명명 convention
```
Past tense (이미 일어남):
✅ OrderPlaced, PaymentReceived, UserRegistered
❌ PlaceOrder (command — request, not fact)
Naming:
{Aggregate}{Action}
domain.{aggregate}.{action} — broker topic
```
## 🤔 의사결정 기준
| 사용 | 패턴 |
|---|---|
| Aggregate 안 변경 알림 | Domain event |
| Context 간 통신 | Integration event + outbox |
| 동기 검증 / 일관성 | Sync handler |
| 외부 효과 (email, push) | Async + retry |
| Replay / audit | Event sourcing |
| 단순 CRUD | event 없이 OK |
## ❌ 안티패턴
- **Event 너무 잘게 (각 setter)**: noise. 비즈니스 의미만.
- **Event = command**: 헷갈림. 동사 시제 검증.
- **Aggregate 직접 외부 publish**: side-effect. Repository 가.
- **Sync handler 가 외부 호출**: 트랜잭션 길어짐.
- **Schema breaking change**: 새 version 만들기.
- **Event = current state**: 그건 read model. event = 변경.
- **모든 곳 listen — 없는 곳 없음**: 결합 강 — 의도 명시.
## 🤖 LLM 활용 힌트
- 과거형 + aggregate 별 명명.
- Outbox + async 가 안전.
- Domain ≠ Integration — 분리.
## 🔗 관련 문서
- [[Backend_Event_Sourcing]]
- [[Backend_Outbox_Pattern]]
- [[Arch_Aggregate_Design]]