온라인 서점
: 소프트웨어로 해결해하고자하는 문제 영역, 도메인(domain)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 void changeShippped() {
// 로직 검사
this.state = OrderState.SHIPPED;
}
}
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;
}
}
배송지 정보 변경 가능 여부를 판단하는 코드를 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;
}
public boolean changeShipped() {
return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
}
}
enum OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}
개념 모델은 순수하게 문제를 분석한 결과물이다. 개념모델은 데이터베이스, 트랜잭션 처리, 성능, 구현 기술과 같은 것들을 고려하고 있지 않기 때문에 작성할 때 개념 모델을 있는 그대로 사용할 수 없다. 그래서 개념 모델을 구현 가능한 형태의 모델로 전환하는 과정을 거치게 된다.
개념 모델을 만들 때 처음부터 완벽하게 도메인을 표현하는 모델을 만드는 시도를 할 수 있지만 실제로 이는 불가능에 가깝다. 소프트웨어를 개발하는 동안 개발자와 관계자들은 해당 도메인을 더 잘 이해하게 된다. 프로젝트 초기에 완벽한 도메인을 만들더라도 결국 도메인에 대한 새로운 지식이 쌓이면서 모델을 보완하거나 수정하는 일이 발생한다.
따라서, 처음부터 완벽한 개념 모델을 만들기보다는 전반적인 개요를 알 수 있는 수준으로 개념 모델을 작성해야 한다. 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델로 점진적으로 발전시켜 나가야 한다.
요구사항으로 부터 주문이 '출고 상태로 변경하기' 배송지 정보 변경하기' '주문 취소하기' '결제 완료로 변경하기'의 4가지 기능을 제공함을 알 수 있다.
Order에 관련 기능을 메서드로 추가할 수 있다.
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 amount;
public OrderLine(Product product, int price, int quantity) {
this.product = product;
this.price = price;
this.quantity = quantity;
this.amount = calculateAmounts();
}
private int calculateAmounts() {
return price * quantity;
}
public int getAmounts() {...}
}
package com.example.dddstudy.domain;
import java.util.List;
public class Order {
private List<OrderLine> orderLines;
private int totalAmounts;
public Order(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() {
this.totalAmounts = new Money(orderLines.stream()
.mapToInt(x -> x.getAmounts().getValue().sum));
}
}
- 주문할 때 배송지 정보를 반드시 지정해야 한다
public class Order {
private List<OrderLine> orderLines;
private int totalAmounts;
private ShippingInfo shippingInfo;
public Order(List<OrderLine> orderLines, ShippingInfo shippingInfo) {
setOrderLines(orderLines);
setShippingInfo(shippingInfo);
}
private void setShippingInfo(ShippingInfo shippingInfo) {
if (shippingInfo == null) {
throw new IllegalArgumentException("no ShippingInfo");
this.shippingInfo = shippingInfo;
}
}
...
}
class ShippingInfo {
private String receiverName;
private String receiverPhoneNumber;
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipcode;
}
문서화를 하는 주된 이유는 지식을 공유하기 위함이다. 실제 구현은 코드에 있으므로 모든 것은 코드를 보면 알 수 있지만 코드는 상세한 모든 내용을 다루고 있기 때문에 코드를 이용해서 전체 소프트웨어를 분석하려면 많은 시간을 투자해야한다. 전반적인 기능 목록이나 모듈 구조, 빌드 과정은 코드를 보고 직접 이해하는 것보다 상위 수준에서 정리한 문서를 참조하는 것이 소프트웨어 전반을 빠르게 이해하는 데 도움이 된다. 전체 구조를 이해하고 더 깊게 이해할 필요가 있는 부분을 코드로 분석해 나가면 된다.
코드를 보면서 도메인을 깊게 이해하게 되므로 코드 자체도 문서화의 대상이 된다. 도메인 지식이 잘 묻어나도록 코드를 작성하지 않으면 코드의 동작 과정은 해석할 수 있어도 도메인 관점에서 왜 코드를 그렇게 작성했는지 이해하는 데는 도움이 되지 않는다. 단순히 코드를 보기 좋게 작성하는 것뿐만 아니라 도메인 관점에서 코드가 도메인을 잘 표현해야 비로소 코드의 가독성이 높아지며 문서로서 코드가 의미를 갖는다.
도출한 모델은 크게 엔티티(Entity)와 벨류(Value)로 분류할 수 있다.
Order
는 엔티티가 되며 주문 번호를 식별자로 갖게 된다.package com.example.dddstudy.domain;
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 order = (Order) obj;
if (this.orderNumber == null) return false;
return this.orderNumber.equals(order.orderNumber);
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((orderNumber == null) ? 0 : orderNumber.hashCode());
return result;
}
}
ShipppingInfo
에서 특정 필드들이 묶여 Receiver
와 Address
라는 벨류로 다뤄질 수 있다.public class ShippingInfo {
// 받는사람
private String receiverName;
private String receiverPhoneNumber;
// 주소
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipcode;
// ...
}
receiverName
, receiverPhoneNumber
는 개념적으로 받는 사람을 의미한다.shippingAddress1
, shippingAddress2
, shippingZipcode
필드도 주소라는 하나의 개념을 표현한다.class Receiver {
private String name;
private String phoneNumber;
public Receiver(String name, String phoneNumber) {
this.name = name;
this.phoneNumber = phoneNumber;
}
public String getName() {
return name;
}
public String getPhoneNumber() {
return phoneNumber;
}
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
if (! (obj instanceof Receiver)) return false;
Receiver order = (Receiver) obj;
return this.name.equals(that.name) &&
this.phoneNumber.equals(that.phoneNumber);
}
}
class Address {
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipcode;
public Address(String shippingAddress1, String shippingAddress2, String shippingZipcode) {
this.shippingAddress1 = shippingAddress1;
this.shippingAddress2 = shippingAddress2;
this.shippingZipcode = shippingZipcode;
}
// getter 메서드...
}
Money
처럼 데이터 변경 기능을 제공하지 않는 타입을 불변(immutable)이라고 표현한다.public class Money {
private int value;
public Money(int value) {
this.value = value;
}
public int getValue() {
return this.value;
}
public Money add(Money money) {
return new Money(this.value + money.value);
}
public Money multiply(int multiplier) {
return new Money(value * multiplier);
}
}
Money
를 사용한 OrderLine
은 다음과 같이 변경된다.Money
타입 덕에 price와 amounts가 금액을 의미한다는 것을 쉽게 알 수 있다.public class OrderLine {
private Product product;
private Money price;
private int quantity;
private Money amount;
}
get/set
메서드를 습관적으로 추가하는 것은 좋지 않은 버릇이다.changeShippingInfo()
메서드는 배송지 정보를 새로 변경한다는 의미를 가졌는데, setOrderState(OrderState state)
메서드는 단순히 배송지 값을 변경한다는 것을 뜻한다.Order order = new Order(orderer, lines, shippingIinfo, OrderState.PREPARING);
public class Order {
Orderer orderer;
List<OrderLine> orderLines;
ShippingInfo shippingInfo;
OrderState state;
public Order(Orderer orderer, List<OrderLine> orderLines,
ShippingInfo shippingInfo, OrderState state) {
this.orderer = orderer;
this.orderLines = orderLines;
this.shippingInfo = shippingInfo;
this.state = state;
}
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() {
this.totalAmounts = orderLines.stream().mapToInt(x -> x.getAmounts()).sum();
}
}
public OrderState {
STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}
public OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED;
}