ユーザの入力値をServiceクラスに渡すときに考えたこと
結論
入力値を受け取るFormクラスとEntityの変換がしんどいので、解決策を4つ考えました。
- 入力値の一つ一つをServiceのメソッドの引数にする
- 意味のある単位でオブジェクトにする
- ServiceのメソッドをFormクラスにしてしまう
- Serviceの引数をインターフェースにする
使えそうな解決策は2と4かなと思っています。今回は社内の文化も踏まえて4で解決しました。
環境
- Java 11
- SpringBoot
- Gradle
パッケージ
Webモジュール
└controller
└form
└service
前提:入力値を受け取るFormクラスとEntityの変換がしんどい
以下のようにFormクラス内で永続化するためのEntityとの変換をしていました。
@Data
public class PurchaseForm {
@NotNull
private Integer commodityId;
@NotNull
private Integer count;
/**
* 入力値を元にEntityを生成をする
*/
public Purchase toEntity() {
// 省略
}
}
シンプルな変換であれば全く問題がないのですが、以下のようなパターンだと死ねる。
変換対象のクラスが複雑なクラスの構造のとき
Entityに変換するためのプログラムのコード量が増えると、ソースコードの見通しが悪くなる。特に生成対象のクラスの子クラスまで生成しようとするとかなり苦しいです。わたしの関わるプロジェクトでは主キーの発番をAUTO INCREMENTに頼ることが多いので、必然的に主キーと外部キーをServiceクラスなどで補う必要があります。Formで一部を初期化して残りをServiceクラスで初期化するなど、一つの関心事が複数のクラスに分断され、追いづらいソースコードになってしまいます。
@Data
public class PurchaseForm {
// 省略
/**
* 入力値を元にEntityを生成をする
*/
public Purchase toEntity() {
// こ
// こ
// が
// め
// っ
// ち
// ゃ
// 長
// い
// と
// 結
// 構
// つ
// ら
// い
}
}
Entity生成に必要な引数が多いと苦しい
ここまで引数に永続化されたオブジェクトが必要なら、もはやFormクラスで変換をすべきでないのでは。
@Data
public class PurchaseForm {
// 省略
/**
* 入力値を元にEntityを生成をする
* 大量の永続化されたオブジェクトを引数にとるなら、ここでEntityを生成する必要があるのか疑問である。
*/
public Purchase toEntity(Hoge hoge, Fuga Futa, ... etc) {
// 省略
}
}
考えうる解決策
1. 入力値の一つ一つをServiceのメソッドの引数にする
これ試したけど苦しい。なぜならFormの入力値が増えれば増えるほど引数が増えてしまうから。
@Controller
@RequireArgsConstructor
public class PurchaseController {
public final PurchaseService purchaseService;
public ModelAndView create(@ModelAttribute @Valid PurchaseForm form, BindingResult result) {
// check
// Ah...!
purchaseService.purchase(form.input1, form.input2, form.input3, form.input4, form.input5,...);
}
}
2. 意味のある単位でオブジェクトにする
@Controller
@RequireArgsConstructor
public class PurchaseController {
public final PurchaseService purchaseService;
public ModelAndView create(@ModelAttribute @Valid PurchaseForm form, BindingResult result) {
// check
purchaseService.purchase(new Hoge(form.input1, form.input2), new Fuga(form.input3, form.input4, form.input5),...);
}
}
これの利点は引数のオブジェクトに対して事前条件を設定して値の整合性を保証できることです。事前条件はコンストラクタでチェックします。
3. ServiceのメソッドをFormクラスにしてしまう
めっちゃシンプルなんだけどめっちゃダメパターンきた!依存の方向がForm←Serviceになってしまうのが苦しい。Formは画面の仕様と密結合なので業務ロジック側がFormに依存するのは避けたいです。業務ロジックが変わっていないのに画面仕様が変わったせいで、Serviceクラスを変更しなくてはならないのはおかしいです。
@Controller
@RequireArgsConstructor
public class PurchaseController {
public final PurchaseService purchaseService;
public ModelAndView create(@ModelAttribute @Valid PurchaseForm form, BindingResult result) {
// check
// これはひどい
purchaseService.purchase(form);
}
}
これを4番目の案で解決しました。
4. Serviceの引数をインターフェースにする
まずドメインに近いパッケージでServiceと引数のinterfaceを定義しました。
@Service
public class PurchaseService {
public void purchase(PurchaseRequest request) {}
}
public interface PurchaseRequest {
String getInput1();
Integer getInput2();
// etc...
}
その上で値のバリデーションは別の実装クラスに書きました。
public class PurchaseForm implements PurchaseRequest {
@NotEmpty
private String input1;
@NotNull
private Integer input2;
public String getInput1() {
return input1;
}
public Integer getInput2() {
return input2;
}
}
この実装方法の嬉しいポイントは、ドメインに近い層でServiceクラスで使う値を宣言するので画面側の変更に強いです。もちろん業務の概念としてInputとOutputが変わるのであれば見直す必要はありますが、ある程度の違いは画面側でコントロールすることが可能です。
補足
いっそのことドメイン側で定義した型に対して画面からの入力値をバインドするというのも良いと思います。それが最適解として語られることが多いような気がします。上記は弊社のプロジェクトでドメイン側のエンティティにバリデーションを実装したり、値の型を意味的に捉えて実装したりする文化がない中で行った解決策です。
思わぬ副次効果
なんとその後、購入商品と個数の情報を元に購入金額を計算するAPIを作成することになりました。PurchaseService#calculatePrice(CalculatePriceRequest)
というメソッドを定義し、PurchaseRequest
にCalculatePriceRequest
を継承させれば、PurchaseRequest
型の引数でも購入金額の計算を容易に行えます。そりゃ商品購入のリクエストには商品と個数の情報があるので、これらをもとに購入金額を計算できるのは至極当然なわけですけどね。
最後に
色々試行錯誤をしているところですが、上手くいったパターンとか上手くいかなかったパターンを分析して、実装方法の手札を増やせればなって感じです。特に私はWEBアプリケーションを開発することが多いので、画面とのIOは色々考えていきたいところです。