최범균님의 '도메인 주도 개발 시작하기'를 읽고 정리한 글입니다.
카탈로그 도메인
- 상품: 상품 가격, 상세 내용을 담고있는 정보배송 도메인
- 상품: 고객에게 실제 배송되는 물리적인 상품아키텍처 구성
도메인 계층은 도메인의 핵심 규칙을 구현한다.
ex) 주문 도메인의 '출고 전 배송지 변경 가능'이라는 규칙과 '주문취소는 배송 전에만 가능'과 같은 규칙을 구현한 코드가 도메인 계층에 존재한다.
도메인 모델 패턴: 이런 도메인 규칙을 객체 지향 기법으로 구현하는 패턴
// 1. 배송지 변경 가능 여부 판단 기능 OrderState에 있는 경우
public class Order {
private OrderState state;
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
if (!state.isShippingChangeable()) {
throw new IllegalStateException("can't change shipping in " + state);
}
this.shippingInfo = newShippingInfo;
}
// ...
public enum OrderState {
PAYMENT_WAITING {
public boolean isShippingChangeable() {
return true;
}
},
PREPARING {
public boolean isShippingChangeable() {
return true;
}
},
SHIPPED, DELIVERING, DELIVERY_COMPLETED;
public boolean isShippingChangeable() {
return false;
}
}
}
// 2. 배송지 변경 가능 여부 판단 기능 Order에 있는 경우
public class Order {
private OrderState state;
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
if (!isShippingChangeable()) {
throw new IllegalStateException("can't change shipping in " + state);
}
this.shippingInfo = newShippingInfo;
}
private boolean isShippingChangeable() {
return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
}
// ...
public enum OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}
}
주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 것
- 출고 전 배송지 변경 가능
- PAYMENT_WAITING(주문 대기중) & PREPARING(상품 준비 중) 상태의 isShippingChangable()
메서드는 true를 반환함
주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현한다.
핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에, 규칙이 바뀌거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있게 된다.
개념 모델과 구현 모델
도메인을 모델링할 때 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.
이 과정은 요구사항에서 출발한다.
- 최소 한 종류 이상의 상품을 주문해야 한다
- 한 상품을 한 개 이상 주문할 수 있다
- 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다
- 주문할 때 배송지 정보를 반드시 지정해야 한다
- 배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다
- 출고를 하면 배송지를 변경할 수 없다
- 출고 전에 주문을 취소할 수 있다
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다
public class Order {
public void changeShipped() { ... }
public void changeShippingInfo(ShippingInfo newShipping) { ... }
public void cancel() { ... }
public void completePayment() { ... }
}
- 한 상품을 한 개 이상 주문할 수 있다.
- 각 상품의 구매 가격의 합은 상품 가격에 구매 개수를 곱한 값이다.
public class OrderLine {
private Product product;
private int price;
private int quantity;
private int amounts;
public OrderLine(Product product, int price, int quantity, int amounts) {
this.product = product;
this.price = price;
this.quantity = quantity;
this.amounts = calculateAmounts();
}
private int calculateAmounts() {
return price * quantity;
}
public int getAmounts() {
return amounts;
}
}
- 최소 한 종류 이상의 상품을 주문해야 한다.
- 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
public class Order {
private List<OrderLine> orderLines;
private Money totalAmounts;
public Order(List<OrderLine> orderLines) {
setOrderLines(orderLines);
}
private void setOrderLines(List<OrderLine> orderLines) {
verifyAtLeastOneOrMoreOrderLines(orderLines);
this.orderLines = orderLines;
calculateTotalAmounts();
}
// 최소 한 종류 이상의 상품을 주문해야 한다.
private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines) {
if (orderLines == null || orderLines.isEmpty()) {
throw new IllegalArgumentException("no OrderLine");
}
}
// 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
private void calculateTotalAmounts() {
int sum = orderLines.stream()
.mapToInt(x -> x.getAmounts())
.sum();
this.totalAmounts = new Money(sum);
}
}
public class ShippingInfo {
private String receiverName;
private String receiverPhoneNumber;
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipCode;
// ... 생성자, getter
}
public class Order {
private List<OrderLine> orderLines;
private Money totalAmounts;
private ShippingInfo shippingInfo;
public Order(List<OrderLine> orderLines, ShippingInfo shippingInfo) {
setOrderLines(orderLines);
setShippingInfo();
}
private void setShippingInfo(ShippingInfo shippingInfo) {
if (shippingInfo == null) {
throw new IllegalArgumentException("no ShippingInfo");
}
this.shippingInfo = shippingInfo;
}
// ...
}
- 출고를 하면 배송지 정보를 변경할 수 없다.
- 출고 전에 주문을 취소할 수 있다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
public enum OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED;
}
public class Order {
private OrderState state;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
}
private void verifyNotYetShipped() {
if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
throw new IllegalStateException("already shipped");
}
}
// ...
}
엔티티(Entity)
와 밸류(Value)
로 구분 가능함.엔티티의 가장 큰 특징은 식별자를 가진다는 것이다.
package one.six;
public class Order {
private String orderNumber;
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (obj.getClass() != Order.class) {
return false;
}
Order other = (Order) obj;
if (this.orderNumber == null) {
return false;
}
return this.orderNumber.equals(other.orderNumber);
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((orderNumber == null) ? 0 : orderNumber.hashCode());
return result;
}
}
식별자의 생성 방식
식별자 생성 예시
UUID uuid = UUID.randomUUID();
String strUuid = uuid.toString();
일련번호 방식은 주로 데이터베이스가 제공하는 자동 증가 기능 사용
자동 증가 칼럼을 제외한 다른 방식은 식별자를 먼저 만들고 엔티티 객체를 생성할 때 식별자를 전달함.
String orderNumber = orderRepository.generateOrderNumber();
Order order = new Order(orderNumber, ...);
orderRepository.save(order);
자동 증가 칼럼은 DB 테이블에 데이터 삽입한 뒤에야 식별자 알 수 있음
Article article = new Article(author, title, ...);
articleRepository.save(article);
Long savedArticleId = article.getId();
밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
public class Receiver {
private String name;
private String phoneNumber;
// 생성자, getter
}
public class Address {
private String address1;
private String address2;
private String zipcode;
// 생성자, getter
}
public class Money {
private int value;
// getter, 생성자
}
// Money타입의 돈 계산 기능 추가
// Money를 사용하는 코드는 '정수 타입 연산'이 아니라 '돈 계산'이라는 의미로 코드 작성
public class Money {
private int value;
// 생성자, getter
public Money add(Money money) {
return new Money(this.value + money.value);
}
public Money multiply(int multiplier) {
return new Money(value * multiplier);
}
}
밸류 객체 데이터를 변경할 때는 기존 데이터를 변경하기보다, 변경한 데이터를 갖는 새로운 밸류 객체를 생성하는 방식을 선호한다.
불변(Immutable) 객체: 데이터 변경 기능을 제공하지 않는 타입
setter로 메서드를 제공해 값을 변경하지 못하게 막아야함.
만약 Money 값을 변경할 수 있다면 문제를 방지하기 위해 다음과 같은 코드를 작성해야함.
public class OrderLine {
private Product product;
private Money price;
private int quantity;
private Money amounts;
public OrderLine(Product product, Money price, int quantity, Money amounts) {
this.product = product;
this.price = new Money(price.getValue());
this.quantity = quantity;
this.amounts = calculateAmounts();
}
// ...
}
Money 값을 변경할 수 없으면 이런 코드 없이 전달받은 price를 안전하게 사용 가능
두 밸류 객체를 비교할 때는 모든 속성이 같은지 비교해야함.
public class Receiver {
private String name;
private String phoneNumber;
// 생성자, getter
public boolean equals(Object other) {
if (other == null)
return false;
if (this == other)
return true;
if (!(other instanceof Receiver))
return false;
Receiver that = (Receiver) other;
return this.name.equals(that.name) && this.phoneNumber.equals(that.phoneNumber);
}
}
public class Order {
// OrderNo 타입 자체로 id가 주문번호임을 알 수 있음
private OrderNo id;
public OrderNo getId() {
return id;
}
}
도메인 모델에 get/set 메서드를 무조건 추가하는 것은 좋지 않은 버릇이다.
set 메서드의 문제점
Order order = new Order(orderer, lines, shippingInfo, OrderState.PREPARING);
생성자로 필요한 것을 모두 받으면, 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사 가능
public class Order {
public Order(Orderer orderer, List<OrderLine> orderLines, ShippingInfo shippingInfo,
OrderState orderState) {
setOrderer(orderer);
setOrderLines(orderLines);
// ... 다른값 설정
}
private void setOrderer(Orderer orderer) {
if (orderer == null) {
throw new IllegalArgumentException("no orderer");
}
this.orderer = orderer;
}
private void setOrderLines(List<OrderLine> orderLines) {
verifyAtLeastOneOrMoreOrderLines(orderLines);
this.orderLines = orderLines;
calculateTotalAmounts();
}
private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines) {
if (orderLines == null || orderLines.isEmpty()) {
throw new IllegalArgumentException("no OrderLine");
}
}
private void calculateTotalAmounts() {
int sum = orderLines.stream()
.mapToInt(x -> x.getAmounts())
.sum();
this.totalAmounts = new Money(sum);
}
}
위 코드의 set메서드는 접근 범위가 private임.
불변 밸류 타입을 사용하면 자연스럽게 밸류 타입에는 set 메서드를 구현하지 않음
DTO(Data Transfer Obejct): 프레젠테이션 계층과 도메인 계층이 데이터를 서로 주고받을 때 사용하는 일종의 구조체
DTO가 도메인 로직을 담고있지 않기 때문에 get/set 메서드를 제공해도 도메인 객체의 데이터 일관성에 영향을 줄 가능성이 높지 않다.
프레임워크가 필드에 직접 값을 할당하는 기능을 제공하고 있다면 set 메서드를 만드는 대신 해당 기능을 최대한 활용하자.
public enum OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED;
}