

OrderService, OrderDaoOrder
OrderService, OrderRepositoryDeliveryInformation, 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를 업데이트 하지 않고 사가를 만든다.