--- 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 void | Promise)[]>(); on(tag: string, handler: (ev: E) => void | Promise) { 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', 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]]