概要
Javaではインスタンスを生成する際に、不正なオブジェクトの生成を防ぐためにコンストラクタで引数のバリデーションをしたりアクセス制御を行ったりします。しかしコンストラクタの定義には制約が多く、ロジックを表現出来る幅が大変限られてしまいます。そこでコンストラクタへのロジックの記述を排除して積極的にファクトリメソッドを採用し、コンストラクタを呼び出す事が出来るプログラム要素をコーディング規約で制限することで可読性や計算の合成可能性を優先します。
サンプルコード
改善対象のサンプルコードです。vavrを利用しています。
//コンストラクタにロジックが記述されているクラス //getterやいくつかのコマンドメソッドを持つ public final class Task { private final long taskID; private final String name; private final String description; private final boolean wasCompleted //完全コンストラクタ public Task(long taskID, String name, String description, boolean wasCompleted) { requireNonNull(name); requireNonNull(description); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); this.taskID = taskID; this.name = name; this.description = description; this.wasCompleted = wasCompleted; } //初期生成用コンストラクタ public Task(long taskID, String name, String description) { this(taskID, name, description, false); } public Task edit(String newName, String newDescription) { return new Task(this.taskID, newName, newDescription, this.wasCompleted); } public Task complete() { return new Task(this.taskID, this.name, this.description, true); } public Task unComplete() { return new Task(this.taskID, this.name, this.description, false); } public long taskID() { return this.taskID(); } public String name() { return this.Name(); } public String description() { return this.description(); } public boolean wasCompleted() { return this.wasCompleted(); } @Override public int hashCode() { return Long.hashCode(taskID); } @Override public boolean equals(Object o) { return this == o || o instanceof Task task && this.id ==task.id; } }
//永続化層 インターフェース定義省略 public final class TaskRepositoryImpl implements TaskRepository { //略 public Option<Task> findByID(long taskID) { var dtoOption = ...; return dtoOption .map(dto -> new Task( //コンストラクタ呼び出し dto.taskID, dto.newName, dto.newDescription, dto.wasCompleted )); } }
//Taskを利用するアプリケーションロジック public class TaskApplicationService { //略 @Transactional public TaskID createTask(String name, String description) { var taskIdPublisher = ...; try { //コンストラクタ呼び出し var task = new Task(idPublisher.nextID(), name, description) taskRepository.save(task); return task.taskID(); } catch(Exception e) { //必要な例外翻訳 } } }
コンストラクタにロジックを含む問題点
コンストラクタを用いたロジックの記述には以下の問題があります。
そもそもアクセス制御が十分に機能しない
Javaのアクセス制御はprivatte, package-private, protected, publicの4種類しかなく、特に別のパッケージに所属するクラスに対して細かいアクセス制御を行うことが出来ません。 例えば「このファクトリからはアクセスを許可する」や「この型のサブタイプからはアクセスを許可する」といった制約は表現出来ません。またrecord構文を採用する場合、コンストラクタの可視性はrecord本体の可視性に固定されます。
インスタンス生成に名前を付けられない
コンストラクタ呼び出しはnew <クラス名>(<引数>)で固定されます。
コンストラクタのオーバーロードはシグネチャからその意図を判断するのが難しく、利用側では誤った呼び出しをする可能性があります。
//ユースケース層での新規タスク生成 var idPublisher = ...; /*OK*/ var newTask = new Task(idPublisher.nextID(), "MyTask", "YOU MUST DO THIS NOW."); /*NG*/ var redundancyNewTask = new Task(idPublisher.nextID(), "MyTask", "YOU MUST DO THIS NOW.", false); //永続化層からの復元 /*OK*/ var restoredTask = new Task(0L, "MyTask", "YOU MUST DO THIS NOW.", true); /*NG*/ var restoreFailedTask = new Task(0L, "MyTask", "YOU MUST DO THIS NOW.");
上段のredundancyNewTaskの生成では「タスクが初期状態で未完である」というロジックがユースケース層に漏れている状態です。下段のrestoreFailedTaskでは誤ってコンストラクタ第四引数を省略してしまっているため、本来trueとして永続化されてるべき情報がfalseとなって復元されています。
戻り値の型が固定される
コンストラクタ呼び出しの戻り値の型は、そのクラスの実装型で固定されます。言い換えれば、戻り値としてOptionやEither, Validationといった型を使うことが出来ないため、呼び出し失敗を通知する方法が例外に固定されます。
更に、仮に例外を使うイディオムとEitherを使うイディオムの両方を提供したい場合、Either版でもコンストラクタ呼び出しは回避できず、結果不要なバリデーションが走ることになります。
public final class Task { //略 //完全コンストラクタ public Task(long taskID, String name, String description, boolean wasCompleted) { requireNonNull(name); requireNonNull(description); if(name.isBlank() || name.contains("\n") || ) throw new IllegalArgumentException(); this.taskID = taskID; this.name = name; this.description = description; this.wasCompleted = wasCompleted; } //戻り値が固定であるためEither<String, Task> Task(....) {...}のような事は出来ない。 //呼び出し失敗は例外として通知されるため、失敗原因を追跡するにはtry-catch文が必須となる。 public static ofEither(long taskID, String name, String description, boolean wasCompleted) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> //ここでコンストラクタが呼ばれるが、既にバリデーション済みの要素に対して //更にコンストラクタ内で不要なバリデーションを行っている new Task(taskID, name, description, wasCompleted) ) ); } private static Either<String, String> nameEither(String name) { if(name == null) return Either.left("name must not be null."); if(name.isBlank()) return Either.left("name must not be blank."); if(name.contains("\n")) return Either.left("name must be single line."); return Either.right(name); } private static Either<String, String> descriptionEither(String description) { if(description == null) return Either.left("description must not be null."); return Either.right(description); } //略 }
対策
コンストラクタへのロジック記述を一切取りやめます。コンストラクタは単にフィールドに対してプリミティブ値や必要な参照のコピーをする事に徹します。コンストラクタを目的別にオーバーロードすることはせず、用途に合わせたstaticなファクトリメソッド、又はファクトリクラスを用意します。
またコンストラクタのアクセス制御を諦め、代わりにコンストラクタを呼び出すことが出来るプログラム要素を規約で以下に限定します。
- 実装クラス自身
- ファクトリクラス
ただしいずれの場合もコンストラクタ呼び出し時に整合性を担保する必要があります。
クラスのクライアントはコンストラクタを直接呼び出すのではなく、必ずファクトリメソッドやファクトリクラスを経由してインスタンス生成します。これによりクライアントコードが宣言的な記述になります。
コンストラクタを公開する副次的な効果として、フィールドの公開を許容出来る場合はrecord構文を使う事でクラスの記述が簡潔になります。
コンストラクタがロジックを持たなくなった事でEither版のファクトリを定義する事が出来ます。コンストラクタ呼び出しは引数のフィールドへのコピー以外何も行わないため、呼び出し毎に不要なバリデーションが走ることが無いため、かえって再利用しやすくなります。
実装
まず、Taskのコンストラクタを完全コンストラクタ1つのみにして、その他のコンストラクタは削除、用途に合わせてファクトリメソッドを定義します。これにより、インスタンス生成に名前を付けることが出来ます。
public final class Task { private final long taskID; private final String name; private final String description; private final boolean wasCompleted //完全コンストラクタ //publicかつロジック無し public Task(long taskID, String name, String description, boolean wasCompleted) { this.taskID = taskID; this.name = name; this.description = description; this.wasCompleted = wasCompleted; } //永続層からの復元用ファクトリメソッド //完全コンストラクタと同様全フィールドを受け取る。 public static restore(long taskID, String name, String description, boolean wasCompleted) { requireNonNull(name); requireNonNull(description); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); return new Task(taskID, name, description, wasCompleted); } //初期生成用ファクトリメソッド //いわゆるコマンドメソッド public static create(long taskID, String name, String description) { requireNonNull(name); requireNonNull(description); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); return new Task(taskID, name, description, false); } //略 }
次にバリデーションメソッドをファクトリの外側で定義し、各ファクトリで再利用します。
public final class Task { //略 //永続層からの復元用ファクトリメソッド //完全コンストラクタと同様全フィールドを受け取る。 public static restore(long taskID, String name, String description, boolean wasCompleted) { validateName(name); validateDescription(description); return new Task(taskID, name, description, wasCompleted); } //初期生成用ファクトリメソッド //いわゆるコマンドメソッド public static create(long taskID, String name, String description) { validateName(name); validateDescription(description); return new Task(taskID, name, description, false); } //nameに対するバリデーションメソッド private static validateName(String name) { requireNonNull(name); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); } //descriptionに対するバリデーションメソッド private static validateDescription(String description) { requireNonNull(description); } //略 }
更に状態更新用のヘルパファクトリメソッドを定義し、コマンドメソッドを再実装します。
public final class Task { //フィールド、コンストラクタ、ファクトリ略 public Task edit(String newName, String newDescription) { validateName(newName); validateDescription(newDescription); return update(newName, newDescription, this.wasCompleted); } public Task complete() { return update(this.name, this.description, true); } public Task unComplete() { return update(this.name, this.description, false); } //略 //状態更新用メソッド //ライフサイクルを通じて不変であるtaskIDを引き継ぐ private Task update(String name, String description, boolean wasCompleted) { return new Task(this.taskID, name, description, wasCompleted); } }
今回のTaskはclassの代わりにrecordを使うことで記述を大幅に省略できます。ただし自動生成されるメソッドのうちhashCodeとequalsは再実装します。
record版のTaskの実装の全文を以下に示します。
public record Task(long taskID, String name, String description, boolean wasCompleted) { //フィールド不要 //コンストラクタも不要 //永続層からの復元用ファクトリメソッド //完全コンストラクタと同様全フィールドを受け取る。 public static restore(long taskID, String name, String description, boolean wasCompleted) { validateName(name); validateDescription(description); return new Task(taskID, name, description, wasCompleted); } //初期生成用ファクトリメソッド //いわゆるコマンドメソッド public static create(long taskID, String name, String description) { validateName(name); validateDescription(description); return new Task(taskID, name, description, false); } public Task edit(String newName, String newDescription) { validateName(newName); validateDescription(newDescription); return update(newName, newDescription, this.wasCompleted); } public Task complete() { return update(this.name, this.description, true); } public Task unComplete() { return update(this.name, this.description, false); } @Override public int hashCode() { return Long.hashCode(taskID); } @Override public boolean equals(Object o) { return this == o || o instanceof Task task && this.id ==task.id; } //nameに対するバリデーションメソッド private static validateName(String name) { requireNonNull(name); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); } //descriptionに対するバリデーションメソッド private static validateDescription(String description) { requireNonNull(description); } //状態更新用メソッド //ライフサイクルを通じて不変であるtaskIDを引き継ぐ private Task update(String name, String description, boolean wasCompleted) { return new Task(this.taskID, name, description, wasCompleted); } }
続いてこれらを利用する永続層とアプリケーションサービスを再実装します。これらは元々直接コンストラクタを呼び出していましたが、「コンストラクタを呼び出すことが出来るのは自身かファクトリ」のルールに従いファクトリメソッドを使うようにします。
//永続化層 インターフェース定義省略 public final class TaskRepositoryImpl implements TaskRepository { //略 public Option<Task> findByID(long taskID) { var dtoOption = ...; return dtoOption .map(dto -> Task //ファクトリメソッド呼び出し。 .restore(dto.taskID, dto.newName, dto.newDescription, dto.wasCompleted) ); } }
//Taskを利用するアプリケーションロジック public class TaskApplicationService { //略 @Transactional public TaskID createTask(String name, String description) { var taskIdPublisher = ...; try { //ファクトリメソッド呼び出し var task = Task.create(idPublisher.nextID(), name, description) taskRepository.save(task); return task.taskID(); } catch(Exception e) { //必要な例外翻訳 } } }
コンストラクタ呼び出しと比べてクライアントコードが宣言的になりました。createとrestoreを間違えることは考えにくく、引数の個数間違いによるロジック漏れやバグを防ぐことが出来ます。
続いてEither版のAPIを定義します。これらは元の例外版ファクトリメソッドを一切汚すことなく追加できます。 ただしバリデーションロジックの共有は難しいので実際はどちらか片方を導入する事になるかと思います。
public record Task(long taskID, String name, String description, boolean wasCompleted) { //略 //Either版ファクトリメソッド public static Either<String, Task> restoreOfEither(long taskID, String name, String description, boolean wasCompleted) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> //コンストラクタは不要なバリデーションを行わない new Task(taskID, validName, validDescription, wasCompleted) ) ); } public static Either<String, Task> createOfEither(long taskID, String name, String description) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> new Task(taskID, validName, validDescription, false) ) ); } //Either版コマンドメソッド public Either<Task> edit(String newName, String newDescription) { return nameEither(newName).flatMap(validName -> descriptionEither(newDescription).flatMap(validDescription -> update(validName, validDescription, this.wasCompleted) ) ); } //略 //Either版バリデーションロジック private static Either<String, String> nameEither(String name) { if(name == null) return Either.left("name must not be null."); if(name.isBlank()) return Either.left("name must not be blank."); if(name.contains("\n")) return Either.left("name must be single line."); return Either.right(name); } private static Either<String, String> descriptionEither(String description) { if(description == null) return Either.left("description must not be null."); return Either.right(description); } //略 }
Either版APIを利用する場合の永続化層、アプリケーションロジックの実装は省略します。
懸念
この手法はコンストラクタが完全に公開されてしまうため、コンストラクタの誤った使い方をコンパイルエラーににすることが出来ません。しかしコンストラクタ呼び出しに関するルールは単純であり、ソースコードの静的解析ツールを作れるかもしれません。
Appendix
改善後のTaskのコード全文を以下に示します。
public record Task(long taskID, String name, String description, boolean wasCompleted) { //フィールド不要 //コンストラクタも不要 //永続層からの復元用ファクトリメソッド //完全コンストラクタと同様全フィールドを受け取る。 public static restore(long taskID, String name, String description, boolean wasCompleted) { validateName(name); validateDescription(description); return new Task(taskID, name, description, wasCompleted); } //初期生成用ファクトリメソッド //いわゆるコマンドメソッド public static create(long taskID, String name, String description) { validateName(name); validateDescription(description); return new Task(taskID, name, description, false); } public Task edit(String newName, String newDescription) { validateName(newName); validateDescription(newDescription); return update(newName, newDescription, this.wasCompleted); } //Either版ファクトリメソッド public static Either<String, Task> restoreOfEither(long taskID, String name, String description, boolean wasCompleted) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> //コンストラクタは不要なバリデーションを行わない new Task(taskID, validName, validDescription, wasCompleted) ) ); } public static Either<String, Task> createOfEither(long taskID, String name, String description) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> new Task(taskID, validName, validDescription, false) ) ); } //Either版コマンドメソッド public Either<Task> edit(String newName, String newDescription) { return nameEither(newName).flatMap(validName -> descriptionEither(newDescription).flatMap(validDescription -> update(validName, validDescription, this.wasCompleted) ) ); } public Task complete() { return update(this.name, this.description, true); } public Task unComplete() { return update(this.name, this.description, false); } @Override public int hashCode() { return Long.hashCode(taskID); } @Override public boolean equals(Object o) { return this == o || o instanceof Task task && this.id ==task.id; } //nameに対するバリデーションメソッド private static validateName(String name) { requireNonNull(name); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); } //descriptionに対するバリデーションメソッド private static validateDescription(String description) { requireNonNull(description); } //Either版バリデーションロジック private static Either<String, String> nameEither(String name) { if(name == null) return Either.left("name must not be null."); if(name.isBlank()) return Either.left("name must not be blank."); if(name.contains("\n")) return Either.left("name must be single line."); return Either.right(name); } private static Either<String, String> descriptionEither(String description) { if(description == null) return Either.left("description must not be null."); return Either.right(description); } //状態更新用メソッド //ライフサイクルを通じて不変であるtaskIDを引き継ぐ private Task update(String name, String description, boolean wasCompleted) { return new Task(this.taskID, name, description, wasCompleted); } }