🐕

JavaのSealedを利用したService層の書き方

2023/11/12に公開

tl;dr

Java17~

// Service層(Result型)
public sealed interface UseCaseSomethingResult
    permits UseCaseSomethingResultResultOk,
        UseCaseSomethingResultDuplicateWorkingUser,
        UseCaseSomethingResultNg {

  public record UseCaseSomethingResultResultOk(String message) implements UseCaseSomethingResult {}

  public record UseCaseSomethingResultDuplicateWorkingUser(String message, long duplicateId)
      implements UseCaseSomethingResult {}

  public record UseCaseSomethingResultNg() implements UseCaseSomethingResult {}
  
  static UseCaseSomethingResult ok(String targetName) {
    return new UseCaseSomethingResultResultOk(targetName);
  }

  static UseCaseSomethingResult duplicate(String targetName, String duplicatedTargetName) {
    return new UseCaseSomethingResultDuplicate(targetName, duplicatedTargetName);
  }
}

// Service層
public class SomthingService {
    @Inject
    private ITargetRepository targetRepository;
      
    public UseCaseSomethingResult doSomething(long targetId, String targetName) {
      final var target = targetRepository.findById(target).orElse(null);
      
      if (target == null) {
        return UseCaseSomethingResult.NG;
      }
      
      var isUpdated = target.isUpdated();
      if (isUpdated) {
        var duplicatedTargetName = target.getName();
        return UseCaseSomethingResult.duplicate(targetName, duplicatedTargetName);
      }

      target.updateName(targetName);
      targetRepository.save(target);
      return UseCaseSomethingResult.ok(targetName);
    }
}

// Presentation層
public static void handleUseCaseSomething(UseCaseSomethingResult result) {
    String message = null;
    // pattern switchで書きたいけどVsCode勢で現段階で21が使えないのでifで書く
    if (result instanceof UseCaseSomethingResultResultOk ok) {
      message = ok.targetName() + "の更新が完了しました!";
    } else if (result instanceof final UseCaseSomethingResultDuplicate duplicate) {
      message =
          duplicate.targetName()
              + "の更新に失敗しました!"
              + duplicate.duplicatedTargetName()
              + "とすでに更新が重複しています。";
    } else if (result instanceof final UseCaseSomethingResultNg ng) {
      message = "更新に失敗しました。";
    } else {
      throw new IllegalArgumentException();
    }

    System.out.println(message);
}

Pros&Cons

Pros

  • Presentation側での分岐がsealedで指定されている型の単位になり、Presentation側で見えるフィールドの値が最小限のスコープにとどまる
  • sealedされたinterfaceを継承するパターンがそのままユースケースの結果パターンを網羅・表現しているので、Serviceの実装を追わなくてもinterfaceを見るだけで利用者は返り値の中身のセマンティックがわかる

Cons

  • 返り値のパターンの数だけ実装しなければいけない手間
  • それぞれの状態ごとに最低限必要なフィールド値を洗い出す手間
  • Presentation側の返り値の要求が変わった時、パターンの数が多いと上の2つの手間がまたかかってくる

Prosの説明

Presentation側での分岐がsealedで指定されている型の単位になり、Presentation側で見えるフィールドの値が最小限のスコープにとどまる

例えばひとつのResult型を作る実装だと以下のような見た目になる。

  public static class UseCaseSomethingMegaResult {
    private boolean isOk;
    private String targetName;
    private String duplicateTargetName;
  }

これだとPresentation側で返り値のパターンが一切見えないので、石橋を叩いて渡るような実装をされても文句は言えない。※ちなみに意図的にnullチェックが完璧ではない状態にしている→精神がすり減る状況を再現

 private static void handleUseCaseSomething(UseCaseSomethingMegaResult result) {
     String message = null;
    // 保守担当>isOkでもtargetNameがnullの時があるの?その時ってどこの分岐行くんや..
    if (result.isOk && result.targetName != null) {
      message = result.targetName() + "の更新が完了しました!";
    } else if (!result.isOk && result.duplicateTargetName != null) {
      message =
	  // 保守担当>nullチェックしてるけどこのメソッドに本当に値は入っているのか...そや一応ここにもnullチェック足しとこ!
          result.targetName
              + "の更新に失敗しました!"
              + result.duplicateTargetName
              + "とすでに更新が重複しています。";
    } else if (!result.isOk) {
      // 保守担当>この時targetNameって取れるんか?わからんな見にいくか..
      // 保守担当>targetNameじゃなくてtargetId欲しかったから結果のクラスにid追加したろ!
      // 保守担当(新人)>targetIdってここの分岐以外でも取れるんやろか..?正常系で全部ほしいんやけど、、せや!正常系にtargetIdのnullチェック追加したろ!
      message = "更新に失敗しました。";
    } else {
      // 保守担当>else早くないか?もっとパターンないか?ていうかこれ以上分岐足したら余計見辛くなるやんけ
      throw new IllegalArgumentException();
    }

    System.out.println(message);
  } 

sealedされたinterfaceを継承するパターンがそのままユースケースの結果パターンを網羅・表現しているので、Serviceの実装を追わなくてもinterfaceを見るだけで利用者は返り値の中身の値のコンテキストが解像度高くわかる

これは最初に見せたコードの通りで、まずServiceのdoSomethingというAPIには3つのパターン(状態)があることがわかる。利用者はそれらをどのようにハンドリングすれば良いかを考えるが、OK/NG/Duplicateとあるので文字通りどのようなケースなのか具体的な名称が割り当てられているのですべての状態について処理すれば良いとわかる。あとは利用者がそれぞれの場面で欲しいデータが足りているかをケースごとに見るだけなのでわざわざサービスを丁寧に見る必要はなくなる。

補足:sealedを使わなくてもできる?

sealed句を用いなくても以下のように抽象クラスを返して利用側で同じようにキャストすれば良い。※AllArgsConstructorはlombokのapiで全てのフィールド引数のコンストラクタを自動で生成する

// Service層
public abstract static class AbstractUseCaseSomethingResult {

    @AllArgsConstructor
    public static class UseCaseSomethingResultResultOk extends AbstractUseCaseSomethingResult {
      public final String targetName;
    }

    @AllArgsConstructor
    public static class UseCaseSomethingResultResultDuplicate
        extends AbstractUseCaseSomethingResult {
      public final String targetName;
      public final String duplicatedTargetName;
    }

    @AllArgsConstructor
    public static class UseCaseSomethingResultNg extends AbstractUseCaseSomethingResult {}
}

しかし、上のような書き方はsealedを用いない実装よりも以下の点で課題がある。

  1. コーディング量が多い
  2. コンストラクタを作らなければいけない→recordは記載せずとも全てのフィールドを引数で受け取るコンストラクタがある※ちなみにrecordクラスは継承(extends)が使えない
  3. フィールドのアクセスレベルと可変性を定義する自由がある→recordはデフォルトでフィールドのgetterを提供するかつfinal(イミュータブル)

そのためsealedを使える場面ではsealedを使う力が強くなる。

interfaceを使ってsealedを使わないという手も考えられるが、21で採用されるpattern switchではsealedを指定していると条件網羅してないとコンパイルエラー→継続的に漏れ防止になりそうなので、それをどう思うかによる。

本記事で言及していない部分を補足してくれる記事

https://zenn.dev/eagle/articles/ts-coproduct-introduction

Discussion