BIGLOBEの「はたらく人」と「トガッた技術」

Java 17の新機能でドメインモデリングの表現力を高めてみる

f:id:biglobe-editor2:20220121123125j:plain 基盤本部(開発部門)の木下です。Java 17 の新機能を使って、ドメイン駆動設計(Domain Driven Design: DDD)のモデリングの表現力を高める例をご紹介します。

皆さんは「事前条件が OK ならデータベースを更新する」というロジックを、クリーンアーキテクチャのどのレイヤーに実装していますか? 事前条件はドメイン知識なのでドメインサービスに実装したいところですが、リポジトリーを操作するアプリケーションサービスの中に書かれることも多いのではないでしょうか。

f:id:biglobe-editor2:20200213142617p:plain:w480
クリーンアーキテクチャー。https://style.biglobe.co.jp/entry/2020/02/13/150709 より引用

この記事では、ドメインサービスとアプリケーションサービスをきれいに分離するために、Java 17 で正式導入された interface の sealed と permits を活用する方法を実例を使いながら段階的に説明していきます。

ポイントは「条件チェック OK の型」と「NG の型」を一つの型として扱う代数的データ型(Algebraic Data Types)の導入です。さらに Java 17 のプレビュー機能である switch のパターンマッチングを使えば、代数的データ型を使った分岐を、安全かつ分かりやすく記述できることも示します。

まずは、ちょっとイケてない実装例から見ていきます。

ドメイン知識がアプリケーションサービスに漏れ出すイケてない例

例として、モバイル契約の料金プラン変更をするユースケースを考えてみます。以下のような仕様だったとします。

  • 3GB, 6GB, 12GB のような複数の料金プランがある
  • 契約していなければプランを変更できない
  • 同じプランには変更できない

ドメインとアプリケーションサービスの実装はこんな風になるでしょう。雑ですが簡単のため値オブジェクトやサービスの戻り値の型は String にしています。

ドメイン層

Java 16 で正式導入された record 型を使うことで、イミュータブルなドメインクラスをボイラープレートコードなしで実現しています。

package com.example.domain;

// プラン変更のビジネスロジック。プランが同じかどうか判定します。
public record PlanChangeApplication(String contractNumber, String changedPlan) {
    public boolean isSamePlan(Contract existingContract) {
        return plan.equals(existingContract.plan());
    }
}
package com.example.domain;

// プラン変更イベント。契約番号とプラン名を持っています。
public record PlanChangedEvent(String contractNumber, String changedPlan) {}
package com.example.domain;

// 契約。プラン名を持っています。
public record Contract(String plan) {
    public PlanChangedEvent changePlan(PlanChangeApplication application) {
        return new PlanChangedEvent(application.contractNumber(), application.plan());
    }
}

イケてないアプリケーション層

package com.example.applicationservice;

// プラン変更のアプリケーションサービス。事前条件を確認してOKならデータベースへ保存します。
public class PlanChangeService {
    public String change(PlanChangeApplication application) {
        Optional<Contract> existingContract = contractRepository.find(application.contractNumber);
        if (existingContract.isEmpty()) {
            return "Error: 契約がありません";
        }
        if (application.isSamePlan(existingContract.get())) {
            return "Error: 同じプランに変更できません";
        }
        PlanChangedEvent event = existingContract.get().changePlan(application);
        contractRepository.persist(event);
        return "Ok: " + event.changedPlan() + " に変更されました";
    }
}

アプリケーションサービスである PlanChangeService の change メソッドでは、ドメインオブジェクトである PlanChangeApplication や Contract のメソッドを使ってエラーチェックし、エラーがなかった場合には ContractRepository を使ってイベントを永続化します。

この change メソッドは比較的長いロジックになっていて良くない匂いがします。どういうときにプラン変更が可能かはドメインの知識であるべきですが、アプリケーションサービスに漏れ出していることが課題です。

ロジックをドメインに寄せる

ドメインで受付処理を実装する

アプリケーションサービスからドメインの知識を追い出し、ドメインサービスで事前条件チェックと適用まで一気にやってしまうことを考えます。そのようなドメインサービスは戻り値に何を返せば良いでしょうか?

やりたいことは、チェックに失敗した場合にはエラーコードを返し、成功した場合には PlanChangedEvent を返すことです。その両方を包含する一つの型が作れればそれがドメインサービスの戻り値の型にできます。

Java ではそのような型は interface によって表現できます。

package com.example.domain;

public sealed interface PlanChangeResult permits PlanChangeResult.Changed, PlanChangeResult.Error {
    record Changed(PlanChangedEvent event) implements PlanChangeResult {}
    record Error(PlanChangeError error) implements PlanChangeResult {}
}

Java 17 で正式導入された sealed と permits によって、PlanChangeResult を実装する型はここに出てくる Changed と Error しかないことを表現しています。

このような、いくつかの異なる型を組み合わせてできる型を代数的データ型と呼びます。 またここでの ChangedError のように代数的データ型が取り得る値の種類をデータ構築子と呼びます。

代数的データ型によって、このような「チェックに失敗した場合はエラーコードを持ち、成功した場合には PlanChangedEvent を持つ」型を表現できます。

なおエラーコードは PlanChangeError という enum で表現することにします。

package com.example.domain;

public enum PlanChangeError [
    CONTRACT_NOT_FOUND("契約がありません"),
    SAME_PLAN("同じプランに変更できません");
    private final String message;
    PlanChangeError(String message) { this.message = message; }
    String message() { return message; }
}

受付をイメージした PlanChangeReception というドメインサービスを作り、PlanChangeResult を返すようにしてみます。

ドメインサービスではリポジトリにアクセスすべきでないため、リポジトリから取得していた existingContract は引数で受け取るようにします。

package com.example.domain;

public class PlanChangeReception {
    public PlanChangeResult apply(
            PlanChangeApplication application,
            Optional<Contract> existingContract) {
        return existingContract.map(
                contract -> application.isSamePlan(contract) ?
                        new PlanChangeResult.Error(PlanChangeError.SAME_PLAN) :
                        new PlanCHangeResult.Changed(contract.changePlan(application))
        ).orElseGet(
                () -> new PlanChangeResult.Error(PlanChangeError.CONTRACT_NOT_FOUND)
        );
    }
}

これによって、アプリケーション層にあったチェックのための分岐がなくなります。

fold を使ってアプリケーションサービスを実装する

次にアプリケーションサービスの実装です。

ドメインサービスを呼び出した結果が成功だったときだけイベントの永続化をするというロジックが必要です。

代数的データ型では fold (畳み込み) と呼ばれる操作を使うことでデータ構築子の単位で振り分けることがよく行われます。 PlanChangeResult に fold メソッドを追加します。

package com.example.domain;

public sealed interface PlanChangeResult permits PlanChangeResult.Changed, PlanChangeResult.Error {
    <R> R fold(
            Function<PlanChangedEvent, ? extends R> handleChanged,
            Function<PlanChangeError, ? extends R> handleError
    );
    record Changed(PlanChangedEvent event) implements PlanChangeResult {
        @Override
        public <R> R fold(
                Function<PlanChangedEvent, ? extends R> handleChanged,
                Function<PlanChangeError, ? extends R> handleError) {
            return handleChanged.apply(event);
        }
    }

    record Error(PlanChangeError error) implements PlanChangeResult {
        @Override
        public <R> R fold(
                Function<PlanChangedEvent, ? extends R> handleChanged,
                Function<PlanChangeError, ? extends R> handleError) {
            return handleError.apply(error);
        }
    }
}

これを使ってアプリケーションサービスを実装します。

package com.example.applicationservice;

public class PlanChangeService {
    public String change(PlanChangeApplication application) {
        Optional<Contract> existingContract = contractRepository.find(application.contractNumber());
        PlanChangeResult status = reception.apply(application, existingContract);
        return status.fold(
                event -> {
                    contractRepository.persist(event);
                    return "Ok: " + changed.event().changedPlan() + " に変更されました";
                },
                error -> "Error: " + error.message()
        );
    }
}

fold を使うことで変なキャストもなく結果に応じた分岐が記述できました。 しかし fold に 2 つの関数を渡す形になるため PlanChangedEvent に対する処理なのか PlanChangeError に対する処理なのかが若干わかり辛いと言えます。

switch のパターンマッチングでアプリケーションサービスを実装する

Java 17 でプレビュー機能となっている switch のパターンマッチングを使うと、より分かりやすく分岐が記述できます。

package com.example.applicationservice;

public class PlanChangeService {
    public String change(PlanChangeApplication application) {
        Optional<Contract> existingContract = contractRepository.find(application.contractNumber);
        PlanChangeResult result = planChangeReception.apply(application, existingContract);
        return switch (result) {
            case PlanChangeResult.Changed changed -> {
                contractRepository.persist(changed.event());
                yield "Ok: " + changed.event().changedPlan() + " に変更されました";
            }
            case PlanChangeResult.Error error -> "Error: " + error.error().message();
        };
    }
}

switch 式の対象になっているクラスに sealed を指定しているので網羅性のチェックもしてくれます。例えば Error のハンドリングを忘れるとコンパイルエラーにしてくれます。

Scala 等と比べると Java 17 のパターンマッチングは弱いですが、この例程度の用途であれば十分実用的と言えるでしょう。

なお、switch のパターンマッチングはプレビュー機能であるため、コンパイルオプションの --enable-preview で有効にしておく必要があります。 また将来のバージョンで仕様が変更される可能性もあります。 正式導入が待ち遠しいですね。

まとめ

Java 17 で正式導入された interface の sealed と permits を活用して代数的データ型を作り、ドメインサービスとアプリケーションサービスを分離する例を紹介しました。

代数的データ型はパターンマッチと組み合わせると安全に分岐を実現でき非常に強力です。ぜひ Java 17 の新機能を活用して代数的データ型を使ってみてください!

BIGLOBE では、DDD を活用したサービス・アーキテクチャの設計・開発に取り組んでいます。この記事を読んで、より良いDDDについて考えてみたい方、DDD に興味を持ち挑戦したいと思った方、BIGLOBE で一緒に取り組んでみませんか?ぜひカジュアル面談にいらしてください。

hrmos.co

※ Javaは、Oracle Corporationおよびその子会社、関連会社の米国およびその他の国における登録商標です。