파생 값, 이벤트, 도메인 서비스
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 의 주문 줄이 받은 것:
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을 싣습니다:
@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>OutboxRelay | relay(): 내 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: - 어느 애그리거트에도 속하지 않는 연산
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 이 생겨, 컴파일러가
빠진 것을 알려 줍니다.
바운디드 컨텍스트를 넘는 이벤트
다른 컨텍스트의 이벤트는 컨텍스트:루트이벤트 로 적습니다:
processManager:
Receipting:
desc: 주문이 들어오면 영수증을 한 장 만든다
on: [shop:OrderPlaced]
uses: [Receipt]
경계는 타입처럼 검사합니다(env.yml - 바운디드 컨텍스트): 받는 쪽은 uses 에
shop 을, publishes 를 선언한 shop 은 거기 OrderPlaced 를 적습니다. 전달은 Spring 애플리케이션 이벤트라 같은
애플리케이션 안(모듈러 모놀리스)에서 이루어집니다. 따로 배포하는 컨텍스트는 보내는 쪽이 outbox 로 브로커에 보내고, 받는 쪽은 그 메시지를 직접 구독합니다 - 이 부분은 생성하지 않습니다. 예:
elasticore-template-ddd 의 billing (CrossContextEventTest: shop 에서 커밋한 주문은 billing 이 받고, 롤백한 주문은
받지 않음).