exposed-workshop / Visual Companion

Exposed + Spring Modulith + DDD

주문은 자기 저장소에 저장하고 배송은 공개된 이벤트만 받아 자기 저장소에 배송 예약을 만든다

하나의 애플리케이션과 DB를 사용해도 주문과 배송의 상태 변경 책임을 분리할 수 있다. 공개 이벤트는 두 모듈의 연결 규칙이 되고, Modulith 검증은 이를 어긴 컴파일 시점 참조를 테스트에서 차단한다.

주문orders transaction
계약orders :: events
배송shipping transaction
주문 수락 → 배송 예약ApplicationModules.verify()
Bounded Context

orders

OrderApplicationService
ddd_modulith_orders: ORD-1001 / ACCEPTED
OrderAcceptedEvent
Bounded Context

shipping

ShippingReservationHandler
ddd_modulith_shipping_reservations: ORD-1001 / RESERVED
    공개 이벤트를 통한 모듈 연결검증 통과

    DDD 적용 사례로 이 예제를 선택한 이유

    이 예제는 DDD 용어만 소개하지 않는다. 주문 수락과 배송 예약이라는 서로 다른 상태 변경을 독립 트랜잭션과 테이블로 구현하고, 허용된 이벤트 참조와 금지된 내부 참조를 자동 검증한다.

    업무 규칙

    상태 변경 책임이 코드에 드러난다

    AcceptOrderCommand는 주문 모듈로 들어가고 OrderApplicationService만 주문 상태를 변경한다.

    모듈 계약

    공개 범위가 실행 가능한 규칙이다

    orders.eventsNamed Interface이고 배송 모듈은 orders :: events만 참조할 수 있다.

    검증

    잘못된 설계를 실패 테스트로 보여준다

    shipping → orders.internal 참조를 넣은 픽스처에서 ApplicationModules.verify()Violations를 반환한다.

    Modulith 경계와 검증 흐름

    주문과 배송은 각자 상태와 Exposed 테이블을 관리한다. 두 모듈 사이에서는 orders :: events만 공개되며, Modulith 검증은 배송 모듈이 주문 모듈의 내부 구현을 직접 참조하지 못하게 한다.

    Orders Context가 공개 이벤트를 통해 Shipping Context와 연결되고 ApplicationModules.verify가 경계를 검사하는 다이어그램
    Spring Modulith 공개 경계와 검증 흐름 orders :: eventsNamed Interface로 공개하고, ApplicationModules.verify()shipping → orders.internal 참조를 차단하는 전체 구조를 보여준다. 다이어그램 크게 보기

    아키텍처 다이어그램

    orders는 주문 상태를, shipping은 배송 예약 상태를 각각 소유한다. 배송 모듈은 주문 모듈의 내부 구현을 참조하지 않고 공개된 OrderAcceptedEvent만 수신한다.

    Exposed로 주문 Aggregate의 일관성 경계를 구현한 방식

    이 예제에는 별도의 Order Aggregate Root 클래스는 없다. 대신 애플리케이션 서비스의 트랜잭션, 저장소의 저장 절차, Exposed 테이블의 제약 조건을 조합해 주문 접수에 필요한 일관성을 보장한다.

    1. 명령 처리 경계

    AcceptOrderCommandOrderApplicationService에서 처리해 주문 상태 변경 진입점을 한곳으로 제한한다.

    2. 트랜잭션 경계

    TransactionTemplate 안에서 주문을 저장하고 조회해 하나의 주문 접수 작업으로 처리한다.

    3. DB 일관성 제약

    WorkshopOrders.orderKey.uniqueIndex()가 동일한 주문 키의 중복 저장을 차단한다.

    4. 저장 결과 반환

    저장된 행을 OrderSummary로 매핑하고, 트랜잭션이 끝난 뒤 OrderAcceptedEvent를 발행한다.

    현재 구현의 범위: 주문 접수만 다루는 간결한 트랜잭션 스크립트 방식이다. 상태 전이 규칙이 늘어나면 Order Aggregate Root에 행위를 정의하고, 저장소가 Aggregate와 Exposed 행을 변환하도록 확장할 수 있다. 다음 클래스 다이어그램은 현재 소스에 실제로 존재하는 타입만 표시한다.

    클래스 다이어그램

    OrderAcceptedEvent를 공개 계약으로 수신

    DDD가 필요한 상황

    여러 기능이 같은 테이블과 저장소를 직접 변경하기 시작하면 상태 변경 규칙이 분산되고 변경 영향 범위를 예측하기 어려워진다. 주문과 배송처럼 업무 용어, 상태 전이, 변경 주기가 다른 영역은 책임 경계와 공개 계약을 분명히 해야 한다.

    orders

    • AcceptOrderCommand 해석
    • orderKey 중복을 고유 인덱스로 차단
    • ACCEPTED 상태로 신규 저장
    • OrderAcceptedEvent 발행
    공개 계약orders :: events

    shipping

    • ShippingReservationHandler가 이벤트 수신
    • 배송 예약 규칙 적용
    • ExposedShippingReservationRepository로 저장
    • 주문 내부 저장소는 참조하지 않음

    DDD 적용 효과와 추가 비용을 함께 본다

    경계를 만들면 변경 영향과 테스트 범위를 줄일 수 있지만, 이벤트 계약·매핑·실패 처리 설계가 추가된다. Modulith가 검증하는 범위와 운영 메시징에서 보장해야 할 범위를 구분한다.

    상태 변경 책임이 한곳에 모인다

    주문 규칙은 orders, 배송 예약 규칙은 shipping이 관리한다. 다른 모듈은 내부 저장소를 직접 호출하지 않는다.

    참조 규칙을 자동 검증한다

    ApplicationModules.verify()는 패키지와 Named Interface를 기준으로 컴파일 시점 참조 위반을 찾는다.

    이벤트 계약 관리 비용이 생긴다

    모듈 사이에 전달할 최소 정보를 이벤트로 정의하고, 내부 모델과 이벤트 모델의 매핑 및 변경 호환성을 관리해야 한다.

    내구성은 별도 설계가 필요하다

    이 예제의 @EventListener는 같은 프로세스에서 동작한다. Outbox, 브로커 저장, 중복 전달, 멱등성, 재처리를 구현하거나 보장하지 않는다.

    정상 흐름과 경계 위반을 같은 테스트 실행에서 확인한다

    테스트는 주문 수락 후 배송 예약이 저장되는 정상 경로와, orders.internal.LeakyOrderRepository를 직접 참조하는 경계 위반 픽스처를 함께 검증한다. 검증기는 업무 규칙이나 런타임 이벤트 전달 성공을 검사하지 않는다.

    실행 명령과 처리 순서
    ./gradlew :08-ddd-modulith-boundaries:test
    
    AcceptOrderCommand
      → orders transaction
      → ExposedOrderRepository
      → ddd_modulith_orders
      → OrderAcceptedEvent
      → ShippingReservationHandler
      → shipping transaction
      → ddd_modulith_shipping_reservations