OrderService
, OrderDao
Order
OrderService
, OrderRepository
DeliveryInformation
, Order
트랜잭션 스크립트 패턴을 적용하면 OrderService
클래스는 각 요청 및 시스템 작업마다 하나의 메서드를 갖게 두지만, 도메인 모델 패턴을 적용하면 서비스 메서드가 단순해진다
@Entity
를 붙여 DDD 엔티티를 나타냄Consumer, Order, Restaurant, Courier
등 비즈니스 객체에 대응되는 클래스가 존재하지만, 비즈니스 객체들의 경계가 불분명하다. Order
라는 비즈니스 객체의 일부인지 분명하지 않음.Order
라는 비즈니스 객체에 어떤 작업을 수행한다고 가정하자. 정확히 무슨 작업을 하는 것이고, 그 범위는 어디까지일까? Order
객체를 조회하거느 어떤 변경을 일으키는 일이겠지만, 실제로 이 객체뿐만 아니라 주문 품목, 지불 정보 등 다른 연관된 데이터 베이스도 많다. 즉 5-4 그림만 보아서는 개발자가 도메인 객체의 경계를 확실하게 파악할 수 없다.Order
애그리거트를 Consumer
애그리거트의 일부로 설계하는 방법도 있음.Consumer
애그리거트를 크게 잡으면 Consumer
및 하나 이상의 Order
를 원자적으로 업데이트 할 수 있으나 확장성이 떨어진다.Order
애그리거트 OrderService, OrderRepository
, 하나 이상의 사가 OrderService
는 OrderRepository
를 이용하여 Order
를 조회/저장한다.Order
애그리거트를 직접 업데이트하고, 여러 서비스에 걸친 업데이트 요청은 사가를 생성해서 처리한다.Order
애그리거트라면 주문 생성됨, 주문 취소됨, 주문 배달됨 등 상태가 바뀌는 이벤트가 발생함.애플리케이션 DB에서의 애그리거트 상태 전이가 이 모든 상황에서 알림을 트리거하는 장본인
OrderCreate
이벤트 클래스에는 orderId
프로퍼티가 존재함 DomainEvent
: 자신을 구현한 클래스가 도메인 이벤트임을 알리는 마커 인터페이스OrderDomainEvent
는 Order
애그리거트가 발행한 OrderCreatedEvent
의 마커 인터페이스DomainEventEnvelope
: 이벤트 객체 및 메타데이터를 조회하는 메서드가 존재. 이 인터페이스는 DomainEvent
를 상속한 매개변수화 객체를 받음// 예제 5-1 OrderCreatedEvent 클래스와 DomainEventEnvelope 인터페이스
interface DomainEvent();
interface OrderDomainEvent extends DomainEvent();
class OrderCreatedEvent implements OrderDomainEvent();
interface DomainEventEnvelope <T extends DomainEvent> {
String getAggregatedId();
Message getMessage();
String getAggregateType();
String getEventId();
T getEVent();
}
OrderCreatedEvent
클래스에 고스란히 담겨 있지만, 이벤트 컨슈머가 이 이벤트를 받아 처리하려면 주문 내역이 필요함.OrderService
에서 직접 가져와도 되지만, 이벤트 컨슈머가 서비스를 쿼리해서 애그리거트를 조회하는 것은 오버헤드를 유발함.class OrderCreatedEvent implements OrderEvent{
private List<OrderLineItem> lineItems;
private DeliveryInformation deliveryInformation; // 컨슈머가 필요로 하는 데이터
private PaymentInformation paymentInformation;
private long restaurantId;
private String restaurantName;
}
OrderCreatedEvent
있기 때문에 주문 이력 서비스 같은 이벤트 컨슈머는 따로 데이터를 조회할 필요가 없음. (7장에 더 자세한 설명)
SK C&C의 이벤트 스토밍 예시
// 예제 5-3 Ticket 애그리거트의 accept() 메서드
public class Ticket {
public List<TicketDomainEvent> accept(LocalDateTime readyBy) {
...
this.acceptTime = LocalDateTime.now(); // Ticket 업데이트
this.readyBy = readyBy;
return singletonList(new TicketAcceptedEvent(readyBy)); // 이벤트 반환
}
}
// 예제 5-4 KitchenServive는 Ticket.accept()를 호출한다.
@Transactional
public class KitchenService {
@Autowired
private TicketRepository ticketRepository;
@Autowired
private TicketDomainEventPublisher domainEventPublisher;
public void accept(long ticketId, LocalDateTime readyBy) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new TicketNotFoundException(ticketId));
List<TicketDomainEvent> events = ticket.accept(readyBy); // ticket.accept 호출
domainEventPublisher.publish(ticket, events); // 도메인 이벤트 발행
}
}
accept()
는 DB에서 TicketRepository
로 Ticket을 가져온 후, 다시 Ticket.accpet()
로 Ticket을 업데이트 한다.TicketDomainEventPublisher.publish()
를 호출하여 Ticket이 반환한 이벤트를 발행한다.// 예제 5-5 Ticket은 도메인 이벤트를 기록하는 상위 클래스 AbstractAggregateRoot를 상속한다.
public class Ticket extemds AbstractAggregateRoot {
public void accept(LocalDateTime readyBy) {
...
this.acceptTime = LocalDateTime.now(); // Ticket 업데이트
this.readyBy = readyBy;
registerEvent(new TicketAcceptedEvent(readBy));
}
}
registerEvent()
: 상위 클래스 AbstractAggregateRoot
에 정의된 메서드.AbstractAggregateRoot.domainEvents()
를 호출해서 이벤트를 가져온다.// 예제 5-6 이벤추에이트 트램 프레임워크의 DomainEventPublisher 인터페이스
public interface DomainEventPublisher{
void publish(String aggregateType, Object aggregateId, List<DomainEvent> domainEvents);
}
publish()
는 이 프레임워크에 탑재된 MessageProducer
인터페이스를 통해 트랜잭션을 걸어 이벤트를 발행한다.DomainEventPublisher
이벤트 발행기를 서비스가 직접 호출할 수도 있지만, 그러면 서비스가 유효한 이벤트만 발행하리라는 보장이 없음. KithchenService
는 Ticket
애그리거트의 이벤트 마커 인터페이스를 구현한 TicketDomainEvent
를 구현한 이벤트AbstractAggregateDomainEventPublisher
의 하위 클래스를 구현하는 것// 예제 5-7 타입-안전한 도메인 이벤트 발행기의 추상 상위 클래스
public abstract class AbstractAggregateDomainEventPublisher<A, E extends DomainEvent> {
private Function<A, Object> idSupplier;
private DomainEventPublisher eventPublisher;
private Class<A> aggregateType;
protected AbstractAggregateDomainEventPublisher(DomainEventPublisher eventPublisher,
Class<A> aggregateType,
Function<A, Object> idSupplier) {
this.eventPublisher = eventPublisher;
this.aggregateType = aggregateType;
this.idSupplier = idSupplier;
}
public Class<A> getAggregateType() {
return aggregateType;
}
public void publish(A aggregate, List<E> events) {
eventPublisher.publish(aggregateType, idSupplier.apply(aggregate), (List<DomainEvent>) events);
}
}
publisher()
는 애그리거트 ID를 조회 후 DomainEventPublisher.publish()
를 호출// 예제 5-8 Ticket 애그리거트의 도메인 이벤트를 발행하는 타입-안전한 인터페이스
package net.chrisrichardson.ftgo.kitchenservice.domain;
import io.eventuate.tram.events.aggregates.AbstractAggregateDomainEventPublisher;
import io.eventuate.tram.events.publisher.DomainEventPublisher;
public class TicketDomainEventPublisher extends AbstractAggregateDomainEventPublisher<Ticket, TicketDomainEvent> {
public TicketDomainEventPublisher(DomainEventPublisher eventPublisher) {
super(eventPublisher, Ticket.class, Ticket::getId);
}
}
TicketDomainEvent
의 하위 클래스에 해당하는 이벤트만 발행한다.예제 5-9 이벤트를 핸들러 메서드로 디스패치한다.
public class KitchenServiceEventConsumer {
@Autowired
private KitchenService kitchenService;
public DomainEventHandlers domainEventHandlers() { // 이벤트와 이벤트 핸들러를 매핑
return DomainEventHandlersBuilder
.forAggregateType("net.chrisrichardson.ftgo.restaurantservice.domain.Restaurant")
.onEvent(RestaurantCreated.class, this::createMenu)
.onEvent(RestaurantMenuRevised.class, this::reviseMenu)
.build();
}
public void reviseMenu(DomainEventEnvelope<RestaurantMenuRevised> de) { // RestaurantMenuRevised 이벤트 핸들러
long id = Long.parseLong(de.getAggregateId());
RestaurantMenu revisedMenu = de.getEvent().getRevisedMenu();
kitchenService.reviseMenu(id, revisedMenu);
}
}
reviseMenu()
: RestaurantMenuRevised
이벤트를 처리함. kitchenService.reviseMenu()
를 호출하여 음식점 메뉴를 업데이트 한 후, 이벤트 핸들러가 발행한 도메인 이벤트 목록을 반환한다.주방 서비스에는 3개의 인바운드 어댑터가 존재한다.
KitchenService
를 호출하여 Ticket
을 생성/수정KitchenService
를 호출하여 Ticket`을 생성/수정RestaurantService
가 발행한 이벤트를 구독. KitchenService
를 호출하여 Restaurant
을 생성/수정아웃바운드 어댑터는 2개가 존자핸다
TicketRepository
, RestaurantRepository
인터페이스를 구현하여 DB에 접근DomainEventPublishingAdapter
: DomainEventPublisher
인터페이스를 구현하여 Ticket
도메인 이벤트를 발행// 예제 5-10, 11 Ticket 엔티티와 도메인 메서드
@Entity
@Table(name = "tickets")
@Access(AccessType.FIELD)
public class Ticket {
@Id
private Long id;
@Enumerated(EnumType.STRING)
private TicketState state;
private TicketState previousState;
private Long restaurantId;
@ElementCollection
@CollectionTable(name = "ticket_line_items")
private List<TicketLineItem> lineItems;
private LocalDateTime readyBy;
private LocalDateTime acceptTime;
private LocalDateTime preparingTime;
private LocalDateTime pickedUpTime;
private LocalDateTime readyForPickupTime;
public static ResultWithDomainEvents<Ticket, TicketDomainEvent> create(long restaurantId, Long id, TicketDetails details) {
return new ResultWithDomainEvents<>(new Ticket(restaurantId, id, details));
}
private Ticket() {
}
public Ticket(long restaurantId, Long id, TicketDetails details) {
this.restaurantId = restaurantId;
this.id = id;
this.state = TicketState.CREATE_PENDING;
this.lineItems = details.getLineItems();
}
public List<TicketDomainEvent> accept(LocalDateTime readyBy) {
switch (state) {
case AWAITING_ACCEPTANCE:
// Verify that readyBy is in the futurestate = TicketState.ACCEPTED;
this.acceptTime = LocalDateTime.now();
if (!acceptTime.isBefore(readyBy))
throw new IllegalArgumentException("readyBy is not in the future");
this.readyBy = readyBy;
return singletonList(new TicketAcceptedEvent(readyBy));
default:
throw new UnsupportedStateTransitionException(state);
}
}
// TODO cancel()
public List<TicketDomainEvent> preparing() {
switch (state) {
case ACCEPTED:
this.state = TicketState.PREPARING;
this.preparingTime = LocalDateTime.now();
return singletonList(new TicketPreparationStartedEvent());
default:
throw new UnsupportedStateTransitionException(state);
}
}
public List<TicketDomainEvent> pickedUp() {
switch (state) {
case READY_FOR_PICKUP:
this.state = TicketState.PICKED_UP;
this.pickedUpTime = LocalDateTime.now();
return singletonList(new TicketPickedUpEvent());
default:
throw new UnsupportedStateTransitionException(state);
}
}
public List<TicketDomainEvent> cancel() {
switch (state) {
case AWAITING_ACCEPTANCE:
case ACCEPTED:
this.previousState = state;
this.state = TicketState.CANCEL_PENDING;
return emptyList();
default:
throw new UnsupportedStateTransitionException(state);
}
}
}
TICKET
테이블에 매핑. 중요한거는 Restaurant 객체를 객체 잠조가 아닌 PK 참조를 하고 있다.accept()
: 음식점이 주문을 접수함preparing()
: 음식점이 주문을 준비하기 시작함. 주문은 더 이상 변경/취소가 불가능readyForPickUP()
: 주문 픽업 준비가 끝남.create()
는 Ticket을 생성하고 preparing()
은 음식점에서 주문을 준비하기 시작할 때 호출됨. preparing()
은 주문 상태를 PREPARING
으로 변경하고, 그 시간을 기록한 후 이벤트를 발행한다.
cancel()
은 사용자가 주문을 취소할 때 호출된다. 취소가 가능한 상태면 주문 상태 변경 후 이벤트 반환, 불가능한 경우는 예외를 던진다.
// 예제 5-12 KitchenService 도메인 서비스
@Transactional
public class KitchenService {
@Autowired
private TicketRepository ticketRepository;
@Autowired
private TicketDomainEventPublisher domainEventPublisher;
@Autowired
private RestaurantRepository restaurantRepository;
public void createMenu(long id, RestaurantMenu menu) {
public void accept(long ticketId, LocalDateTime readyBy) {
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new TicketNotFoundException(ticketId));
List<TicketDomainEvent> events = ticket.accept(readyBy);
domainEventPublisher.publish(ticket, events);
}
// ...
}
accept()
는 음식점에서 새 주문을 접수할 때 다음 두 매개변수를 전달받아 호출된다. Ticket
애그리거트를 가져와 accept()
를 호출한다. 그리고 생성된 이벤트를 무조건 발행한다.// 예제 5-13 사가가 전송한 커맨드 메시지를 처리한다.
public class KitchenServiceCommandHandler {
@Autowired
private KitchenService kitchenService;
public CommandHandlers commandHandlers() { // 커맨드 메시지를 메시지 핸들러에 매핑
return SagaCommandHandlersBuilder
.fromChannel(KitchenServiceChannels.kitchenServiceChannel)
.onMessage(CreateTicket.class, this::createTicket)
.onMessage(ConfirmCreateTicket.class, this::confirmCreateTicket)
.onMessage(CancelCreateTicket.class, this::cancelCreateTicket)
.build();
}
private Message createTicket(CommandMessage<CreateTicket> cm) {
CreateTicket command = cm.getCommand();
long restaurantId = command.getRestaurantId();
Long ticketId = command.getOrderId();
TicketDetails ticketDetails = command.getTicketDetails();
try {
Ticket ticket = kitchenService.createTicket(restaurantId, ticketId, ticketDetails); // KitchenService를 호출하여 Ticket 생성
CreateTicketReply reply = new CreateTicketReply(ticket.getId());
return withLock(Ticket.class, ticket.getId()).withSuccess(reply); // 성공 응답 반환
} catch (RestaurantDetailsVerificationException e) {
return withFailure(); // 실패 응답 반환
}
}
private Message confirmCreateTicket // 주문 확정
(CommandMessage<ConfirmCreateTicket> cm) {
Long ticketId = cm.getCommand().getTicketId();
kitchenService.confirmCreateTicket(ticketId);
return withSuccess();
}
}
주문 서비스에는 4개의 인바운드 어댑터가 존재한다.
OrderService
를 이용하여 Order
생성/수정OrderService
를 호출하여 Restarant
레플리카를 생성/수정OrderService
를 호출하여 Order
를 수정한다.아웃바운드 어댑터는 3개가 존자핸다
OrderRepository
인터페이스를 구현하여 주문 서비스 DB에 접근한다.DomainEventPublisher
인터페이스를 구현하여 Order
도메인 이벤트를 발행한다.CommamdPublisher
인터페이스를 구현한 클래ㅡㅅ. 커맨드 메시지를 사가 참여자들에게 보낸다.// 예제 5-14 Order 클래스와 필드
@Entity
@Table(name = "orders")
@Access(AccessType.FIELD)
public class Order {
@Id
@GeneratedValue
private Long id;
@Version
private Long version;
@Enumerated(EnumType.STRING)
private OrderState state;
private Long consumerId;
private Long restaurantId;
@Embedded
private OrderLineItems orderLineItems;
@Embedded
private DeliveryInformation deliveryInformation;
@Embedded
private PaymentInformation paymentInformation;
@Embedded
private Money orderMinimum = new Money(Integer.MAX_VALUE);
}
OrderService
또는 사가 첫 번째 단계, 둘 중 하나는 Order 메서드를 호출해서 수행 가능한 작업인지 확인한 후 해당 주문을 APPROVAL_PENDING
상태로 변경. 중간에 pending 상태를 두면서 시맨틱 락을 적용함.// 예제 5-15 주문 생성 도중 호출되는 메서드들
@Entity
@Table(name = "orders")
@Access(AccessType.FIELD)
public class Order {
public static ResultWithDomainEvents<Order, OrderDomainEvent>
createOrder(long consumerId, Restaurant restaurant, List<OrderLineItem> orderLineItems) {
Order order = new Order(consumerId, restaurant.getId(), orderLineItems);
List<OrderDomainEvent> events = singletonList(new OrderCreatedEvent(
new OrderDetails(consumerId, restaurant.getId(), orderLineItems,
order.getOrderTotal()),
restaurant.getName()));
return new ResultWithDomainEvents<>(order, events);
}
public Order(long consumerId, long restaurantId, List<OrderLineItem> orderLineItems) {
this.consumerId = consumerId;
this.restaurantId = restaurantId;
this.orderLineItems = new OrderLineItems(orderLineItems);
this.state = APPROVAL_PENDING;
}
public List<OrderDomainEvent> noteApproved() {
switch (state) {
case APPROVAL_PENDING:
this.state = APPROVED;
return singletonList(new OrderAuthorized());
default:
throw new UnsupportedStateTransitionException(state);
}
}
public List<OrderDomainEvent> noteRejected() {
switch (state) {
case APPROVAL_PENDING:
this.state = REJECTED;
return singletonList(new OrderRejected());
default:
throw new UnsupportedStateTransitionException(state);
}
}
}
createOrder()
: 주문을 생성하고OrderCreatedEvent
를 발행하는 정적 팩토리 메서드OrderCreatedEvent
: 주문 품목, 총액, 음식점 ID, 음식점명 등 주문 내역이 포함된 이벤트Order
는 처음에 APPROVAL_PENDING
상태로 출발한다. CreateOrderSaga
완료 시 고객의 신용카드 승인까지 성공하면 noteApproved()
, 서비스 중 하나라도 주문을 거부하거나 신용카드 승인이 실패하면 noteRejected()
가 호출된다. 이렇듯 Order
애그리거트에 있는 메서드는 대부분 애그리거트 상태에 따라 동작이 결정됨.// 예제 5-16 Order 클래스의 주문 변경 메서드
@Entity
@Table(name = "orders")
@Access(AccessType.FIELD)
public class Order {
public ResultWithDomainEvents<LineItemQuantityChange, OrderDomainEvent> revise(OrderRevision orderRevision) {
switch (state) {
case APPROVED:
LineItemQuantityChange change = orderLineItems.lineItemQuantityChange(orderRevision);
if (change.newOrderTotal.isGreaterThanOrEqual(orderMinimum)) {
throw new OrderMinimumNotMetException();
}
this.state = REVISION_PENDING;
return new ResultWithDomainEvents<>(change, singletonList(new OrderRevisionProposed(orderRevision, change.currentOrderTotal, change.newOrderTotal)));
default:
throw new UnsupportedStateTransitionException(state);
}
}
public List<OrderDomainEvent> confirmRevision(OrderRevision orderRevision) {
switch (state) {
case REVISION_PENDING:
LineItemQuantityChange licd = orderLineItems.lineItemQuantityChange(orderRevision);
orderRevision.getDeliveryInformation().ifPresent(newDi -> this.deliveryInformation = newDi);
if (!orderRevision.getRevisedLineItemQuantities().isEmpty()) {
orderLineItems.updateLineItems(orderRevision);
}
this.state = APPROVED;
return singletonList(new OrderRevised(orderRevision, licd.currentOrderTotal, licd.newOrderTotal));
default:
throw new UnsupportedStateTransitionException(state);
}
}
}
revise()
: 변경된 주문량이 최소 주문량 이상인지 확인하고 문제가 없으면 주문 상태를 REVISION_PENDING
으로 바꾼다.confirmRevision()
을 호출하여 주문 변경을 마무리 한다.Order
애그리거트 생성/수정을 오케스트레이션하므로 복잡함.OrderRespository
, OrderDomainEventPublisher
, SagaManager
등 주입되는 의존성이 많음.package net.chrisrichardson.ftgo.orderservice.domain;
import io.eventuate.tram.events.aggregates.ResultWithDomainEvents;
import io.eventuate.tram.events.publisher.DomainEventPublisher;
import io.eventuate.tram.sagas.orchestration.SagaManager;
import io.micrometer.core.instrument.MeterRegistry;
import net.chrisrichardson.ftgo.orderservice.api.events.OrderDetails;
import net.chrisrichardson.ftgo.orderservice.api.events.OrderDomainEvent;
import net.chrisrichardson.ftgo.orderservice.api.events.OrderLineItem;
import net.chrisrichardson.ftgo.orderservice.sagas.cancelorder.CancelOrderSagaData;
import net.chrisrichardson.ftgo.orderservice.sagas.createorder.CreateOrderSagaState;
import net.chrisrichardson.ftgo.orderservice.sagas.reviseorder.ReviseOrderSagaData;
import net.chrisrichardson.ftgo.orderservice.web.MenuItemIdAndQuantity;
import net.chrisrichardson.ftgo.restaurantservice.events.MenuItem;
import net.chrisrichardson.ftgo.restaurantservice.events.RestaurantMenu;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import static java.util.stream.Collectors.toList;
@Transactional
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private SagaManager<CreateOrderSagaState> createOrderSagaManager;
@Autowired
private SagaManager<ReviseOrderSagaData> reviseOrderSagaManager;
@Autowired
private OrderDomainEventPublisher orderAggregateEventPublisher;
public Order createOrder(long consumerId, long restaurantId,
List<MenuItemIdAndQuantity> lineItems) {
Restaurant restaurant = restaurantRepository.findById(restaurantId)
.orElseThrow(() -> new RestaurantNotFoundException(restaurantId));
List<OrderLineItem> orderLineItems = makeOrderLineItems(lineItems, restaurant); // Order 애그리거트 (주문 상품) 생성
ResultWithDomainEvents<Order, OrderDomainEvent> orderAndEvents =
Order.createOrder(consumerId, restaurant, orderLineItems); // 주문 생성
Order order = orderAndEvents.result;
orderRepository.save(order); // Order를 DB에 저장
orderAggregateEventPublisher.publish(order, orderAndEvents.events); // 도메인 이벤트 발행
OrderDetails orderDetails = new OrderDetails(consumerId, restaurantId, orderLineItems, order.getOrderTotal());
CreateOrderSagaState data = new CreateOrderSagaState(order.getId(), orderDetails);
createOrderSagaManager.create(data, Order.class, order.getId()); // CreateOrderSaga 생성
meterRegistry.ifPresent(mr -> mr.counter("placed_orders").increment());
return order;
}
public Order reviseOrder(long orderId, OrderRevision orderRevision) {
Order order = orderRepository.findById(orderId).orElseThrow(() -> new OrderNotFoundException(orderId)); // Order 조회
ReviseOrderSagaData sagaData = new ReviseOrderSagaData(order.getConsumerId(), orderId, null, orderRevision);
reviseOrderSagaManager.create(sagaData); // ReviseOrderSaga 생성
return order;
}
}
createOrder()
: 먼저 Order 애그리거트를 생성/저장한 후 애그리거트가 발생시킨 도메인 이벤트를 발행하고, 제일 마지막에 CreateOrderSaga
를 생성함.reviseOrder()
: Order
를 조회한 후 ReviseOrderSaga
생성KitchenService
는 사가에 참여할 뿐 사가를 시작하지는 않지만, OrderService
는 주문을 생성하고 수정할 때 사가에 전적으로 의지한다. 다른 서비스가 있는 데이터가 트랜잭션 관점에서 일관성이 보장되어야 하기 때문이다.OrderService
메서드는 대부분 직접 Order
를 업데이트 하지 않고 사가를 만든다.