LoginSignup
5
9

More than 5 years have passed since last update.

無いってなんだ? 〜3種の「無い」〜

Posted at

無いってなんだ?

とある状況において値が存在しないとか、データベースの参照を行ったのに結果が得られないとか、実務でコードを書いていると沢山の「無い」を考慮する必要があると思います。
だって、nullとかOptionalとか、NullObjectパターンとか、無いものを表現する技法って沢山あるじゃあないですか。

それらの扱いについて思うところをまとめてみたいと思います。

聞き飽きたよ?

確かにOptionalとかEitherとかは大好きですが、今回は技法の話ではありません。

最近は一言で「無い」と言っても、いくらか種類があると感じています。
今回はその「無い」の種類についてまとめてみたいと思います。

メリットは?

あくまで「現時点で僕が考えてみた分類」ですが、それにより各パッケージの責任範囲が明確になります。
また、適切な単体テストを行える設計に自然に近づいたり、ロジックの整理や保守がしやすくなると考えています。

設計のある程度の前提

当然設計は多種多様なのでなんとも言えませんが、ここでは以下の様な大枠を想定しています。
ゆるふわDDD風レイヤー設計?とでも捉えてもらえれば良いのでしょうか...??

名前 概要 備考
api 処理の起点であり、serviceを用いる
URLであったりコマンドラインツールであったりする
今回は登場しない
service domainを扱いシステムが行うべき処理を実現させる
domain 業務ロジックを記載する
repository データベースや外部システムとやりとりする際のインターフェース domainにinterfaceとして配置する
mapper repositoryを実装する 今回は実装しない

では試してみます

例によって都合の良いお題を用意します。
今回はとある通信事業者が固定回線やモバイル回線の契約管理をすると言った感じです。

お題概要

とある会員と契約を管理するシステムを実現します。

会員はモバイル回線と固定回線をそれぞれ最大1つ持つことが出来ます。
(固定回線は今回は登場しません)

モバイル回線には最大1つの音声オプションが付き、
音声オプションには最大1つの留守番電話オプションが付きます。

以上のデータ構成を前提として、以下の要求を実現します。

要求

以下の4つの要求を実現する。
全ての処理は会員IDを入力とする。

  1. モバイル回線を申し込めるかチェック
    • 会員がモバイル回線を契約していなければ、申し込むことが出来る
  2. 留守番電話オプションを解約出来るかチェックする
    • 会員が留守番電話オプションの契約をしていれば、解約することが出来る
    • モバイル回線を契約している会員である前提とする(そういう画面遷移だとでもする)
  3. 留守番電話オプションを解約するために必要になる項目を取得する
    • 留守番電話オプションを契約している会員である前提とする(そういう画面遷移だとでもする)
    • 必要な項目は「モバイル回線ID」「音声オプションID」「留守番電話オプションID」とする
  4. 契約している全ての月額利用料の合計値を参照する
    • モバイル回線は1500円、音声オプションは700円、留守番電話オプションは200円である
    • モバイル回線を契約している会員である前提とする(そういう画面遷移だとでもする)

露骨に「無い」がちりばめられています...

素直な解答例

domain

まずは素直に、モバイル回線 -> 音声オプション -> 留守番電話オプションを全てOptionalで表現します。
(今回は「無い」の技法は別にOptionalでなくても良いのですが、一番楽なので以下全てOptionalで記載します。本質はnullでもほぼ同じです。)

MobileLine.java
@AllArgsConstructor
public class MobileLine {
    @Getter
    private final MobileLineId id;
    @Getter
    private final MonthlyFee fee = new MonthlyFee(1500);
    @Getter
    private final Optional<VoiceOption> voiceOption;
}
VoiceOption.java
@AllArgsConstructor
public class VoiceOption {
    @Getter
    private final VoiceOptionId id;
    @Getter
    private final MonthlyFee fee = new MonthlyFee(700);
    @Getter
    private final Optional<AnswerPhoneOption> answerPhoneOption;
}
AnswerPhoneOption.java
@AllArgsConstructor
public class AnswerPhoneOption {
    @Getter
    private final AnswerPhoneOptionId id;
    @Getter
    private final MonthlyFee fee = new MonthlyFee(200);
}

シンプルですね。
必要になりそうな値段と、最大1つのサブ要素を持っています。
MonthlyFeeXxxIdはただのプリミティブラッパーです)

repository

次はrepositoryです。と言っても先ほどのルートクラスを参照するだけです。

MobileLineRepository.java
public interface MobileLineRepository {
    Optional<MobileLine> findMobileLine(UserId userId);
}

会員はモバイル回線を持っていないかもしれないので、Optionalになっています。
外部キー参照のイメージです。

service

最後にserviceですが、ここはつなぎ合わせるだけです。

MobileLineService.java
public class MobileLineService {
    private MobileLineRepository repository;

    // 1. モバイル回線を申し込めるかチェック
    public boolean checkMobileLineApplicable(UserId userId) {
        Optional<MobileLine> mobileLineOptional = repository.findMobileLine(userId);

        return !mobileLineOptional.isPresent();
    }

    // 2. 留守番電話オプションを解約出来るかチェックする
    public boolean checkAnswerPhoneCancellable(UserId userId) {
        Optional<MobileLine> mobileLineOptional = repository.findMobileLine(userId);

        MobileLine mobileLine = mobileLineOptional
                .orElseThrow(() -> new RuntimeException("モバイル回線が見つかりません"));

        if (mobileLine.getVoiceOption().isPresent()) {
            return mobileLine.getVoiceOption().get().getAnswerPhoneOption().isPresent();
        } else {
            return false;
        }
    }

    // 3. 留守番電話オプションを解約するために必要になる項目を取得する
    public AnswerPhoneOptionCancellation getAnswerPhoneOptionIdForCancellation(UserId userId) {
        Optional<MobileLine> mobileLineOptional = repository.findMobileLine(userId);

        MobileLine mobileLine = mobileLineOptional
                .orElseThrow(() -> new RuntimeException("モバイル回線が見つかりません"));

        VoiceOption voiceOption = mobileLine.getVoiceOption()
                .orElseThrow(() -> new RuntimeException("音声オプションが見つかりません"));

        AnswerPhoneOption answerPhoneOption = voiceOption.getAnswerPhoneOption()
                .orElseThrow(() -> new RuntimeException("留守番電話オプションが見つかりません"));

        return new AnswerPhoneOptionCancellation(
                mobileLine.getId(),
                voiceOption.getId(),
                answerPhoneOption.getId()
        );
    }

    // 4. 契約している全ての月額利用料の合計値を参照する
    public MonthlyFee totalMonthlyFee(UserId userId) {
        Optional<MobileLine> mobileLineOptional = repository.findMobileLine(userId);

        MobileLine mobileLine = mobileLineOptional
                .orElseThrow(() -> new RuntimeException("モバイル回線が見つかりません"));

        if (mobileLine.getVoiceOption().isPresent()) {
            if (mobileLine.getVoiceOption().get().getAnswerPhoneOption().isPresent()) {
                return MonthlyFee.sum(
                        mobileLine.getFee(),
                        mobileLine.getVoiceOption().get().getFee(),
                        mobileLine.getVoiceOption().get().getAnswerPhoneOption().get().getFee()
                );
            } else {
                return MonthlyFee.sum(
                        mobileLine.getFee(),
                        mobileLine.getVoiceOption().get().getFee()
                );
            }
        } else {
            return mobileLine.getFee();
        }
    }
}

出来ましたね。
(もう少しMobileLineにコードを移しても良いですが、処理の全体像を把握しやすくするためにサービスにベタ書いてます)

これを見て「ステキコード!」と感じる人はあんまりいないんじゃあないかと思います。
ただ、じゃあ何が悪いか、どうしたら良いかがピンと来ない、という人もいるかも知れません。

ですのでここからは、お題の解釈とコードの解説をしつつ、このコードを改善してみたいと思います。

3種の「無い」

少し話が飛躍しますが、1-2年コードを書いていて最近強く思う事があります。

それは「無い」には3種類ある!という事です。
上のお題とコードで、それぞれについて考えてみます。

A「無い」でも正常

ひとつめはこれです。

今回の例で言うと、「1. モバイル回線を申し込めるかチェック」のモバイル回線と、
「4. 契約している全ての月額利用料の合計値を参照する」の音声オプションと留守番電話オプションの事です。

これらは無いなら無いで、正常系の1パターンとして扱われます。
基本的にこのケースは例外は出ません

B「無い」のもあり得るけどエラー

このケースは、上の言い方に合わせるならエラー系の1パターンに相当します。
ですが一口でエラーと言ってしまうと後述する3種目と区別が付かないので、サービス仕様上想定されるエラーという意味でビジネスエラーと言うことにします。

サービスエラーとかでも良いのかも知れません。要は身内で齟齬がなければ良いと思います。
僕はビジネスロジックで検知するべきエラーなのでビジネスエラーと言っています。

このエラーはロジックを書いて検知やガードをします。
設計に依るかも知れないので断言は出来ませんが、このケースも例外は出ません。

今回の例で言うと「2. 留守番電話オプションを解約出来るかチェックする」の留守番電話オプションが相当します。
留守番電話オプションがあった場合は「正常」で、無かった場合は「他の数ある解約出来ない理由の1つ」です。

A と B の違い

違いは、後続処理の違い等です。

A はあってもなくても正常系ですので、フローに違いはありません。
対して B はあれば○○する、なければ代わりに××をする、とかなければ〜〜までスキップする、の様にフローが変わります。

B はフロー図のyes / noで分岐をするところのイメージです。

C「無い」のはあり得ない

最後はサービス仕様上想定(定義)されていないエラーです。これはほぼシステム都合で起きるのでシステムエラーと僕は呼んでいます。

例えば DB の接続に失敗するとか、外部システムの呼び出しでタイムアウトするとか、DB ぶっ壊れてあるはずの値が参照出来ないとか、です。
二重サブミットや誤操作によるデータ破損・損失等もあるでしょう。

一口で言うとサービスが正常に稼働していて想定内の使い方をした場合は起きない様なエラーです。

このケースは例外を用いるのが適切でしょう。いちいち捕まえたりもしません。

今回の例だと「2. 留守番電話オプションを解約出来るかチェックする」のモバイル回線と
「3. 留守番電話オプションを解約するために必要になる項目を取得する」のモバイル回線と音声オプションと留守番電話オプション、
「4. 契約している全ての月額利用料の合計値を参照する」のモバイル回線が相当します。

例えば 2. においてモバイル回線は(お題的に)ある前提なので、これが無いのはもう想定外です。何が起きたかわかりません。
(ある前提と言うのは、例えばモバイル回線がある人にしか遷移出来ない画面から呼ばれる処理、みたいな場合です。珍しくはないですよね?)

B と C の違い

C は B と違ってフロー図にいちいちyes / noで登場しません。

例えば「状態不整合が発生している yes / no」とか「データベースに接続失敗した yes / no」とか、いちいちサービス仕様フロー図に明記しませんよね?

また、B と違って C は後続処理が出来ない場合が多いです。
モバイル回線の参照に失敗したのに、音声オプションがあるかチェックしたって意味ないですよね?
発生しちゃったらどうにもならない、って場合が多いです。

他には B は再現するけど C は再現しない、ということもあります。

B は例えば「留守番電話オプションを持ってない人は何度解約出来るかチェックしても同じ結果になります」が、
C は「アタックを受けていて高負荷でデータベースに接続失敗したけど、対処して再申込したら無事成功した」みたいな事があります。

ではどうすれば

ただ単に「音声オプションの有無」と言っても、その時その時で扱い方が違いうことがわかりました。

ですので今回は、思い切って「その時ごとに違うクラスにしちゃう」方針をとってみたいと思います。

4つの要件を整理

「無い」と必要な情報を整理してみます。

1. モバイル回線を申し込めるかチェック

  • モバイル回線: A「無い」でも正常
  • 必要な情報
    • 特になし

計算等は何もしないので、本当にあるかないかだけ分かれば十分です。

2. 留守番電話オプションを解約出来るかチェックする

  • モバイル回線: C「無い」のはあり得ない
  • 音声オプション: B「無い」のもあり得るけどエラー
  • 留守番電話オプション: B「無い」のもあり得るけどエラー
  • 必要な情報
    • 特になし

ここでも特に計算等が無いので、必要な値はなさそうです。

3. 留守番電話オプションを解約するために必要になる項目を取得する

  • モバイル回線: C「無い」のはあり得ない
  • 音声オプション: C「無い」のはあり得ない
  • 留守番電話オプション: C「無い」のはあり得ない
  • 必要な情報
    • モバイル回線ID
    • 音声オプションID
    • 留守番電話オプションID

ここは全てがある前提で、全ての要素のIDが必要です。

4. 契約している全ての月額利用料の合計値を参照する

  • モバイル回線: C「無い」のはあり得ない
  • 音声オプション: A「無い」でも正常
  • 留守番電話オプション: A「無い」でも正常
  • 必要な情報
    • モバイル回線の月額料金
    • 音声オプションの月額月額料金
    • 留守番電話オプションの月額月額料金

ここではモバイル回線より下はなくても正常で、ある場合は要素の値段が必要です。

最初のクラスと比べる

こうして整理してみると、実は最初に作ったMobileLineって、4つの要件の和集合みたいな形をしていますね。

MobileLine.java
@AllArgsConstructor
public class MobileLine {
    @Getter
    private final MobileLineId id;
    @Getter
    private final MonthlyFee fee = new MonthlyFee(1500);
    @Getter
    private final Optional<VoiceOption> voiceOption;
}

ですが、実はIDと月額料金は必要な場合の方が少ないし、VoiceOptionOptionalであるとも限らないことが分かりました。

確かに全ての要素を持ち、無い可能性を考慮してOptionalにしておけば、全ての要件に対応できます。
が、実はこれあんまりメリットはなくて辛さばっかり増すんです。(それについては最後に触れます)

ですのでここからは「ギリギリまで最低要素に絞ったクラス」を紹介します。

クラスを一新!

クラス数はとても多くなってしまいますが、思い切ることにしたので大量のクラスを作ります。

1. モバイル回線を申し込めるかチェック

クラス不要。存在有無のbooleanで十分。

2. 留守番電話オプションを解約出来るかチェックする

MobileLineForAnswerPhoneCancellableCheck.java
@AllArgsConstructor
public class MobileLineForAnswerPhoneCancellableCheck {
    @Getter
    private final Optional<VoiceOptionForAnswerPhoneCancellableCheck> voiceOption;
}
VoiceOptionForAnswerPhoneCancellableCheck.java
@AllArgsConstructor
public class VoiceOptionForAnswerPhoneCancellableCheck {
    @Getter
    private final Optional<AnswerPhoneOptionForAnswerPhoneCancellableCheck> answerPhoneOption;
}
AnswerPhoneOptionForAnswerPhoneCancellableCheck.java
@AllArgsConstructor
public class AnswerPhoneOptionForAnswerPhoneCancellableCheck {
}

現状の仕様だと有無だけで申込可否が判断できるので、一切の値がありませんね。
(少し無駄な感じがしますが、実際は各種ステータスとか契約継続年数とか色々値も必要になるでしょうし、そういう妄想で脳内補完してもらえればと思います。)

3. 留守番電話オプションを解約するために必要になる項目を取得する

MobileLineForAnswerPhoneCancellation.java
@AllArgsConstructor
public class MobileLineForAnswerPhoneCancellation {
    @Getter
    private final MobileLineId id;
    @Getter
    private final VoiceOptionForAnswerPhoneCancellation voiceOption;
}
VoiceOptionForAnswerPhoneCancellation.java
@AllArgsConstructor
public class VoiceOptionForAnswerPhoneCancellation {
    @Getter
    private final VoiceOptionId id;
    @Getter
    private final AnswerPhoneOptionForAnswerPhoneCancellation answerPhoneOption;
}
AnswerPhoneOptionForAnswerPhoneCancellation.java
@AllArgsConstructor
public class AnswerPhoneOptionForAnswerPhoneCancellation {
    @Getter
    private final AnswerPhoneOptionId id;
}

これは今までと雰囲気が違います。
この要件でだけ必要になるIDを持っていて、Optionalは全て外れています。

4. 契約している全ての月額利用料の合計値を参照する

MobileLineForTotalMonthlyFee.java
@AllArgsConstructor
public class MobileLineForTotalMonthlyFee {
    @Getter
    private final MonthlyFee fee = new MonthlyFee(1500);
    @Getter
    private final Optional<VoiceOptionForTotalMonthlyFee> voiceOption;
}
VoiceOptionForTotalMonthlyFee.java
@AllArgsConstructor
public class VoiceOptionForTotalMonthlyFee {
    @Getter
    private final MonthlyFee fee = new MonthlyFee(700);
    @Getter
    private final Optional<AnswerPhoneOptionForTotalMonthlyFee> answerPhoneOption;
}
AnswerPhoneOptionForTotalMonthlyFee.java
@AllArgsConstructor
public class AnswerPhoneOptionForTotalMonthlyFee {
    @Getter
    private final MonthlyFee fee = new MonthlyFee(200);
}

これも、この要件でだけ必要になる月額料金を持っていて、下位の要素をOptionalで持っています。

リポジトリ

返すクラスが変わったので、リポジトリのメソッドも変わります。

MobileLineRepository.java
public interface MobileLineRepository {
    boolean isMobileLineApplicable(UserId userId); // クラスを返してもらう必要はない

    MobileLineForAnswerPhoneCancellableCheck findForCancellable(UserId userId);

    MobileLineForAnswerPhoneCancellation findForCancel(UserId userId);

    MobileLineForTotalMonthlyFee findForTotalMonthlyFee(UserId userId);
}

Optionalがなくなりましたね!

ここや、例えばMobileLineForAnswerPhoneCancellation.javaから消えたOptionalはどこへ行ったかと言うと、
代わりにどこかへ移動したのではなくて、本当になくなりました。

Repositoryの実装クラスの中で、状態不整合が発生していたら例外にしてしまいます。
サービス層で.orElseThrow()をやっていた部分を、Optionalに包む前に不整合をチェックして発生していたら例外にしてしまいます。

どうせ後で必ずget()出来なければいけないのですから、それが出来ないとわかった段階で例外にしてしまいましょう。
そうすればdomainOptionalで保持しておいて後でチェックする必要はないのです。

サービス

ここまで揃えば簡単です。
そもそも一番簡単なのがserviceなのでは、とすら思います。

ここまでで一生懸命作ったdomainを使うだけですからね。

MobileLineService.java
public class MobileLineService {
    private MobileLineRepository repository;

    // 1. モバイル回線を申し込めるかチェック
    public boolean checkMobileLineApplicable(UserId userId) {
        return repository.isMobileLineApplicable(userId);
    }

    // 2. 留守番電話オプションを解約出来るかチェックする
    public boolean checkAnswerPhoneCancellable(UserId userId) {
        MobileLineForAnswerPhoneCancellableCheck mobileLine = repository.findForCancellable(userId);

        return mobileLine.getVoiceOption().map(it -> it.getAnswerPhoneOption().isPresent()).orElse(false); // map orElse でちょっと工夫
    }

    // 3. 留守番電話オプションを解約するために必要になる項目を取得する
    public AnswerPhoneOptionCancellation cancelAnswerPhoneOption(UserId userId) {
        MobileLineForAnswerPhoneCancellation mobileLine = repository.findForCancel(userId);

        return new AnswerPhoneOptionCancellation(
                mobileLine.getId(),                                    // isPresent のチェックは不要
                mobileLine.getVoiceOption().getId(),                   // repository が例外を出さなかったことで値があることが確定している
                mobileLine.getVoiceOption().getAnswerPhoneOption().getId()
        );
    }

    // 4. 契約している全ての月額利用料の合計値を参照する
    public MonthlyFee totalMonthlyFee(UserId userId) {
        MobileLineForTotalMonthlyFee mobileLine = repository.findForTotalMonthlyFee(userId);

        return MonthlyFee.sum(
                mobileLine.getFee(),                                                          // map orElse でちょっと工夫
                mobileLine.getVoiceOption().map(it -> it.getFee()).orElse(MonthlyFee.zero()), // 0円として素直に加算してしまうとちょっと楽かも
                mobileLine.getVoiceOption().flatMap(it -> it.getAnswerPhoneOption()).map(it -> it.getFee()).orElse(MonthlyFee.zero())
        );
    }
}

最初のMobileLineServiceとの大きな違いは、「システムエラーのチェックをしていない」ことです。
「要素のあるはずないはず」はrepositoryの返してくれたクラスを信じています。

ですので、要件のために成さねばならない計算にだけ集中出来ています。

実は、もうちょっとだけ...

ここまで来たので、折角なのでもう1つだけ、直したいことが、ある、んです。

ですので、要件のために成さねばならない計算にだけ集中出来ています。

これ、つまり業務ロジックというやつです。
「サービス仕様を満たすための必要な計算」と考えるとしっくり来るかも知れません。

計算のテスト、したいですよね?
したいはずです。しないなんてあり得ないですよね?
はい、したいですね。

こことか!

return mobileLine.getVoiceOption().map(it -> it.getAnswerPhoneOption().isPresent()).orElse(false);

こことか!

return MonthlyFee.sum(
        mobileLine.getFee(),
        mobileLine.getVoiceOption().map(it -> it.getFee()).orElse(MonthlyFee.zero()),
        mobileLine.getVoiceOption().flatMap(it -> it.getAnswerPhoneOption()).map(it -> it.getFee()).orElse(MonthlyFee.zero())
);

動作確認せずにデプロイする自信ありますか?絶対大丈夫?

全てのパターンをserviceのテストで通そうと思ったら、repositoryの返却値を沢山用意しないといけません。

それはモック化だったり、データベースへのダミーデータの登録だったりしますが、そんなことやってられませんよ。

そこで注目するのが、最初のレイヤー説明の表の以下の記載です。

domain 業務ロジックを記載する

またクラスを一新!!

もう一回だけ掲載します。
これで最後の改善です。

2. 留守番電話オプションを解約出来るかチェックする

MobileLineForAnswerPhoneCancellableCheck.java
@AllArgsConstructor
public class MobileLineForAnswerPhoneCancellableCheck {
    private final Optional<VoiceOptionForAnswerPhoneCancellableCheck> voiceOption;

    public boolean isAnswerPhoneCancellable() {
        return voiceOption.map(it -> it.isAnswerPhoneCancellable()).orElse(false);
    }
}
VoiceOptionForAnswerPhoneCancellableCheck.java
@AllArgsConstructor
public class VoiceOptionForAnswerPhoneCancellableCheck {
    private final Optional<AnswerPhoneOptionForAnswerPhoneCancellableCheck> answerPhoneOption;

    public boolean isAnswerPhoneCancellable() {
        return answerPhoneOption.isPresent();
    }
}
AnswerPhoneOptionForAnswerPhoneCancellableCheck.java
public class AnswerPhoneOptionForAnswerPhoneCancellableCheck {
}

ルートクラスに「申し込める?」って聞く様にします。

3. 留守番電話オプションを解約するために必要になる項目を取得する

MobileLineForAnswerPhoneCancellation.java
@AllArgsConstructor
public class MobileLineForAnswerPhoneCancellation {
    private final MobileLineId mobileLineId;
    private final VoiceOptionId voiceOptionId;
    private final AnswerPhoneOptionId answerPhoneOptionId;

    public AnswerPhoneOptionCancellation cancel() {
        return new AnswerPhoneOptionCancellation(
                mobileLineId,
                voiceOptionId,
                answerPhoneOptionId
        );
    }
}

ここは思い切ってネスト構造をやめて、フラットな1クラスにしてみました。
どうせ全部必要になるんですし。

「キャンセルに必要な情報の素材集」と言ったところです。
ルートクラスに「キャンセルに必要な情報を作って」と言うと、素材を使って組み立てます。
素材が具体的に何かは外側には見えません。カプセル化されていますね。

4. 契約している全ての月額利用料の合計値を参照する

MobileLineForTotalMonthlyFee.java
@AllArgsConstructor
public class MobileLineForTotalMonthlyFee {
    private final MonthlyFee fee = new MonthlyFee(1500);
    private final Optional<VoiceOptionForTotalMonthlyFee> voiceOption;

    public MonthlyFee getTotalMonthlyFee() {
        return MonthlyFee.sum(
                fee,
                voiceOption.map(it -> it.voiceOptionAndAnswerPhoneOptionFee()).orElse(MonthlyFee.zero())
        );
    }
}
VoiceOptionForTotalMonthlyFee.java
@AllArgsConstructor
public class VoiceOptionForTotalMonthlyFee {
    private final MonthlyFee fee = new MonthlyFee(700);
    private final Optional<AnswerPhoneOptionForTotalMonthlyFee> answerPhoneOption;

    public MonthlyFee voiceOptionAndAnswerPhoneOptionFee() {
        return MonthlyFee.sum(
                fee,
                answerPhoneOption.map(it -> it.answerPhoneOptionFee()).orElse(MonthlyFee.zero())
        );
    }
}
AnswerPhoneOptionForTotalMonthlyFee.java
@AllArgsConstructor
public class AnswerPhoneOptionForTotalMonthlyFee {
    private final MonthlyFee fee = new MonthlyFee(200);

    public MonthlyFee answerPhoneOptionFee() {
        return fee;
    }
}

ここはちょっと迷いましたが、自身以下の値段の総和を返すメソッドをそれぞれ用意して、上位クラスが自身と加算します。
まぁ中の詳細は些細な問題で、ここもルートクラスに「総和出して」と言うだけで済む形になりました。

リポジトリ

リポジトリは返すクラスの要件で分けるのが良いかな、と思っています。
パッケージ整理とかも進めていくとそうなりました。
(最近は。ただまだあんまり自信ないですが。)

ですので、作ったクラスの数だけリポジトリもクラス分割をします。

コードは割愛します。

サービス

MobileLineService.java
public class MobileLineService {
    private MobileLineRepository mobileLineRepository;
    private MobileLineForAnswerPhoneCancellableCheckRepository forAnswerPhoneCancellableCheckRepository;
    private MobileLineForAnswerPhoneCancellationRepository forAnswerPhoneCancellationRepository;
    private MobileLineForTotalMonthlyFeeRepository totalMonthlyFeeRepository;

    // 1. モバイル回線を申し込めるかチェック
    public boolean checkMobileLineApplicable(UserId userId) {
        return mobileLineRepository
                .isMobileLineApplicable(userId);
    }

    // 2. 留守番電話オプションを解約出来るかチェックする
    public boolean checkAnswerPhoneCancellable(UserId userId) {
        return forAnswerPhoneCancellableCheckRepository.find(userId)
                .isAnswerPhoneCancellable();
    }

    // 3. 留守番電話オプションを解約するために必要になる項目を取得する
    public AnswerPhoneOptionCancellation cancelAnswerPhoneOption(UserId userId) {
        return forAnswerPhoneCancellationRepository.find(userId)
                .cancel();
    }

    // 4. 契約している全ての月額利用料の合計値を参照する
    public MonthlyFee totalMonthlyFee(UserId userId) {
        return totalMonthlyFeeRepository.find(userId)
                .getTotalMonthlyFee();
    }
}

repositoryに対してルートクラスを要求して、ルートクラスに計算を要求するだけになりました!!

サービスなんてやることはそんだけです。

計算と処理が分離出来ましたね。

ドメインクラスに移った計算のテストは、データベースのダミーデータ投入なんかしないで手で
直接ルートクラス以下をnewして好きなだけテストできます。

改善の考察

責務

コードの掲載を3回しました。

  • 最初のコード
    • domain: 何でも持ってて全てを考慮している
    • repository: domainへのただのアクセッサ程度
    • service: システムエラーチェック、処理、計算
  • 次のコード
    • domain: 要件に特化し、必要な値を適切な形で持っている
    • repository: システムエラーチェック
    • service: 処理、計算
  • 最後のコード
    • domain: 計算
    • repository: システムエラーチェック
    • service: 処理

こうして見ると、少しずつサービスから責務を分離してドメインまで移動していったことがわかります。
というか最初のサービス、やっぱり頑張りすぎでしたね。

堅牢性

ちょっと違う観点で見てみます

  • 最初のコード
    • get(), orElseThrow()がある
    • ドメインクラスに@Getterがある
  • 次のコード
    • get(), orElseThrow()がなくなる
  • 最後のコード
    • ドメインクラスの@Getterがなくなる

原則として、Optionalget()をしてはいけません。
(詳細は割愛しますが、基本的にはget()した次点で何かの改善案を模索した方が良いです。)

改善していく中で、そのget()orElseThrow()が無くなったので、Optionalまわりで例外が発生することはなくなりました。

また、ルートクラスに命じる形を取ることで@Getterが無くなりました。
これはカプセル化がちゃんと出来たことを意味しています。

最後に作ったクラスは要件毎に分離されているので、とある要件でのみ追加の値が必要になった場合も、そのクラスにしか改修影響が及びません。
サービスクラスは中身の要素を知らないまま、ルートクラスに「〜〜して」って言うだけです。

解約判定に申込日が追加されたとしても、サービスの改修も不要だし、値段計算を念のため再結合評価、とかも不要になってます。

3種の「無い」

正常

例外はない

サービス仕様フロー図に分岐として明記されない
1.png

ビジネスエラー

例外はない

サービス仕様フロー図には分岐として明記される
2.png

発生後の後続処理も仕様に則り正常に処理される

サービス仕様に書かれているので、ビジネスロジックを書くdomainで検知する

データの状態が同じであれば、必ず同じビジネスエラーが発生する

システムエラー

例外を使う

サービス仕様フロー図に分岐として明記されない
3.png

発生後は後続処理を続けられない場合が多い

サービス仕様に書かれていないし例外が絡むので、ビジネスロジックを書くdomainで検知するのではなく、mapperでガードする

データの状態が同じでも、負荷や通信断絶により発生したりしなかったりする
(データ不整合であれば必ず同じ様に発生する)

和集合クラス vs 特化クラス

まず特化クラスの場合、とある要件の改修が別の要件に影響しません。

和集合クラスは特定の要件でしか保持していない可能性がある値は全てOptionalにしてしまいます。(もしくはList

これは例えば「キャンセル時以外ではキャンセル申込日はemptyだけど、キャンセル取り消しをする時はキャンセル申込日に値があるはず」とか
「申込直後は必ず空リストだけど、解約するときは1-3件の要素が入ったリストのはず」の様な大量の前提知識を必要とするOptionalが出来てしまいます。(もしくはList
これは超辛いです。(実話)

申込、変更、解約、それらの取り消し、任意項目等々を考えると、和集合クラスがOptionalまみれになり
orElseThrow("〜〜の場合はあるはずです")まみれになるのが容易に想像できませんか。

例外

僕はdomainでの例外は絶対にするべきでないと思っています。

domainでやるのは計算だから状態不整合とかは論外だし、
ビジネスロジックとして計算している以上エラーも想定内なので値で失敗を表現するべきだと思っているからです。

例外は全部mapperに押しつけちゃいましょう。(やや乱暴)

serviceはどうなんでしょうか。
catchserviceが適切かもしれません。例えば「システムエラーがあったらアラーム出す」とか。

サービスには何が残るか

今回は1ルートクラスしかありませんでしたが、例えば
「留守番電話オプションを契約している」&「留守番電話オプションの未払い金がない」&「契約から2年経っている」の様に
複合条件になる場合は、他のリポジトリとルートクラスも登場します。

それらを扱ったり、それらドメインを全て要求する大きいドメインを扱うのがサービス層の責務だと思ってます。今は。

具体的な計算も末端が考慮するべきシステムエラーもほとんどの事は知らず、ただ「処理順」と「全体の登場人物」を知っているクラス、みたいなものでしょうか。

テスト

最初の例では肝心の計算ロジックのパターン網羅をするために、データベースのダミーデータ投入が必要でした。

最後の例では計算はdomainに切り離されたので、自分で好きな値でnewしてテストをすることが可能です。

総合

冒頭にこう書きました。

あくまで「現時点で僕が考えてみた分類」ですが、それにより各パッケージの責任範囲が明確になります。
また、適切な単体テストを行える設計に自然に近づいたり、ロジックの整理や保守がしやすくなると考えています。

実はこの時点ではあんまり考えてなかったのでうまく話が繋がらなかったら消そうとでも思っていましたが、案外すんなり繋がって安心しました。

システムエラーと処理と計算は各レイヤーに収まりましたし、単体テスト可能になってますし、要件変更が他の要件に影響しなくなってます!

以上

以上です。

ちょっと「無い」を考慮するだけで、結構色々メリットがあることが紹介できたでしょうか。

Optionalは便利だし、何より僕は失敗系のそーゆーのが大好きですが、むやみやたらと何でもOptionalにせず
要求と照らし合わせるとグッと良い設計に自然と近づいていくのではないか、と思っています。

5
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
9