주요 도메인 개념 간의 관계를 파악하기 어렵다는 것은 곧 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다. 상위 수준에서 모델이 어떻게 엮여있는지 알아야 전체 모델을 망가뜨리지 않으면서 추가 요구사항을 모델에 반영할 수 있는데 세부적인 모델만 이해한 상태로는 코드를 수정하기가 두렵기 때문에 코드 변경을 최대한 회피하는 쪽으로 요구사항을 협의하게 된다.
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
를 생성할 때 필요한 데이터의 일부를 직접 제공하면서 중요한 도메인 로직을 함께 구현할 수 있게 된다.