無いってなんだ?
とある状況において値が存在しないとか、データベースの参照を行ったのに結果が得られないとか、実務でコードを書いていると沢山の「無い」を考慮する必要があると思います。
だって、null
とかOptional
とか、NullObjectパターン
とか、無いものを表現する技法って沢山あるじゃあないですか。
それらの扱いについて思うところをまとめてみたいと思います。
聞き飽きたよ?
確かにOptional
とかEither
とかは大好きですが、今回は技法の話ではありません。
最近は一言で「無い」と言っても、いくらか種類があると感じています。
今回はその「無い」の種類についてまとめてみたいと思います。
メリットは?
あくまで「現時点で僕が考えてみた分類」ですが、それにより各パッケージの責任範囲が明確になります。
また、適切な単体テストを行える設計に自然に近づいたり、ロジックの整理や保守がしやすくなると考えています。
設計のある程度の前提
当然設計は多種多様なのでなんとも言えませんが、ここでは以下の様な大枠を想定しています。
ゆるふわDDD風レイヤー設計?
とでも捉えてもらえれば良いのでしょうか...??
名前 | 概要 | 備考 |
---|---|---|
api | 処理の起点であり、serviceを用いる URLであったりコマンドラインツールであったりする |
今回は登場しない |
service | domainを扱いシステムが行うべき処理を実現させる | |
domain | 業務ロジックを記載する | |
repository | データベースや外部システムとやりとりする際のインターフェース | domainにinterfaceとして配置する |
mapper | repositoryを実装する | 今回は実装しない |
では試してみます
例によって都合の良いお題を用意します。
今回はとある通信事業者が固定回線やモバイル回線の契約管理をすると言った感じです。
お題概要
とある会員と契約を管理するシステムを実現します。
会員はモバイル回線と固定回線をそれぞれ最大1つ持つことが出来ます。
(固定回線は今回は登場しません)
モバイル回線には最大1つの音声オプションが付き、
音声オプションには最大1つの留守番電話オプションが付きます。
以上のデータ構成を前提として、以下の要求を実現します。
要求
以下の4つの要求を実現する。
全ての処理は会員ID
を入力とする。
- モバイル回線を申し込めるかチェック
- 会員がモバイル回線を契約していなければ、申し込むことが出来る
- 留守番電話オプションを解約出来るかチェックする
- 会員が留守番電話オプションの契約をしていれば、解約することが出来る
- モバイル回線を契約している会員である前提とする(そういう画面遷移だとでもする)
- 留守番電話オプションを解約するために必要になる項目を取得する
- 留守番電話オプションを契約している会員である前提とする(そういう画面遷移だとでもする)
- 必要な項目は「モバイル回線ID」「音声オプションID」「留守番電話オプションID」とする
- 契約している全ての月額利用料の合計値を参照する
- モバイル回線は1500円、音声オプションは700円、留守番電話オプションは200円である
- モバイル回線を契約している会員である前提とする(そういう画面遷移だとでもする)
露骨に「無い」がちりばめられています...
素直な解答例
domain
まずは素直に、モバイル回線 -> 音声オプション -> 留守番電話オプション
を全てOptional
で表現します。
(今回は「無い」の技法は別にOptional
でなくても良いのですが、一番楽なので以下全てOptional
で記載します。本質はnull
でもほぼ同じです。)
@AllArgsConstructor
public class MobileLine {
@Getter
private final MobileLineId id;
@Getter
private final MonthlyFee fee = new MonthlyFee(1500);
@Getter
private final Optional<VoiceOption> voiceOption;
}
@AllArgsConstructor
public class VoiceOption {
@Getter
private final VoiceOptionId id;
@Getter
private final MonthlyFee fee = new MonthlyFee(700);
@Getter
private final Optional<AnswerPhoneOption> answerPhoneOption;
}
@AllArgsConstructor
public class AnswerPhoneOption {
@Getter
private final AnswerPhoneOptionId id;
@Getter
private final MonthlyFee fee = new MonthlyFee(200);
}
シンプルですね。
必要になりそうな値段と、最大1つのサブ要素を持っています。
(MonthlyFee
やXxxId
はただのプリミティブラッパーです)
repository
次はrepository
です。と言っても先ほどのルートクラスを参照するだけです。
public interface MobileLineRepository {
Optional<MobileLine> findMobileLine(UserId userId);
}
会員はモバイル回線を持っていないかもしれないので、Optional
になっています。
外部キー参照のイメージです。
service
最後にservice
ですが、ここはつなぎ合わせるだけです。
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つの要件の和集合みたいな形をしていますね。
@AllArgsConstructor
public class MobileLine {
@Getter
private final MobileLineId id;
@Getter
private final MonthlyFee fee = new MonthlyFee(1500);
@Getter
private final Optional<VoiceOption> voiceOption;
}
ですが、実はID
と月額料金は必要な場合の方が少ないし、VoiceOption
がOptional
であるとも限らないことが分かりました。
確かに全ての要素を持ち、無い可能性を考慮してOptional
にしておけば、全ての要件に対応できます。
が、実はこれあんまりメリットはなくて辛さばっかり増すんです。(それについては最後に触れます)
ですのでここからは「ギリギリまで最低要素に絞ったクラス」を紹介します。
クラスを一新!
クラス数はとても多くなってしまいますが、思い切ることにしたので大量のクラスを作ります。
1. モバイル回線を申し込めるかチェック
クラス不要。存在有無のboolean
で十分。
2. 留守番電話オプションを解約出来るかチェックする
@AllArgsConstructor
public class MobileLineForAnswerPhoneCancellableCheck {
@Getter
private final Optional<VoiceOptionForAnswerPhoneCancellableCheck> voiceOption;
}
@AllArgsConstructor
public class VoiceOptionForAnswerPhoneCancellableCheck {
@Getter
private final Optional<AnswerPhoneOptionForAnswerPhoneCancellableCheck> answerPhoneOption;
}
@AllArgsConstructor
public class AnswerPhoneOptionForAnswerPhoneCancellableCheck {
}
現状の仕様だと有無だけで申込可否が判断できるので、一切の値がありませんね。
(少し無駄な感じがしますが、実際は各種ステータスとか契約継続年数とか色々値も必要になるでしょうし、そういう妄想で脳内補完してもらえればと思います。)
3. 留守番電話オプションを解約するために必要になる項目を取得する
@AllArgsConstructor
public class MobileLineForAnswerPhoneCancellation {
@Getter
private final MobileLineId id;
@Getter
private final VoiceOptionForAnswerPhoneCancellation voiceOption;
}
@AllArgsConstructor
public class VoiceOptionForAnswerPhoneCancellation {
@Getter
private final VoiceOptionId id;
@Getter
private final AnswerPhoneOptionForAnswerPhoneCancellation answerPhoneOption;
}
@AllArgsConstructor
public class AnswerPhoneOptionForAnswerPhoneCancellation {
@Getter
private final AnswerPhoneOptionId id;
}
これは今までと雰囲気が違います。
この要件でだけ必要になるID
を持っていて、Optional
は全て外れています。
4. 契約している全ての月額利用料の合計値を参照する
@AllArgsConstructor
public class MobileLineForTotalMonthlyFee {
@Getter
private final MonthlyFee fee = new MonthlyFee(1500);
@Getter
private final Optional<VoiceOptionForTotalMonthlyFee> voiceOption;
}
@AllArgsConstructor
public class VoiceOptionForTotalMonthlyFee {
@Getter
private final MonthlyFee fee = new MonthlyFee(700);
@Getter
private final Optional<AnswerPhoneOptionForTotalMonthlyFee> answerPhoneOption;
}
@AllArgsConstructor
public class AnswerPhoneOptionForTotalMonthlyFee {
@Getter
private final MonthlyFee fee = new MonthlyFee(200);
}
これも、この要件でだけ必要になる月額料金を持っていて、下位の要素をOptional
で持っています。
リポジトリ
返すクラスが変わったので、リポジトリのメソッドも変わります。
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()
出来なければいけないのですから、それが出来ないとわかった段階で例外にしてしまいましょう。
そうすればdomain
でOptional
で保持しておいて後でチェックする必要はないのです。
サービス
ここまで揃えば簡単です。
そもそも一番簡単なのがservice
なのでは、とすら思います。
ここまでで一生懸命作ったdomain
を使うだけですからね。
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. 留守番電話オプションを解約出来るかチェックする
@AllArgsConstructor
public class MobileLineForAnswerPhoneCancellableCheck {
private final Optional<VoiceOptionForAnswerPhoneCancellableCheck> voiceOption;
public boolean isAnswerPhoneCancellable() {
return voiceOption.map(it -> it.isAnswerPhoneCancellable()).orElse(false);
}
}
@AllArgsConstructor
public class VoiceOptionForAnswerPhoneCancellableCheck {
private final Optional<AnswerPhoneOptionForAnswerPhoneCancellableCheck> answerPhoneOption;
public boolean isAnswerPhoneCancellable() {
return answerPhoneOption.isPresent();
}
}
public class AnswerPhoneOptionForAnswerPhoneCancellableCheck {
}
ルートクラスに「申し込める?」って聞く様にします。
3. 留守番電話オプションを解約するために必要になる項目を取得する
@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. 契約している全ての月額利用料の合計値を参照する
@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())
);
}
}
@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())
);
}
}
@AllArgsConstructor
public class AnswerPhoneOptionForTotalMonthlyFee {
private final MonthlyFee fee = new MonthlyFee(200);
public MonthlyFee answerPhoneOptionFee() {
return fee;
}
}
ここはちょっと迷いましたが、自身以下の値段の総和を返すメソッドをそれぞれ用意して、上位クラスが自身と加算します。
まぁ中の詳細は些細な問題で、ここもルートクラスに「総和出して」と言うだけで済む形になりました。
リポジトリ
リポジトリは返すクラスの要件で分けるのが良いかな、と思っています。
パッケージ整理とかも進めていくとそうなりました。
(最近は。ただまだあんまり自信ないですが。)
ですので、作ったクラスの数だけリポジトリもクラス分割をします。
コードは割愛します。
サービス
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
がなくなる
- ドメインクラスの
原則として、Optional
はget()
をしてはいけません。
(詳細は割愛しますが、基本的にはget()
した次点で何かの改善案を模索した方が良いです。)
改善していく中で、そのget()
とorElseThrow()
が無くなったので、Optional
まわりで例外が発生することはなくなりました。
また、ルートクラスに命じる形を取ることで@Getter
が無くなりました。
これはカプセル化がちゃんと出来たことを意味しています。
最後に作ったクラスは要件毎に分離されているので、とある要件でのみ追加の値が必要になった場合も、そのクラスにしか改修影響が及びません。
サービスクラスは中身の要素を知らないまま、ルートクラスに「〜〜して」って言うだけです。
解約判定に申込日が追加されたとしても、サービスの改修も不要だし、値段計算を念のため再結合評価、とかも不要になってます。
3種の「無い」
正常
例外はない
ビジネスエラー
例外はない
発生後の後続処理も仕様に則り正常に処理される
サービス仕様に書かれているので、ビジネスロジックを書くdomain
で検知する
データの状態が同じであれば、必ず同じビジネスエラーが発生する
システムエラー
例外を使う
発生後は後続処理を続けられない場合が多い
サービス仕様に書かれていないし例外が絡むので、ビジネスロジックを書くdomain
で検知するのではなく、mapper
でガードする
データの状態が同じでも、負荷や通信断絶により発生したりしなかったりする
(データ不整合であれば必ず同じ様に発生する)
和集合クラス vs 特化クラス
まず特化クラスの場合、とある要件の改修が別の要件に影響しません。
和集合クラスは特定の要件でしか保持していない可能性がある値は全てOptional
にしてしまいます。(もしくはList
)
これは例えば「キャンセル時以外ではキャンセル申込日はempty
だけど、キャンセル取り消しをする時はキャンセル申込日に値があるはず」とか
「申込直後は必ず空リストだけど、解約するときは1-3件の要素が入ったリストのはず」の様な大量の前提知識を必要とするOptional
が出来てしまいます。(もしくはList
)
これは超辛いです。(実話)
申込、変更、解約、それらの取り消し、任意項目等々を考えると、和集合クラスがOptional
まみれになり
orElseThrow("〜〜の場合はあるはずです")
まみれになるのが容易に想像できませんか。
例外
僕はdomain
での例外は絶対にするべきでないと思っています。
domain
でやるのは計算だから状態不整合とかは論外だし、
ビジネスロジックとして計算している以上エラーも想定内なので値で失敗を表現するべきだと思っているからです。
例外は全部mapper
に押しつけちゃいましょう。(やや乱暴)
service
はどうなんでしょうか。
catch
はservice
が適切かもしれません。例えば「システムエラーがあったらアラーム出す」とか。
サービスには何が残るか
今回は1ルートクラスしかありませんでしたが、例えば
「留守番電話オプションを契約している」&「留守番電話オプションの未払い金がない」&「契約から2年経っている」の様に
複合条件になる場合は、他のリポジトリとルートクラスも登場します。
それらを扱ったり、それらドメインを全て要求する大きいドメインを扱うのがサービス層の責務だと思ってます。今は。
具体的な計算も末端が考慮するべきシステムエラーもほとんどの事は知らず、ただ「処理順」と「全体の登場人物」を知っているクラス、みたいなものでしょうか。
テスト
最初の例では肝心の計算ロジックのパターン網羅をするために、データベースのダミーデータ投入が必要でした。
最後の例では計算はdomain
に切り離されたので、自分で好きな値でnew
してテストをすることが可能です。
総合
冒頭にこう書きました。
あくまで「現時点で僕が考えてみた分類」ですが、それにより各パッケージの責任範囲が明確になります。
また、適切な単体テストを行える設計に自然に近づいたり、ロジックの整理や保守がしやすくなると考えています。
実はこの時点ではあんまり考えてなかったのでうまく話が繋がらなかったら消そうとでも思っていましたが、案外すんなり繋がって安心しました。
システムエラーと処理と計算は各レイヤーに収まりましたし、単体テスト可能になってますし、要件変更が他の要件に影響しなくなってます!
以上
以上です。
ちょっと「無い」を考慮するだけで、結構色々メリットがあることが紹介できたでしょうか。
Optional
は便利だし、何より僕は失敗系のそーゆーのが大好きですが、むやみやたらと何でもOptional
にせず
要求と照らし合わせるとグッと良い設計に自然と近づいていくのではないか、と思っています。