주요 도메인 개념 간의 관계를 파악하기 어렵다는 것은 곧 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다. 상위 수준에서 모델이 어떻게 엮여있는지 알아야 전체 모델을 망가뜨리지 않으면서 추가 요구사항을 모델에 반영할 수 있는데 세부적인 모델만 이해한 상태로는 코드를 수정하기가 두렵기 때문에 코드 변경을 최대한 회피하는 쪽으로 요구사항을 협의하게 된다.
totalAmounts를 갖고 있는 Order 엔티티quantity와 금액인 price를 갖고 있는 OrderLine 벨류public class Order {
// 애그리거트 루트는 도메인 규칙을 구현한 기능을 제공한다.
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newSippingInfo);
}
private void verifyNotYetShipped() {
if (state != OrderState.PAYMENT_WATING && state != OrderState.WAITING)
throw new IllegalStateException("already shipped");
}
...
Order의 changeShippingInfo()메서드가 이 규칙에 따라, 배송 시작 여부를 확인하고 변경이 가능한 경우에만 배송지 정보를 변경해야 한다.Order는 총 주문 금액을 구하기 위해 OrderLine 목록을 사용한다.public class Order {
private Money totalAmounts;
private List<OrderLine> orderLines;
private void calculateTotalAmounts() {
int sum = orderLines.stream()
.mapToInt(o1 -> o1.getPrice() * o1.quantity())
.sum();
this.totalAmounts = new Money(sum);
}
}
OrderLine 목록을 별도 클래스로 분리하는 경우, Order의 changeOrderLines()메서드는 orderLines 필드에 상태 변경을 위임하는 방식으로 기능을 구현한다.public class OrderLines {
private List<OrderLine> lines;
public Money getTotalAmounts(); { ...구현; }
public void changeOrderLines(List<OrderLine> newLines) {
this.line = newLines;
}
}
public class Order {
private OrderLines orderLines;
public void changeOrdrLines(List<OrderLines> newLines) {
orderLines.changeOrderLines(newLines);
this.totalAmounts = orderLines.getTotalAmounts();
}
}
OrderLines 목록이 변경되는데, 총 합은 계산하지 않는 버그를 만들게 된다.OrderLines lines = order.getOrderLines();
// 외부에서 애그리거트 내부 상태 변경!
// order의 totalAmounts가 값이 OrderLines가 일치하지 않게 됨
lines.changeOrderLines(newOrderLines);
public class Order {
private Orderer orderer;
public void shipTo(ShippingInfo newShippingInfo,
boolean useNewShippingAsMemberAddr) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
if (useNewShippingAddrAsMemberAddr) {
// 다른 애그리거트의 상태를 변경하면 안 됨!
orderer.getCustomer().changeAddress(newShippingInfo.getAddress());
}
}
}
Order와 OrderLine을 물리적으로 각각 별도의 DB에 저장하더라도, Order가 애그리거트 루트이므로 Order를 위한 리포지터리만 존재한다.save: 애그리거트 저장findById: ID로 애그리거트를 구함// 리포지터리에 애그리거트를 저장하려면 애그리거트 전체를 영속화해야 한다.
orderRepository.save(order);
// 리포지터리는 완전한 order를 제공해야 한다.
Order order = orderRepository.findById(orderId);
// order가 와전한 애그리거트가 아니면
// 기능 실행도중 NullPointException과 같은 문제가 발생한다.
order.cancel();
public class Order {
private Orderer orderer;
...
}
public class Orderer {
private Member member;
private String name;
}
public class Member {
...
}
order.getOrderer().getMember().getId();
public class Order {
private Orderer orderer;
public void changeShippingInfo(ShippingInfo newShippingInfo,
boolean useNewShippingAddrAsMemberAddr) {
...
if (useNewShippingAddrAsMemberAddr) {
// 한 애그리거트 내부에서 다른 애그리거트에 접근할 수 있으면,
// 구현이 쉬워진다는 것 때문에 다른 애그리거트의 상태를 변경하는
// 유횩에 빠지기 쉽다.
orderer.getCustomer().changeAddress(newShippingInfo.getAddress());
}
}
}
아... 그 당시에 알고있다는 사실로 그쳐야 하는구나.
중요한것은, 단순히 조회만 가능하고 수정이 불가능해야 하는구나.
상태 변경 자체를 애그리거트에 가두는게 가장 주요한 목적이구나.
그 부분을 가두어야지만, 복잡도 증가를 예방할 수 있게 되는거구나.
이런 경우, ID를 이용해 다른 애그리거트를 참조함으로써 문제를 해결할 수 있다.
public class Order {
private Orderer orderer;
...
}
public class Orderer {
private MemberId memberId; //**
private String name;
...
}
public class Member {
private MemberId id; // **
...
}
public class ChangeOrderService {
@Transactional
public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo,
boolean useNewShippingAddrAsMemberAddr) {
Order order = orderRepository.findById(id);
if (order == null) throw new OrderNotFoundException();
order.changeShipingInfo(newShippingInfo);
if (useNewShippingAddrAsMemberAddr) {
// ID를 이용해서 참조하는 애그리거트를 구한다.
Customer customer = customerRepository.findById(order.getOderer().getCustomerId());
customer.changeAddress(newShippingInfo.getAddress());
}
}
}
Customer customer = customerRepository.findById(ordererId);
List<Order> orders = orderRepository.findByOrderer(ordereId);
List<OrderView> dtos = orderers.stream()
.map(order -> {
PorductId prodId = order.getOrderLines().get(0).getProductId();
// 각 주문마다 첫 번째 주문 상품 정보 로딩 위한 쿼리 실행
Product product = productRepository.findById(prodId);
return new OrderView(order, customer, product);
}).collect(toList());
@Repository
public clss JpaOrderViewDao implements OrderViewDao {
@PersistenceContext
private EntityManager em;
@Override
public List<OrderView> selectByOrderer(String ordererId) {
String selectQueiry =
"select new com.myshop.order.application.dto.OrderView(o, m, p) " +
"from Order o join o.orderLines ol, Member m, Product p " +
"where o.orderer.memberId.id = :orderId " +
"and o.orderer.memberId = m.id " +
"and index(ol) = 0 " +
"and ol.productId = p.id " +
"order by o.number.number desc";
TypedQuery<OrderView> query =
em.createQuery(selectQuery, OrderView.class);
query.setParameter("ordererId", ordererId);
return query.getResultList();
}
}
카테고리 - 상품public class Category {
private Set<Product> products; // 다른 애그리거트에 대한 1:N 연관
...
}
Category에 속한 모든 Product를 조회하게 된다.public class Category {
private Set<Product> products;
public List<Product> getProducts(int page, int size) {
List<Product> sortedProducts = sortById(products);
return sortedProducts.subList((page - 1) * size, page * size);
}
...
Product에 Category로의 연관을 추가하고, 그 연관을 이용해 특정 Category에 속한 Product목록을 구할 수 있다.public class Product {
...
private CategoryId category;
...
}
public class ProductListService {
public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
Category category = categoryRepository.findById(categoryId);
checkCategory(category);
List<Product> products =
productRepository.findByCategoryId(category.getId(), page, size);
int totalCount = productRepository.countsByCategroyId(category.getId());
return new Page(page, size, totalCount, products);
}
...
public class Product {
private Set<CategoryId> categoryIds;
...
Category에 속한 Product 목록을 구하는 기능을 구현할 수 있다.@Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
@ElementCollection
@CollectionTable(name = "product_category",
joinColumns = @JoinColumn(name = "product_id"))
private Set<CategoryId> categoryIds;
...
@Repository
public class JpaProductRepository implements ProductRepository {
@PersistenceContext
private ENtityManager entityManager;
@Override
public List<Product> findByCategoryId(CategoryId categoryId, int page, int size) {
TypedQuery<Product> query = entityManager.createQuery(
"select p from Product p " +
"where :catId member of p.categoryIds order by p.id.id desc ",
Product.class);
query.setParaemntet("catId", categoryId);
query.setFirstResult((page - 1) * size);
query.setMaxResults(size);
retury query.getResultList();
}
...
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store account = accountRepository.findStoreById(req.getStoreId());
checkNull(account);
if (account.isBlocked()) {
throw new StroeBlockedException();
}
ProductId id = productRepository.nextId();
Product product = new Product(id, account.getId(), ... 생략);
productRepository.save(product);
return id;
}
...
}
Product를 생성할 수 있는지 판단하는 코드와 생성하는 코드가 분리되어 있다.Store가 Product를 생성하는 것은 논리적으로 하나의 도메인 기능인데, 이 도메인 기능을 응용 서비스에서 구현하고 있다.Store 애그리거트이다.public class Stroe extends Member {
public Product createProduct(ProductId newProductId, ...생략) {
if (isBlocked()) throw new StoreBlockedExcpetion();
return new Product(newProductId, getId(), ...생략);
}
}
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store account = accountRepository.findStoreById(req.getStoreId());
checkNull(account);
ProductId id = productRepository.nextId();
Product product = account.createProduct(id, ...생략);
productRepository.save(product);
return id;
}
}
Store 애그리거트의 createProduct()Product 애그리거트를 생성하는 팩토리 역할 수행Poduct를 생성한다.Store의 상태를 확인하지 않는다.Product 생성 가능 여부 확인 가능 로직 변경시, Store만 변경하면 된다.Product의 경우 Store의 식별자를 필요로 한다.Store에 Product를 생성하는 팩토리 메서드를 추가하면, Product를 생성할 때 필요한 데이터의 일부를 직접 제공하면서 중요한 도메인 로직을 함께 구현할 수 있게 된다.