본문으로 건너뛰기
ElastiCORE 3.0 은 릴리스 후보(RC)입니다: Maven Central 의 3.0.261022-rc1. 2.2 라인 프로젝트는 2.2 문서를 보세요.
버전: 3.0

파생 값, 이벤트, 도메인 서비스

architecture = 'ddd' 나 'hexagonal' 이 필요합니다.

@snapshot 과 @compute​

OrderLine:
fields:
item: Item @ManyToOne
quantity: quantity! @min(1)
unitCost: money(10.2) @snapshot(item.unitCost) # 주문 당시 가격을 한 번 복사
subTotal: money(12.2) @compute(unitCost * quantity)
PurchaseOrder:
fields:
totalWithoutVat: money(12.2) @compute(sum(lines.subTotal))
  • @snapshot(item.unitCost) 은 참조한 행의 값을 비어 있을 때 한 번 복사합니다 - 나중에 가격이 바뀌어도 주문은 그대로입니다. 참조한 행이 없으면 404(Item 99 does not exist, 코드 reference.OrderLine.item).
  • @compute(...) 는 저장할 때마다 계산합니다: a * b, a + b, a - b(필드나 숫자), sum(멤버목록.필드). 피연산자 하나가 null 이면 결과도 null.
  • 둘 다 요청으로 쓸 수 없습니다(DTO 에서 읽기 전용, 매퍼가 쓰지 않음 - @editable 엔티티에서도).
  • 엔티티에 derive(lookup) 이 생기고, 코어 서비스가 생성·수정·일괄 수정·조건 수정 저장 전과 명령 뒤에 부릅니다. 멤버가 먼저 계산되므로 합계는 최신 줄을 더합니다. 다른 @compute 를 쓰는 @compute 는 그 필드를 먼저 선언합니다.
  • @formula 와 다릅니다: @formula("...") 는 DB 가 계산하는 SQL 입니다.

Pet Store 의 주문 줄이 받은 것:

OrderLineBase.java (생성)
public void derive(io.elasticore.springboot3.domain.Lookup lookup) {
if (this.unitCost == null && this.itemId != null) {
Item source = (Item) lookup.find(Item.class, this.itemId);
if (source == null)
throw new io.elasticore.springboot3.validation.InvariantViolationException("reference.OrderLine.item", "Item " + this.itemId + " does not exist", String.valueOf(this.itemId));
this.unitCost = source.getUnitCost();
}
this.subTotal = (this.unitCost == null || this.quantity == null) ? null : this.unitCost.times(this.quantity);
}

@event - 도메인 이벤트​

PurchaseOrder:
meta: entity @root @expose(20) @event(Placed(total), Shipped, Cancelled)
생성되는 것내용
PurchaseOrderPlacedEvent, ...ShippedEvent, ...CancelledEvent루트의 식별자 + 선언한 필드 + occurredOn 을 담은 불변 값 (Java: equals/hashCode/toString, Kotlin: data class)
raisePlaced(), raiseShipped(), ...루트의 발생 메서드
@DomainEvents / @AfterDomainEventPublication저장할 때 Spring Data 가 발행
  • 저장할 때 발행됩니다. 저장이 실패하면 발행되지 않고, 발행된 뒤에는 지워져 다음 저장에서 다시 발행되지 않습니다.
  • 괄호 안의 내용(Placed(total))은 raisePlaced() 를 부른 시점의 값입니다: 컬럼, enum, 값 객체. 다른 애그리거트 참조와 컬렉션은 경고와 함께 빠집니다 - 식별자 컬럼으로 싣습니다.
  • 언제 발생하는지는 생성하지 않습니다 - states: 의 이동만 예외로, 도착 상태와 같은 이름의 이벤트를 냅니다 (SHIPPED 는 Shipped). 그 밖에는 내 코드가 그 일이라고 판단하는 곳에서 raiseCancelled() 를 부릅니다.
  • 이벤트 클래스는 namespace.event(없으면 엔티티 패키지)에 놓입니다. 받는 쪽은 내 @EventListener 입니다. 에이전트 키트의 상점 레시피는 @event(Shipped(total), Cancelled(total)) 로 선언하므로 이벤트가 total 을 싣습니다:
OrderEvents.java (내 것, 에이전트 키트의 상점 레시피)
@Component
class OrderEvents {
private static final Logger log = LoggerFactory.getLogger(OrderEvents.class);

@EventListener
void shipped(PurchaseOrderShippedEvent e) {
log.info("order {} SHIPPED total={}", e.getOrderId(), e.getTotal());
}
}

Outbox: JVM 밖으로 이벤트 보내기​

Spring Data 의 이벤트는 같은 JVM 안에만 전달됩니다. env.yml 에 outbox: true 를 두면 모든 이벤트가 같은 트랜잭션에서 outbox 테이블에 기록되고, 커밋 후 따로 보내집니다:

파일 (인프라 패키지)역할
<Domain>OutboxMessage엔티티 (<prefix>outbox 테이블)
<Domain>OutboxRepository미발행 100건을 순서대로 잠가서 읽음
<Domain>OutboxRecorder모든 이벤트를 BEFORE_COMMIT 에 JSON 으로 기록
<Domain>OutboxRelayrelay(): 내 OutboxPublisher 로 보냄. 실패하면 그 애그리거트의 뒤 메시지는 기다림(순서 보장). elasticore.outbox.max-attempts(10). @EnableScheduling 이면 elasticore.outbox.relay-delay-ms(5000)마다
@Bean
OutboxPublisher kafka(KafkaTemplate<String, String> kafka) {
return m -> kafka.send("erp-events", m.getAggregateId(), m.getPayload()).get();
}

전달은 최소 한 번입니다 - 소비자는 OutboxEnvelope.getId() 로 중복을 거릅니다.

domainService: - 어느 애그리거트에도 속하지 않는 연산​

model/domainservice.yml
domainService:
Pricing:
desc: What an order costs once the products it names are priced
uses: [Order, Product]

두 파일이 생깁니다: PricingDomainServiceBase(재생성 - uses 의 리포지터리 포트를 주입받는 필드)와 PricingDomainService(한 번만 생성, @Service, 연산은 내가 씀). uses 에 애그리거트를 더하면 Base 에 포트가 생기고 내 클래스는 고치지 않습니다. uses 가 비었거나 하나뿐이면 경고입니다 - 애그리거트 하나에 대한 연산은 보통 그 애그리거트의 메서드입니다. namespace.domainService 는 namespace.service 와 다른 곳에 둡니다.

processManager: - 이벤트 다음의 단계​

processManager:
Billing:
desc: 출고된 수주마다 청구서를 한 장 만든다
on: [SalesOrderShipped]
uses: [Invoice]
생성 (일부)
// BillingProcessManagerBase - 재생성
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void on(SalesOrderShippedEvent event) {
handle(event);
}
protected abstract void handle(SalesOrderShippedEvent event);

// BillingProcessManager - 한 번만 생성, 내 것
@Component
public class BillingProcessManager extends BillingProcessManagerBase {
@Override
protected void handle(SalesOrderShippedEvent event) {
// The next step goes here.
}
}

이벤트를 낸 트랜잭션이 커밋된 뒤, 자기 트랜잭션에서 실행됩니다. 여기서 실패해도 이미 일어난 일은 되돌려지지 않으므로 재시도·보상은 handle 이 정합니다. 멱등하게 쓰세요. on 에 이벤트를 더하면 추상 handle 이 생겨, 컴파일러가 빠진 것을 알려 줍니다.

바운디드 컨텍스트를 넘는 이벤트​

다른 컨텍스트의 이벤트는 컨텍스트:루트이벤트 로 적습니다:

blueprint/billing/model/domainservice.yml
processManager:
Receipting:
desc: 주문이 들어오면 영수증을 한 장 만든다
on: [shop:OrderPlaced]
uses: [Receipt]

경계는 타입처럼 검사합니다(env.yml - 바운디드 컨텍스트): 받는 쪽은 uses 에 shop 을, publishes 를 선언한 shop 은 거기 OrderPlaced 를 적습니다. 전달은 Spring 애플리케이션 이벤트라 같은 애플리케이션 안(모듈러 모놀리스)에서 이루어집니다. 따로 배포하는 컨텍스트는 보내는 쪽이 outbox 로 브로커에 보내고, 받는 쪽은 그 메시지를 직접 구독합니다 - 이 부분은 생성하지 않습니다. 예: elasticore-template-ddd 의 billing (CrossContextEventTest: shop 에서 커밋한 주문은 billing 이 받고, 롤백한 주문은 받지 않음).