カッコイイValidationを書く。Javaで。今どきバリデーションはif文で手続き的に書くなんてことはしないらしい。また、1個バリデーションをパスできなかったからってそこでエラーを返さないで、一通りバリデーションした(失敗)結果をまとめて返すらしい。たしかに利用者側からすると複数間違いがあるのに、1回1個のエラーだけ返され複数回やり取りするなんてキレそう。 うまいことエラーをコントロールする方法をRailwayProgrammingと呼ぶらしい。実際はバリデーションに絞らず、もっと広域の処理におけるエラーコントロールを指していそうだが、今回はバリデーションの場合だけ考えてみる。

題材選び

いい感じに制限があったのでゆうパケットを題材にしてみる。 3辺の長さと重さを入れてもらってバリデーション通す。バリデーション通ったらゆうパケットドメインが作成され、通らなかったらエラーを返す処理を書いてみる。Validation処理だけ書いてみてもいいが、理解を深めるためゆうパケット登録的なAPI1個作ってみることにする。縦・横・奥行き・重さを入力値として受取、問題なければDBへ登録(面倒なのでやらない)、受付番号を返すAPIで。 Java + SpringBoot構成で作ってみた。テンプレートはSpring Initializrで作成。

環境周り

主にValidationはvavrを使って実現する。

build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.4'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring関連
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // その他ライブラリ
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    implementation 'io.vavr:vavr:0.9.0'

    // テスト関連
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    // フォーマッター
    implementation 'com.google.googlejavaformat:google-java-format:1.28.0'
}

tasks.named('test') {
    useJUnitPlatform()
}
ディレクトリ構成
src/main/java/com/example/demo
├── DemoApplication.java
├── controller
│   └── yu_packet
│       └── registration
│           ├── YuPaketRegistrationController.java
│           ├── request
│           │   └── YuPacketRequest.java
│           └── response
│               ├── YuPacketResponse.java
│               └── YuPacketResponseImpl.java
├── domain
│   └── yu_packet
│       ├── Depth.java
│       ├── Height.java
│       ├── InvalidatedReason.java
│       ├── Weight.java
│       ├── Witdth.java
│       ├── YuPacketRegistrationCommand.java
│       ├── YuPacketTrackingNumber.java
│       ├── YuPacketValidator.java
│       ├── YuPaket.java
│       └── YuPaketCreateCommand.java
└── service
    └── YuPaketRegistrationService.java

Domain

ゆうパケット クラスの構成

ゆうパケットドメインは、バリデーションをパスしたゆうパケット(YuPaket.Valid)とバリデーション失敗したゆうパケット(YuPaket.Invalid)から構成されると考えた。代数型における直和型で表現してる感じ。Javaにおける直和型の書き方はパクってきたので詳細は下記を参照されたし。 強いて言うならバリデーションをパスしたゆうパケット(YuPaket.Valid)は、(今回DB保存処理は書かないが)DBに保存された際きっと払い出される識別番号を持つくらいだろうか。

YuPaket.java
package com.example.demo.domain.yu_packet;

import io.vavr.collection.Seq;
import java.util.function.Function;

public sealed interface YuPaket permits YuPaket.Invalid, YuPaket.Valid {

  <T, R> R mapEither(
      Function<Valid, ? extends R> validMapper, Function<Invalid, ? extends R> invalidMapper);

  static YuPaket invalid(Seq<InvalidatedReason> invalidReasonList) {
    return new Invalid(invalidReasonList);
  }

  static YuPaket valid(
      YuPaketCreateCommand yuPaketCreateCommand, YuPacketTrackingNumber trackingNumber) {
    return new Valid(
        yuPaketCreateCommand.getWitdth(),
        yuPaketCreateCommand.getHeight(),
        yuPaketCreateCommand.getDepth(),
        yuPaketCreateCommand.getWeight(),
        trackingNumber);
  }

  record Invalid(Seq<InvalidatedReason> invalidReasonList) implements YuPaket {
    @Override
    public <T, R> R mapEither(
        Function<Valid, ? extends R> validMapper, Function<Invalid, ? extends R> invalidMapper) {
      return invalidMapper.apply(this);
    }
  }

  record Valid(
      Witdth witdth,
      Height height,
      Depth depth,
      Weight weight,
      YuPacketTrackingNumber trackingNumber)
      implements YuPaket {
    @Override
    public <T, R> R mapEither(
        Function<Valid, ? extends R> validMapper, Function<Invalid, ? extends R> invalidMapper) {
      return validMapper.apply(this);
    }
  }
}
InvalidatedReason.java

バリデーションで弾かれた理由を表現するクラス。

package com.example.demo.domain.yu_packet;

public record InvalidatedReason(String value) {}
YuPacketTrackingNumber.java

バリデーションをパスした後、DBへ登録する際に払い出されるであろうゆうパケットを識別するID。

package com.example.demo.domain.yu_packet;

import lombok.Data;

@Data
public class YuPacketTrackingNumber {
  private final int value;
}

あとは箱のサイズやら重さを表現するやつら。recordクラスで良かったな。。

Depth.java
package com.example.demo.domain.yu_packet;

import lombok.Data;

@Data
public class Depth {
  private final int value;
}
Height.java
package com.example.demo.domain.yu_packet;

import lombok.Data;

@Data
public class Height {
  private final int value;
}
Weight.java
package com.example.demo.domain.yu_packet;

import lombok.Data;

@Data
public class Weight {
  private final float value;
}
Witdth.java
package com.example.demo.domain.yu_packet;

import lombok.Data;

@Data
public class Witdth {
  private final int value;
}

Validation

本題。ホームページ見た感じバリデーションしたい内容は下記4項目を選定してみた。

  • 奥行きが33cm以下
    • validationDepth
  • 高さが3cm以下
    • validationHeight
  • 重さが1kg以下
    • validationWeight
  • 3辺の合計が60以下
    • validationTotalDimension

大枠の処理としてはValidation.combine()に実施したいバリデーションのメソッドを渡す。渡したバリデーション全てをパスしているならば.ap()メソッドがバリデーション結果をまとめて受け取ってくれる。今回は4個バリデーションメソッドを渡してあるので戻り値も4個((depth, height, weight, _x))。これを使って何か作って返すのが正常系の流れ。何かしらバリデーションをパスしない場合は.mapError()メソッド側の処理に落ちる。ここでRailwayProgrammingが生きてくるのだが、エラーの型は統一されているため.mapError()はバリデーションが返すエラーを集めてリストにしといてくれる。今回はただのString型であるためerrorMessages -> errorMessages.map(InvalidatedReason::new)のerrorMessages Listの中に最大4個,最小1個Stringが入っている。後はエラーをまとめて返却する型を作成するのが異常系の流れとなる。

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);
  }
}

バリデーションメソッド

高さのバリデーションメソッドに注目してみる。メソッドの戻り値はエラー内容後続処理で使う何かである。書き方としてはValidation<エラー内容, 後続処理で使う何か>である。例えば「高さが3cm以下」バリデーションメソッドであれば、入力値が高さが3cm以下であることがわかれば、高さドメイン(Height.java)を作って返してあげてるのが適当かと思ってそれにした。ゆうパケットは3辺と重さの制限があるのでそれぞれバリデーションし、問題なければ各ドメイン(Depth.java,Height.java,Weight.java)を作成し返す。それを元にゆうパケットを作るYuPaketCreateCommand.javaを作って呼び出し元に返してあげる作戦。(横幅の制限はなかったので特にバリデーションせず、値は引数から引っ張ってきている)

  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());
  }

後続処理でバリデーション結果を使用しない場合

ゆうパケット的は3辺と重さの制限以外に、3辺の合計値にも制限がある。したがって3辺合計値のバリデーションも作成したが、3辺合計値はゆうパケットドメイン的に必要ない。この戻り値は特に使用しないためVoidにしとく。

  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);
  }
YuPaketCreateCommand.java

バリデーションをパスしたゆうパケット(YuPaket.Valid)はこのYuPaketCreateCommandから作る。YuPaketCreateCommandYuPacketValidatorから作る、かつ、YuPaketCreateCommandのコンストラクタはパッケージプライベートなので基本YuPacketValidatorを通さないと作れない。ので、必ずゆうパケットはバリデーションをパスした要素で構成される(はず)。安全だ!

package com.example.demo.domain.yu_packet;

import lombok.Getter;

@Getter
public class YuPaketCreateCommand {
  private final Witdth witdth;
  private final Height height;
  private final Depth depth;
  private final Weight weight;

  YuPaketCreateCommand(Witdth witdth, Height height, Depth depth, Weight weight) {
    this.witdth = witdth;
    this.height = height;
    this.depth = depth;
    this.weight = weight;
  }
}

Service

メソッド名はregisterだけどDBへの登録処理とかは面倒なのでやらず、、、バリデーション呼んでYuPaketドメインを作るだけ。

YuPaketRegistrationService

Validation失敗が返ったときに呼び出す関数invalidatedReasons -> YuPaket.invalid(invalidatedReasons)と、成功してたときに呼んでほしい関数createCommand -> YuPaket.valid(createCommand, new YuPacketTrackingNumber(1)).foldに渡しておいてあげれば、validate(command)の結果を元にうまいこと呼び分けてくれる。

package com.example.demo.service;

import com.example.demo.domain.yu_packet.YuPacketRegistrationCommand;
import com.example.demo.domain.yu_packet.YuPacketTrackingNumber;
import com.example.demo.domain.yu_packet.YuPacketValidator;
import com.example.demo.domain.yu_packet.YuPaket;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class YuPaketRegistrationService {
  private final YuPacketValidator yuPacketValidator;

  public YuPaket register(YuPacketRegistrationCommand command) {
    return yuPacketValidator
        .validate(command)
        .fold(
            invalidatedReasons -> YuPaket.invalid(invalidatedReasons),
            createCommand -> YuPaket.valid(createCommand, new YuPacketTrackingNumber(1)));
  }
}

Controller

受け付けたリクエストをコマンドに変換、サービスにコマンドを渡し返ってきたゆうパケットの型ゆうパケット(YuPaket.Valid)/バリデーション失敗(YuPaket.invalid)に応じてレスポンスを返してあげる。

YuPaketRegistrationController.java

サービスにコマンドを渡した結果、無事もろもろのバリデーションをクリアした場合はバリデーション済みのゆうパケット(YuPaket.Valid)をもとに成功レスポンスをを作成する関数response::successを呼んでほしく、バリデーション失敗した場合は失敗理由を持ってるバリデーション失敗(YuPaket.invalid)をもとに失敗レスポンスをを作成する関数response::validatedFailureを呼んでほしい。ゆうパケットドメインの型に応じて関数を呼び分けてくれるのがmapEither

package com.example.demo.controller.yu_packet.registration;

import com.example.demo.controller.yu_packet.registration.request.YuPacketRequest;
import com.example.demo.controller.yu_packet.registration.response.YuPacketResponse;
import com.example.demo.domain.yu_packet.YuPacketRegistrationCommand;
import com.example.demo.service.YuPaketRegistrationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class YuPaketRegistrationController {
  private final YuPaketRegistrationService service;
  private final YuPacketResponse response;

  @RequestMapping(value = "/yu-paket", method = RequestMethod.POST)
  public ResponseEntity<?> invoke(@Validated @RequestBody YuPacketRequest yuPacketRequest) {
    YuPacketRegistrationCommand command = yuPacketRequest.toCommand();

    return service.register(command).mapEither(response::success, response::validatedFailure);
  }
}

Request

リクエストされた値を受け付け、登録用コマンドYuPacketRegistrationCommandへ変換するメソッドをもたせた

YuPacketRequest.java
package com.example.demo.controller.yu_packet.registration.request;

import com.example.demo.domain.yu_packet.YuPacketRegistrationCommand;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
public class YuPacketRequest {
  @NotNull private int width;

  @NotNull private int height;

  @NotNull private int depth;

  @NotNull private float weight;

  public YuPacketRegistrationCommand toCommand() {
    return new YuPacketRegistrationCommand(width, height, depth, weight);
  }
}
YuPacketRegistrationCommand.java
package com.example.demo.domain.yu_packet;

import lombok.Data;

@Data
public class YuPacketRegistrationCommand {
  private final int width;
  private final int height;
  private final int depth;
  private final float weight;
}

Response

レスポンスを作る。

YuPacketResponse.java
package com.example.demo.controller.yu_packet.registration.response;

import com.example.demo.domain.yu_packet.YuPaket;
import java.util.Map;
import org.springframework.http.ResponseEntity;

public interface YuPacketResponse {
  ResponseEntity<Map> success(YuPaket.Valid yuPaket);

  ResponseEntity<Map> validatedFailure(YuPaket.Invalid yuPaket);
}
YuPacketResponseImpl.java
package com.example.demo.controller.yu_packet.registration.response;

import com.example.demo.domain.yu_packet.InvalidatedReason;
import com.example.demo.domain.yu_packet.YuPaket;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

@Component
class YuPacketResponseImpl implements YuPacketResponse {

  @Override
  public ResponseEntity<Map> success(YuPaket.Valid yuPaket) {

    // status
    HttpStatus httpStatus = HttpStatus.OK;

    // header
    MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
    headers.add("Content-Type", "application/json; charset=UTF-8");
    HttpHeaders httpHeaders = new HttpHeaders(headers);

    // body
    Map<String, String> body = new HashMap<>();
    body.put("id", String.valueOf(yuPaket.trackingNumber().getValue()));

    return ResponseEntity.status(httpStatus).headers(httpHeaders).body(body);
  }

  @Override
  public ResponseEntity<Map> validatedFailure(YuPaket.Invalid yuPaket) {
    // status
    HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

    // header
    MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
    headers.add("Content-Type", "application/json; charset=UTF-8");
    HttpHeaders httpHeaders = new HttpHeaders(headers);

    // body
    Map<String, List<String>> body = new HashMap<>();
    body.put("errors", yuPaket.invalidReasonList().map(InvalidatedReason::value).toJavaList());

    return ResponseEntity.status(httpStatus).headers(httpHeaders).body(body);
  }
}

APIを呼んでみる

% curl -X POST localhost:8080/yu-paket -H "Content-Type: application/json" -d @- <<EOF | jq .
{
   "width":10,
   "height":20,
   "depth":40,
   "weight":1.0
}
EOF

ちゃんとエラーがまとまって返ってくる!

{
  "errors": [
    "Depth must be between 1 and 33 cm. depth: 40",
    "Height must be between 1 and 3 cm. height: 20",
    "Total dimension must be less than or equal to 60 cm. total dimension: 70"
  ]
}