Skip to the content.

Domain Event Guide

Domain Layer의 Domain Event 설계 가이드

📌 V2 컨벤션: Aggregate 내부에서 Event 생성, pullDomainEvents() 패턴 사용


1) 핵심 원칙

필수 규칙 (Zero-Tolerance)

규칙 설명 예시
DomainEvent 인터페이스 구현 모든 도메인 이벤트는 DomainEvent 인터페이스 구현 필수 OrderCreatedEvent implements DomainEvent
Record 타입 사용 불변성 보장을 위해 record 사용 필수 public record OrderCreatedEvent(...)
VO 타입 필드 사용 원시 타입 대신 Value Object 사용 OrderId orderId (O), Long orderId (X)
from() 팩토리 메서드 Aggregate로부터 Event 생성 시 from() 사용 OrderCreatedEvent.from(order)
Event 접미사 클래스명은 *Event로 끝나야 함 OrderCreatedEvent, OrderCancelledEvent
Aggregate 내부 생성 Event는 Aggregate 내부에서만 생성 order.registerEvent(...)

금지사항

금지 항목 이유
Lombok 사용 Pure Java 원칙, 명시적 코드 작성
원시 타입 필드 타입 안전성 부족, 도메인 의미 불명확
Setter 메서드 불변성 위반, record 사용으로 자동 방지
외부에서 직접 생성 Aggregate 상태와 Event 불일치 위험
Mutable 필드 Event는 발생 시점 상태의 스냅샷

2) DomainEvent 인터페이스

package com.ryuqq.domain.common.event;

import java.time.Instant;

/**
 * 도메인 이벤트 마커 인터페이스
 *
 * <p>모든 도메인 이벤트는 이 인터페이스를 구현해야 합니다.</p>
 */
public interface DomainEvent {

    /**
     * 이벤트 발생 시각
     *
     * @return 이벤트가 생성된 시각
     */
    Instant occurredAt();

    /**
     * 이벤트 타입 식별자
     *
     * <p>기본 구현은 클래스 단순명을 반환합니다.</p>
     *
     * @return 이벤트 타입 문자열
     */
    default String eventType() {
        return this.getClass().getSimpleName();
    }
}

3) Domain Event 구현 패턴

기본 구조

package com.ryuqq.domain.order.event;

import com.ryuqq.domain.common.event.DomainEvent;
import com.ryuqq.domain.order.aggregate.order.Order;
import com.ryuqq.domain.order.vo.OrderId;
import com.ryuqq.domain.order.vo.OrderStatus;
import com.ryuqq.domain.order.vo.Money;
import com.ryuqq.domain.member.vo.MemberId;

import java.time.Instant;

/**
 * 주문 생성 이벤트
 *
 * <p>주문이 성공적으로 생성되었을 때 발행됩니다.</p>
 *
 * @param orderId 주문 ID (VO)
 * @param memberId 회원 ID (VO)
 * @param totalAmount 주문 총액 (VO)
 * @param status 주문 상태 (VO/Enum)
 * @param occurredAt 이벤트 발생 시각
 */
public record OrderCreatedEvent(
    OrderId orderId,
    MemberId memberId,
    Money totalAmount,
    OrderStatus status,
    Instant occurredAt
) implements DomainEvent {

    /**
     * Aggregate로부터 Event 생성
     *
     * <p>이 메서드만 사용하여 Event를 생성해야 합니다.</p>
     * <p>Aggregate가 Clock에서 얻은 시간을 전달받아 사용합니다.</p>
     *
     * @param order 주문 Aggregate
     * @param occurredAt 이벤트 발생 시각 (Aggregate의 clock.instant()에서 전달)
     * @return 주문 생성 이벤트
     */
    public static OrderCreatedEvent from(Order order, Instant occurredAt) {
        return new OrderCreatedEvent(
            order.id(),
            order.memberId(),
            order.totalAmount(),
            order.status(),
            occurredAt
        );
    }
}

상태 변경 이벤트

package com.ryuqq.domain.order.event;

import com.ryuqq.domain.common.event.DomainEvent;
import com.ryuqq.domain.order.aggregate.order.Order;
import com.ryuqq.domain.order.vo.OrderId;
import com.ryuqq.domain.order.vo.OrderStatus;

import java.time.Instant;

/**
 * 주문 취소 이벤트
 *
 * @param orderId 주문 ID
 * @param previousStatus 이전 상태
 * @param currentStatus 현재 상태 (CANCELLED)
 * @param cancelReason 취소 사유
 * @param occurredAt 이벤트 발생 시각
 */
public record OrderCancelledEvent(
    OrderId orderId,
    OrderStatus previousStatus,
    OrderStatus currentStatus,
    String cancelReason,
    Instant occurredAt
) implements DomainEvent {

    public static OrderCancelledEvent from(Order order, OrderStatus previousStatus, String reason, Instant occurredAt) {
        return new OrderCancelledEvent(
            order.id(),
            previousStatus,
            order.status(),
            reason,
            occurredAt
        );
    }
}

4) Aggregate와 Event 통합

Aggregate 내부 Event 관리

package com.ryuqq.domain.order.aggregate.order;

import com.ryuqq.domain.common.event.DomainEvent;
import com.ryuqq.domain.order.event.OrderCreatedEvent;
import com.ryuqq.domain.order.event.OrderCancelledEvent;
import com.ryuqq.domain.order.vo.OrderId;
import com.ryuqq.domain.order.vo.OrderStatus;
import com.ryuqq.domain.order.vo.Money;
import com.ryuqq.domain.member.vo.MemberId;

import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

/**
 * 주문 Aggregate Root
 */
public class Order {

    private final OrderId id;
    private final MemberId memberId;
    private final Money totalAmount;
    private OrderStatus status;
    private final Clock clock;  // Clock 주입 (테스트 가능성)

    // Domain Events 내부 저장소
    private final List<DomainEvent> domainEvents = new ArrayList<>();

    private Order(OrderId id, MemberId memberId, Money totalAmount, OrderStatus status, Clock clock) {
        this.id = id;
        this.memberId = memberId;
        this.totalAmount = totalAmount;
        this.status = status;
        this.clock = clock;
    }

    /**
     * 신규 주문 생성 + Event 등록
     */
    public static Order forNew(MemberId memberId, Money totalAmount, Clock clock) {
        OrderId orderId = OrderId.generate();
        Order order = new Order(orderId, memberId, totalAmount, OrderStatus.CREATED, clock);

        // Aggregate 내부에서 Event 생성 및 등록 (Clock에서 시간 획득)
        Instant now = clock.instant();
        order.registerEvent(OrderCreatedEvent.from(order, now));

        return order;
    }

    /**
     * 영속화 복원 (Event 없음)
     */
    public static Order reconstitute(OrderId id, MemberId memberId, Money totalAmount, OrderStatus status, Clock clock) {
        return new Order(id, memberId, totalAmount, status, clock);
    }

    /**
     * 주문 취소 + Event 등록
     */
    public void cancel(String reason) {
        validateCancellable();

        OrderStatus previousStatus = this.status;
        this.status = OrderStatus.CANCELLED;

        // 상태 변경 후 Event 등록 (Clock에서 시간 획득)
        Instant now = clock.instant();
        registerEvent(OrderCancelledEvent.from(this, previousStatus, reason, now));
    }

    // ========== Event 관리 메서드 ==========

    /**
     * Event 등록 (내부용)
     */
    protected void registerEvent(DomainEvent event) {
        this.domainEvents.add(event);
    }

    /**
     * 등록된 Event 조회 및 초기화
     *
     * <p>Application Layer에서 호출하여 Event 발행 후 초기화합니다.</p>
     *
     * @return 등록된 도메인 이벤트 목록
     */
    public List<DomainEvent> pullDomainEvents() {
        List<DomainEvent> events = List.copyOf(this.domainEvents);
        this.domainEvents.clear();
        return events;
    }

    // ========== 검증 메서드 ==========

    private void validateCancellable() {
        if (this.status == OrderStatus.CANCELLED) {
            throw new IllegalStateException("이미 취소된 주문입니다.");
        }
        if (this.status == OrderStatus.DELIVERED) {
            throw new IllegalStateException("배송 완료된 주문은 취소할 수 없습니다.");
        }
    }

    // ========== Getter (VO 반환) ==========

    public OrderId id() { return id; }
    public MemberId memberId() { return memberId; }
    public Money totalAmount() { return totalAmount; }
    public OrderStatus status() { return status; }
}

Application Layer에서 Event 발행

package com.ryuqq.application.order.manager;

import com.ryuqq.application.port.out.command.OrderPersistencePort;
import com.ryuqq.domain.order.aggregate.order.Order;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class OrderPersistenceManager {

    private final OrderPersistencePort persistencePort;
    private final ApplicationEventPublisher eventPublisher;

    public OrderPersistenceManager(
        OrderPersistencePort persistencePort,
        ApplicationEventPublisher eventPublisher
    ) {
        this.persistencePort = persistencePort;
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public void save(Order order) {
        // 1. 영속화
        persistencePort.save(order);

        // 2. Event 발행 (영속화 성공 후)
        order.pullDomainEvents().forEach(eventPublisher::publishEvent);
    }
}

5) Event 필드 설계 원칙

VO 사용 필수

// ✅ 올바른 예: VO 타입 사용
public record OrderCreatedEvent(
    OrderId orderId,           // VO
    MemberId memberId,         // VO
    Money totalAmount,         // VO
    OrderStatus status,        // Enum (VO 취급)
    Instant occurredAt         // Java 표준 불변 타입
) implements DomainEvent { ... }

// ❌ 잘못된 예: 원시 타입 사용
public record OrderCreatedEvent(
    Long orderId,              // 원시 타입 래퍼
    Long memberId,             // 원시 타입 래퍼
    BigDecimal totalAmount,    // 도메인 의미 불명확
    String status,             // 타입 안전성 부족
    Instant occurredAt
) implements DomainEvent { ... }

필드 선택 가이드라인

포함해야 할 필드 포함하지 말아야 할 필드
Aggregate ID (식별자) 전체 Aggregate 객체
변경된 상태 값 변경되지 않은 값
이벤트 발생 시각 민감 정보 (비밀번호 등)
컨텍스트에 필요한 최소 정보 과도한 중첩 객체

상태 변경 이벤트 패턴

// 이전 상태와 현재 상태 모두 포함 (변경 추적 가능)
public record OrderStatusChangedEvent(
    OrderId orderId,
    OrderStatus previousStatus,    // 이전 상태
    OrderStatus currentStatus,     // 현재 상태
    Instant occurredAt
) implements DomainEvent {

    public static OrderStatusChangedEvent from(Order order, OrderStatus previousStatus, Instant occurredAt) {
        return new OrderStatusChangedEvent(
            order.id(),
            previousStatus,
            order.status(),
            occurredAt
        );
    }
}

6) 패키지 구조

domain/
└─ [boundedContext]/           # 예: order
   ├─ aggregate/
   │  └─ order/
   │     └─ Order.java         # Aggregate Root (Event 등록)
   │
   ├─ event/                   # Domain Event
   │  ├─ OrderCreatedEvent.java
   │  ├─ OrderCancelledEvent.java
   │  └─ OrderStatusChangedEvent.java
   │
   └─ vo/
      ├─ OrderId.java
      ├─ OrderStatus.java
      └─ Money.java

7) 체크리스트

Event 생성 시 확인사항

Aggregate 확인사항


8) 관련 문서