前回ゆうパケットを題材にValidationを実装してみた。例えば荷物の高さ制限は3cm以下なので、これを満たさない入力値はエラーで返すAPIみたいなのを作ってた。ただこの3cm制限は高さドメインでは無くValidationに持たせていたが、これドメインでは?とちょっと思ってきた。なので自分の判断基準を決めて、コードを直してみる。コードの全体はValidationとRailwayプログラミングを参照。

(改修前)Height.java

今のところドメイン知識は無く、高さを保持するだけのクラス。

package com.example.demo.domain.yu_packet;

import lombok.Data;

@Data
public class Height {
  private final int value;
}
(改修前)YuPacketValidator.java
package com.example.demo.domain.yu_packet;

import io.vavr.collection.Seq;
import io.vavr.control.Validation;
import org.springframework.stereotype.Component;

@Component
public class YuPacketValidator {
  public Validation<Seq<InvalidatedReason>, YuPaketCreateCommand> validate(
      YuPacketRegistrationCommand command) {
    return Validation.combine(
            validationDepth(command),
            validationHeight(command),
            validationWeight(command),
            validationTotalDimension(command))
        .ap(
            (depth, height, weight, _x) ->
                new YuPaketCreateCommand(new Witdth(command.getWidth()), height, depth, weight))
        .mapError(errorMessages -> errorMessages.map(InvalidatedReason::new));
  }

  private static Validation<String, Depth> validationDepth(YuPacketRegistrationCommand command) {
    return (0 < command.getDepth() && command.getDepth() <= 34)
        ? Validation.valid(new Depth(command.getDepth()))
        : Validation.invalid("Depth must be between 1 and 33 cm. depth: " + command.getDepth());
  }

  private static Validation<String, Height> validationHeight(YuPacketRegistrationCommand command) {
    return (0 < command.getHeight() && command.getHeight() <= 3)
        ? Validation.valid(new Height(command.getHeight()))
        : Validation.invalid("Height must be between 1 and 3 cm. height: " + command.getHeight());
  }

  private static Validation<String, Weight> validationWeight(YuPacketRegistrationCommand command) {
    return (0 < command.getWeight() && command.getWeight() <= 1.0)
        ? Validation.valid(new Weight(command.getWidth()))
        : Validation.invalid(
            "Weight must be between 1 and 1 Kilogram. weight: " + command.getWeight());
  }

  private static Validation<String, Void> validationTotalDimension(
      YuPacketRegistrationCommand command) {
    int totalDimension = command.getWidth() + command.getHeight() + command.getDepth();
    return (totalDimension <= 60)
        ? Validation.valid(null)
        : Validation.invalid(
            "Total dimension must be less than or equal to 60 cm. total dimension: "
                + totalDimension);
  }
}

例えば、高さ制限の知識はこのバリデーションが持っている。これドメインでは?と思った次第。

  private static Validation<String, Height> validationHeight(YuPacketRegistrationCommand command) {
    return (0 < command.getHeight() && command.getHeight() <= 3)
        ? Validation.valid(new Height(command.getHeight()))
        : Validation.invalid("Height must be between 1 and 3 cm. height: " + command.getHeight());
  }

判断基準

このHeightクラスは何者なのか、これが判断基準な気がする。候補は下記2個

  • ゆうパケットの高さを表現するクラス
  • 郵便局が扱う荷物全般の高さを表現するクラス
    • 例えばゆうパケットではなく、ゆうパックみたいな3cm制限を持たない荷物も表す郵便局が扱う荷物全般を表現するクラス

んー狭義に定義しておいた方が、責務が分かれるためビジネスルールをドメインに持たせやすそうな気がする。ので、このHeightクラスはゆうパケットの高さを表現するクラスと判断する。クラス名はHeightでいいのだろうか?わざわざYuPacketHeightとするのは冗長そうだし、仮にゆうパックドメインが誕生してもゆうパケットと同時に扱うことが少なそうなのでHeightのままにしておく。

Height.java

一応外から呼び出せるvalidateメソッドを追加した。コンストラクタのofは内部でvalidateをパスできればHeightを返し、何か問題があればOption.noneを返すようにした。Heightドメイン的にはHeigthが作れた / 作れなかった だけ返して、その解釈・処理対応方法は呼び出し側に任せる設計とした。他のDepthやWeight等も同様の設計。

package com.example.demo.domain.yu_packet;

import io.vavr.control.Option;
import lombok.Data;

@Data
public class Height {
  private final int value;

  public static Option<Height> of(int height) {
    return validate(height) ? Option.of(new Height(height)) : Option.none();
  }

  public static Boolean validate(int height) {
    return 0 < height && height <= 3;
  }
}
YuPacketValidator.java

バリデーションは上記方針を受けてHeightドメインをofコンストラクタで作成し、仮に失敗が返ってきたらエラー理由を考えて詰めて返してあげる方針。か、staticメソッドで実装してあるvalidateメソッドを呼び、パスすればofコンストラクタでドメインを作成、そのの上で仮に失敗が返ってきたらエラー理由を考えて詰めて返してあげる方針かで迷った。流石にvalidateメソッドが全くもって複雑では無いため、前者の「ドメインをofコンストラクタで作成し、仮に失敗が返ってきたらエラー理由を考えて詰めて返してあげる方針」とした。…仮にバリデーションが複雑になりコンストラクタ内で複数のチェックを組み合わせる必要があっても「ドメインをofコンストラクタで作成し、仮に失敗が返ってきたらエラー理由を考えて詰めて返してあげる方針」の方がいいか。Heightドメインを作成するために必要なバリデーションはHeightドメイン内部にあるべきで、YuPacketValidator側でどのバリデーションメソッドを呼ぶかをコントロールすべきでは無い(そもそもできない)気がした。

package com.example.demo.domain.yu_packet;

import io.vavr.collection.Seq;
import io.vavr.control.Validation;
import org.springframework.stereotype.Component;

@Component
public class YuPacketValidator {
  public Validation<Seq<InvalidatedReason>, YuPaketCreateCommand> validate(
      YuPacketRegistrationCommand command) {
    return Validation.combine(
            validationDepth(command),
            validationHeight(command),
            validationWeight(command),
            validationTotalDimension(command))
        .ap(
            (depth, height, weight, _x) ->
                new YuPaketCreateCommand(new Witdth(command.getWidth()), height, depth, weight))
        .mapError(errorMessages -> errorMessages.map(InvalidatedReason::new));
  }

  private static Validation<String, Depth> validationDepth(YuPacketRegistrationCommand command) {
    return Depth.of(command.getDepth())
        .map(Validation::<String, Depth>valid)
        .getOrElse(
            () ->
                Validation.invalid(
                    "Depth must be between 1 and 34 cm. depth: " + command.getDepth()));
  }

  private static Validation<String, Height> validationHeight(YuPacketRegistrationCommand command) {
    return Height.validate(command.getHeight())
        ? Height.of(command.getHeight())
            .map(Validation::<String, Height>valid)
            .getOrElse(Validation.invalid("cannot create Height"))
        : Validation.invalid("Height must be between 1 and 3 cm. height: " + command.getHeight());
  }

  private static Validation<String, Weight> validationWeight(YuPacketRegistrationCommand command) {
    return Weight.of(command.getWeight())
        .map(Validation::<String, Weight>valid)
        .getOrElse(
            () ->
                Validation.invalid(
                    "Weight must be between 1 and 1 Kilogram. weight: " + command.getWeight()));
  }

  private static Validation<String, Void> validationTotalDimension(
      YuPacketRegistrationCommand command) {
    int totalDimension = command.getWidth() + command.getHeight() + command.getDepth();
    return (totalDimension <= 60)
        ? Validation.valid(null)
        : Validation.invalid(
            "Total dimension must be less than or equal to 60 cm. total dimension: "
                + totalDimension);
  }
}
(ボツ案) バリデーション呼んでからドメインが作れるか確認するパターン

コンストラクタのofメソッドでドメイン作成前に、バリデーションする構成。ドメイン作成する際に必要なバリデーションをYuPacketValidator側でコントロールしているため責務がきれいに分離できていない。

private static Validation<String, Height> validationHeight(YuPacketRegistrationCommand command) {
    return Height.validate(command.getHeight())
        ? Height.of(command.getHeight())
            .map(Validation::<String, Height>valid)
            .getOrElse(Validation.invalid("cannot create Height"))
        : Validation.invalid("Height must be between 1 and 3 cm. height: " + command.getHeight());
  }
(採用案) ドメイン作成にバリデーションをまかせ、かえってきた成功/失敗で振る舞いを決めるパターン

YuPacketValidatorは、ドメイン作成にあたりどのバリデーションメソッドを呼ぶ必要があるかは知らない。バリデーションをパスしドメインが作成されたか、または失敗しドメイン作成できなかったかしか知らず、失敗時どのような振る舞いにするかだけがYuPacketValidatorの関心事になっているためきれいに責務分離ができている。

  private static Validation<String, Weight> validationWeight(YuPacketRegistrationCommand command) {
    return Weight.of(command.getWeight())
        .map(Validation::<String, Weight>valid)
        .getOrElse(
            () ->
                Validation.invalid(
                    "Weight must be between 1 and 1 Kilogram. weight: " + command.getWeight()));
  }