Validationとドメインモデル
https://enterprisecraftsmanship.com/posts/validation-and-ddd/
Khorikovさんのブログを元に…
例) 注文を配送状態にする処理である。 注文の配送処理時に、配送先住所と配達日時が渡され、注文が更新される。
注文によっては、制約として「日本国内配送のみ」「土日配送不可」のものがあり、この制約に抵触しないかをビジネスルールとチェックして、配送処理を行うものとする。
この実現手段を考える。
Solution1: OrderにisValidメソッドを作る
OrderクラスにisValidメソッドを用意する。
code: Order.java
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Order {
@Getter
OrderId orderId;
@Getter
OrderStatus status = OrderStatus.IN_PROGRESS;
@Getter
EnumSet<OrderConstraint> constraints;
@Setter
@Getter
Address deliverAddress;
@Setter
@Getter
DeliveryTime deliveryTime;
public static Order ofInProgress(OrderId orderId, EnumSet<OrderConstraint> constraint) {
Order order = new Order();
order.orderId = orderId;
order.status = OrderStatus.IN_PROGRESS;
order.constraints = constraint;
return order;
}
public ConstraintViolations validateForDelivery() {
ConstraintViolations violations = new ConstraintViolations();
if (constraints.contains(SHIPPING_JAPAN_ONLY) && !deliverAddress.getCountry().equals("JP")) {
violations.add(SHIPPING_JAPAN_ONLY.getConstraintViolation());
}
if (constraints.contains(DELIVERY_WEEKDAY) && deliveryTime.isHoliday()) {
violations.add(DELIVERY_WEEKDAY.getConstraintViolation());
}
return violations;
}
public void deliver() {
status = OrderStatus.DELIVERING;
}
}
配送のdeliverメソッドを呼ぶ前に、validateForDeliverを呼ぶことでドメインモデルのバリデーションを行う設計である。
配送に関する条件は、ドメインモデルのOrderの中にありドメイン知識の漏洩はしていないが、この設計の問題点は、validateForDeliverを呼ぶまで、OrderはValidではない状態で存在しうるところである。
Solution2: アプリケーションレイヤでValidな状態を担保する
code:Order.java
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Order {
@Getter
OrderId orderId;
@Getter
OrderStatus status = OrderStatus.IN_PROGRESS;
@Getter
EnumSet<OrderConstraint> constraints;
@Setter
@Getter
Address deliverAddress;
@Setter
@Getter
DeliveryTime deliveryTime;
public static Order ofInProgress(OrderId orderId, EnumSet<OrderConstraint> constraint) {
Order order = new Order();
order.orderId = orderId;
order.status = OrderStatus.IN_PROGRESS;
order.constraints = constraint;
return order;
}
public ConstraintViolations validateForDelivery() {
ConstraintViolations violations = new ConstraintViolations();
if (constraints.contains(SHIPPING_JAPAN_ONLY) && !deliverAddress.getCountry().equals("JP")) {
violations.add(SHIPPING_JAPAN_ONLY.getConstraintViolation());
}
if (constraints.contains(DELIVERY_WEEKDAY) && deliveryTime.isHoliday()) {
violations.add(DELIVERY_WEEKDAY.getConstraintViolation());
}
return violations;
}
public void deliver() {
status = OrderStatus.DELIVERING;
}
}
アプリケーションレイヤの配送を扱うユースケースにて、配送可能なOrderであるかを検証する。
code:DeliverOrderHandlerImpl.java
public class DeliverOrderHandlerImpl implements DeliverOrderHandler {
private final LoadOrderPort loadOrderPort;
private final SaveOrderPort saveOrderPort;
public DeliverOrderHandlerImpl(LoadOrderPort loadOrderPort, SaveOrderPort saveOrderPort) {
this.loadOrderPort = loadOrderPort;
this.saveOrderPort = saveOrderPort;
}
@Override
public Result<DeliveredOrderEvent> handle(DeliverOrderCommand command) {
OrderId orderId = new OrderId(command.getOrderId());
final Order order = loadOrderPort.load(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
ConstraintViolations violations = new ConstraintViolations();
Validated<Address> addressValidated = Address.validator().validate(command.getCountry(), command.getPostalCode(), command.getRegion(), command.getLocality(), command.getStreetAddress());
if (addressValidated.isValid()) {
order.setDeliverAddress(addressValidated.value());
} else {
violations.addAll(addressValidated.errors());
}
Validated<DeliveryTime> deliveryTimeValidated = DeliveryTime.validator()
.<DeliverOrderCommand>compose(c -> LocalDateTime.parse(c.getDeliveryTime(), DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm")))
.validate(command);
if (deliveryTimeValidated.isValid()) {
order.setDeliveryTime(deliveryTimeValidated.value());
} else {
violations.addAll(deliveryTimeValidated.errors());
}
if (violations.isEmpty()) {
ConstraintViolations orderViolations = order.validateForDelivery();
violations.addAll(orderViolations);
}
if (!violations.isEmpty()) {
return Result.error(new OrderDeliveryException(violations));
}
// 注文を配送状態にし、永続化する。
order.deliver();
saveOrderPort.save(order);
return Result.ok(new DeliveredOrderEvent(
order.getOrderId(),
order.getDeliverAddress(),
order.getDeliveryTime()
));
}
}
この設計の課題は、配送可能な注文に関するビジネスルールがドメイン層から漏洩している点にある。ドメインモデルが満たすべき不変条件を守るのは、ドメイン層からアプリケーション層の責務になっている。
そして前の設計と同じく、ビジネスルールのValidationを忘れてしまうと、Validでない状態でOrderが作られてしまうことには変わりない。
Solution3: TryExecuteパターン
code:Order.java
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Order {
private static final Validator<Order> validator = ValidatorBuilder.<Order>of()
.constraintOnCondition((order, constraintGroup) -> order.getConstraints().contains(SHIPPING_JAPAN_ONLY),
c -> c.constraintOnTarget(o -> o.getDeliverAddress().getCountry().equals("JP"), SHIPPING_JAPAN_ONLY.getConstraintViolation().name(),
SHIPPING_JAPAN_ONLY.getViolationMessage()))
.constraintOnCondition((order, constraintGroup) -> order.getConstraints().contains(DELIVERY_WEEKDAY),
c -> c.constraintOnTarget(o -> !o.getDeliveryTime().isHoliday(), DELIVERY_WEEKDAY.getConstraintViolation().name(),
DELIVERY_WEEKDAY.getViolationMessage()))
.build();
public static Order ofInProgress(OrderId orderId, EnumSet<OrderConstraint> constraint) {
Order order = new Order();
order.orderId = orderId;
order.status = OrderStatus.IN_PROGRESS;
order.constraints = constraint;
return order;
}
@Getter
OrderId orderId;
@Getter
OrderStatus status = OrderStatus.IN_PROGRESS;
@Getter
EnumSet<OrderConstraint> constraints;
@Getter
Address deliverAddress;
@Getter
DeliveryTime deliveryTime;
protected OrderMemento saveState() {
return new OrderMemento(status, deliverAddress, deliveryTime);
}
protected void restoreState(OrderMemento orderMemento) {
status = orderMemento.getOrderStatus();
deliverAddress = orderMemento.getAddress();
deliveryTime = orderMemento.getDeliveryTime();
}
/**
* 住所と配送日を受け取り配送処理を行う。
*
* @param address 配送先住所
* @param deliveryTime 配達日時
* @return 注文のValidated型
*/
public Validated<Order> deliver(Address address, DeliveryTime deliveryTime) {
OrderMemento previousState = saveState();
this.deliverAddress = address;
this.deliveryTime = deliveryTime;
status = OrderStatus.DELIVERING;
Validated<Order> validatedOrder = validator.applicative().validate(this);
if (!validatedOrder.isValid()) {
restoreState(previousState);
}
return validatedOrder;
}
}
deliverメソッドの中で、ドメインモデルがValidかどうかを検証する。Validでなければエラー(例外なりEither/Resultなり)が返る。ドメイン知識の漏洩は発生していないし、前の2つの設計と違い呼び出し側からみて、Validでない状態でOrderが作られることはない。(deliverの中では検証のために一時的にinvalidな状態が存在する。したがって元に戻すためにMementoパターンを使っている)
Solution4: Execute / CanExecute パターン
Solution3はOrderを使う側からすると、必要なドメイン知識はドメインでよろしくやってくれて、不正な状態のOrderを気にすることがなくなるので、だいぶ良い設計といえるようになったが、TryExecuteの中で不正な状態が発生はしうるので、Order自体のコードをいじる過程で、不正な状態で処理をしてしまうバグをまだ生みやすい、と考えることもできる。
ので、Orderのインスタンスが常にValidな状態でしか存在し得ないようにできないか? というのがExecute / CanExecuteパターンである。
単にTryExecuteのパターンに、canDeliverというメソッドを用意して、いきなりdeliverを呼ぶんじゃなくて、まずcanDeliverを呼んで、trueが返ればdeliverを呼びだす、ことによってOrderがValidな状態でしか存在しえないようにしようという試みだ。
code:Order.java
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Order {
private static final Validator<Order> validator = ValidatorBuilder.<Order>of()
.constraintOnCondition((order, constraintGroup) -> order.getConstraints().contains(SHIPPING_JAPAN_ONLY),
c -> c.constraintOnTarget(o -> o.getDeliverAddress().getCountry().equals("JP"), SHIPPING_JAPAN_ONLY.getConstraintViolation().name(),
SHIPPING_JAPAN_ONLY.getViolationMessage()))
.constraintOnCondition((order, constraintGroup) -> order.getConstraints().contains(DELIVERY_WEEKDAY),
c -> c.constraintOnTarget(o -> !o.getDeliveryTime().isHoliday(), DELIVERY_WEEKDAY.getConstraintViolation().name(),
DELIVERY_WEEKDAY.getViolationMessage()))
.build();
public static Order ofInProgress(OrderId orderId, EnumSet<OrderConstraint> constraint) {
Order order = new Order();
order.orderId = orderId;
order.status = OrderStatus.IN_PROGRESS;
order.constraints = constraint;
return order;
}
@Getter
OrderId orderId;
@Getter
OrderStatus status = OrderStatus.IN_PROGRESS;
@Getter
EnumSet<OrderConstraint> constraints;
@Getter
Address deliverAddress;
@Getter
DeliveryTime deliveryTime;
protected OrderMemento saveState() {
return new OrderMemento(status, deliverAddress, deliveryTime);
}
protected void restoreState(OrderMemento orderMemento) {
status = orderMemento.getOrderStatus();
deliverAddress = orderMemento.getAddress();
deliveryTime = orderMemento.getDeliveryTime();
}
public boolean canDeliver(Address address, DeliveryTime deliveryTime) {
Validated<Order> validatedOrder = validator.applicative().validate(this);
return validatedOrder.isValid();
}
/**
* 住所と配送日を受け取り配送処理を行う。
*
* @param address 配送先住所
* @param deliveryTime 配達日時
* @return 注文のValidated型
*/
public Validated<Order> deliver(Address address, DeliveryTime deliveryTime) {
OrderMemento previousState = saveState();
this.deliverAddress = address;
this.deliveryTime = deliveryTime;
status = OrderStatus.DELIVERING;
Validated<Order> validatedOrder = validator.applicative().validate(this);
if (!validatedOrder.isValid()) {
restoreState(previousState);
}
return validatedOrder;
}
}
…でも!
canDeliverを呼び忘れると、結果的にInvalidな状態でOrderが作られることになるので、deliverではそのFail Safeな対策としてTryExecuteの処理を残してある。
Khorikovさんの記事もここまで、であるが、そんなことでAlways-Valid Domain Modelと言っちゃっていいものか?
ドメインモデル貧血症で述べたように、このSolution4のOrderは不変条件が異なる(deliverAddressとdeliverTimeは、Deliveringステータスのときは必須だが、InProgressステータスのときは必須ではない)し、ふるまいが異なる(deliverはInProgressステータスに対してしか呼べない)。
したがって、これは型で明示したほうが良い。
code: Order.java
public interface Order {
static InProgressOrder ofInProgress(OrderId orderId, EnumSet<OrderConstraint> constraint) {
InProgressOrder order = new InProgressOrder();
order.orderId = orderId;
order.constraints = constraint;
return order;
}
OrderId getOrderId();
EnumSet<OrderConstraint> getConstraints();
}
code:AbstractOrder.java
public abstract class AbstractOrder implements Order{
@Getter
OrderId orderId;
@Getter
EnumSet<OrderConstraint> constraints;
}
code:InProgressOrder.java
public class InProgressOrder extends AbstractOrder {
/**
* 住所と配送日を受け取り配送処理を行う。
*
* @param address 配送先住所
* @param deliveryTime 配達日時
* @return 注文のValidated型
*/
public Validated<DeliveringOrder> deliver(Address address, DeliveryTime deliveryTime) {
return DeliveringOrder.of(this, address, deliveryTime);
}
}
code:DeliveringOrder.java
public class DeliveringOrder extends AbstractOrder {
private static final Validator<DeliveringOrder> validator = ValidatorBuilder.<DeliveringOrder>of()
.constraintOnCondition((order, constraintGroup) -> order.getConstraints().contains(SHIPPING_JAPAN_ONLY),
c -> c.constraintOnTarget(o -> o.getDeliverAddress().getCountry().equals("JP"), SHIPPING_JAPAN_ONLY.getConstraintViolation().name(),
SHIPPING_JAPAN_ONLY.getViolationMessage()))
.constraintOnCondition((order, constraintGroup) -> order.getConstraints().contains(DELIVERY_WEEKDAY),
c -> c.constraintOnTarget(o -> !o.getDeliveryTime().isHoliday(), DELIVERY_WEEKDAY.getConstraintViolation().name(),
DELIVERY_WEEKDAY.getViolationMessage()))
.build();
private DeliveringOrder(InProgressOrder inProgressOrder, Address deliverAddress, DeliveryTime deliveryTime) {
this.orderId = inProgressOrder.getOrderId();
this.constraints = inProgressOrder.getConstraints();
this.deliverAddress = deliverAddress;
this.deliveryTime = deliveryTime;
}
static Validated<DeliveringOrder> of(InProgressOrder inProgressOrder, Address deliverAddress, DeliveryTime deliveryTime) {
DeliveringOrder deliveringOrder = new DeliveringOrder(inProgressOrder, deliverAddress, deliveryTime);
return validator.applicative().validate(deliveringOrder);
}
@Getter
Address deliverAddress;
@Getter
DeliveryTime deliveryTime;
}
これで、deliverを不正な状態で呼び出すことが出来なくなるし、OrderがInvalidな状態で作られることもなくなる。
継承でやるの?
上記例のOrderはステータスによってふるまいと不変条件が異なる典型例なので、その違いで型を分け、それらを同じ抽象概念Orderとしてまとめ上げるのに、継承を使って表現した。が、そうしにくいケースも存在する。
この記事の例
https://zenn.dev/dowanna6/articles/92c6494570a4dc
code: Person.ts
class Person { // (ドメイン)
public age: number
public constructor(age: number) {
this.age = age
}
}
class Usecase1 { // (アプリケーションサービス層に属するクラス。コントローラー層のメソッドの1つと捉えていただいてもOKです)
do(p: Person) {
if (p.age > 18) {
// 18歳以上なら登録できる!
}
}
}
class Usecase2 { // (アプリケーションサービス層に属するクラス。コントローラー層のメソッドの1つと捉えていただいてもOKです)
do(p: Person) {
if (p.age < 48) {
// 48歳未満なら好きな画像も保存できる!
}
}
}
class Usecase3 { // (アプリケーションサービス層に属するクラス。コントローラー層のメソッドの1つと捉えていただいてもOKです)
do(p: Person) {
if (p.age == 100) {
// 100歳の方にはamazonギフト券をプレゼント!
}
}
}
この記事では、この貧血症コードに対して、前述のExecute / CanExecuteパターンを用いて改善例を提示しているが、これももっと型で明示し、「CanExecuteメソッド呼び出しを忘れてしまうかもしれない」リスクに対処したい。
かといって、AgeLessThan48Personとか Age100Personとかサブクラス作って