ドメインモデル貧血症
マーチン・ファウラー命名のドメインモデル貧血症は、ドメイン層っぽいデータをもつクラスを作りながらも、フィールドのGetter/Setterだけをつものを指す。そうしちゃうとドメインオブジェクトに業務知識が実装されず、それを使う側に任されることになる。
実際のコード例を用いた説明は、この記事が非常に分かりやすい。
①貧血症モデル
code:Task.java
public class Task {
@Getter @Setter
private Long id;
@Getter @Setter
private TaskStatus taskStatus;
@Getter @Setter
private String name;
@Getter @Setter
private LocalDate dueDate;
@Getter @Setter
private int postponeCount;
public static final int POSTPONE_MAX_COUNT = 3;
}
こういう単なる属性のGetter/Setterだけしかもたないものだと、TaskStatusをどう変更するのも、dueDateを変更するのも、これを使う側の自由である。だが、そこには業務ルール(ドメイン知識)があるはずで、それをドメイン層で実装しなければならない、というのが貧血症を脱しドメインモデルに向かう道となる。
②ドメイン知識を実装したモデル
code:Task.java
public class Task {
@Getter
private Long id;
@Getter
private final String name;
@Getter
private LocalDate dueDate;
private TaskStatus taskStatus;
private int postponeCount;
public static final int POSTPONE_MAX_COUNT = 3;
public Task(String name, LocalDate dueDate) {
if (name == null || dueDate == null) {
throw new IllegalArgumentException("必須項目が設定されていません");
}
this.name = name;
this.dueDate = dueDate;
this.taskStatus = TaskStatus.UNDONE;
this.postponeCount = 0;
}
public void postpone() {
if (postponeCount >= POSTPONE_MAX_COUNT) {
throw new IllegalArgumentException("最大延期回数を超過しています");
}
dueDate = dueDate.plusDays(1L);
postponeCount++;
}
public void done() {
this.taskStatus = TaskStatus.DONE;
}
public boolean isUndone() { return this.taskStatus == TaskStatus.UNDONE; }
public boolean canPostpone() { return this.postponeCount < POSTPONE_MAX_COUNT; }
}
正しいドメインモデルとはオブジェクトに業務のふるまいを持たせることなのか?
こういう説明を見てしまうと、Setterを用意するのではなく、ドメインモデルに状態を変えたり、状態をチェックするメソッドを持たせることなのだな? と考えてしまいがちだが、これは貧血症改善の一部でしかない。
上記②のモデルでも、DONE状態のTaskに対して、postponeメソッドやdoneメソッドを呼べてしまう。それらのメソッドの中で、TaskStatusをチェックして例外返すようにすればいいじゃないかと思いがちだが、そもそもTaskの状態によっては、そのふるまいを実装する条件が揃っていないのに、それをTaskという1つの型で表現しているがために、 Always-Valid Domain Model が満たせていない、と考えたほうがよい。 完了タスクやPOSTPONE_MAX_COUNTに達したTaskに対しては、そもそもpostponeを呼べてはならない(postponeというふるまいが定義されない)し、完了タスクにはdoneというふるまいがされてはならない。
ということで、これらの型を分ける。
以下のように型定義できる。(※ 簡単のため生成時の不変条件チェックは省略してあります)
③Design by Typesドメインモデル
code:Task.java
public interface Task {
Long getId();
String getName();
}
code:UndoneTask.java
public interface UndoneTask extends Task {
LocalDate getDueDate();
DoneTask done();
}
code:PostponableUndoneTask.java
@Value
public class PostponableUndoneTask implements UndoneTask {
Long id;
String name;
LocalDate dueDate;
int postponeCount;
public static final int POSTPONE_MAX_COUNT = 3;
public UndoneTask postpone() {
if (postponeCount < POSTPONE_MAX_COUNT) {
return new PostponableUndoneTask(id, name, dueDate.plusDays(1L),postponeCount + 1);
} else {
return new UndoneTaskWithDeadline(id, name, dueDate.plusDays(1L));
}
}
@Override
public DoneTask done() {
return new DoneTask(id, name, LocalDate.now());
}
}
code:UndoneTaskWithDeadline.java
@Value
public class UndoneTaskWithDeadline implements UndoneTask {
Long id;
String name;
LocalDate dueDate;
@Override
public DoneTask done() {
return new DoneTask(id, name, LocalDate.now());
}
}
code:DoneTask.java
@Value
public class DoneTask implements Task {
Long id;
String name;
LocalDate doneDate;
}
こうして型で業務上取り扱いの変わるデータの違いを表現しておくと、これを使う側の間違いをなくすことができるし、型そのものが業務知識である。
doneやpostponeがUndoneTaskやPostponableUndoneTaskに実装されるべきかどうか? は貧血症かどうかの判断材料とはあまり関係ない。
done: UndoneTask -> DoneTask
postpone: PostponableUndoneTask -> UndoneTask
というfunctionがドメイン層のどこかで実装されていればよいわけで、関数型言語でドメインモデル作るときはそうするはずだ。
ということで、貧血症かどうかは、異なる値やふるまいをもつデータを異なるモデルとして定義できているかにかかっている、といえる。
参考文献