型を活用したプログラミング
この記事は何
例えば「ユーザ ID やメールアドレスを String
型のまま引きずり回さない」ための, つまり型を型として活用することを考えるための記事です.
ドメイン駆動設計で言う「ドメインオブジェクト」「エンティティ」「値オブジェクト」を知ることと,
ドメインオブジェクトを活用するための「ドメインサービス」「アプリケーションサービス」を概要レベルで知ることを目標としています.
基本的にコードサンプルは Java で書き, それに合わせて文章が構成されています.
型を活用していますか
Java を書いていると当たり前に String
型や Integer
型や List<T>
型を使いますよね.
そしてこれら Java 標準の型以外にも自前で便利な型を作るかと思います.
その「型」という概念がなぜ存在しているか考えたことはありますか?
型を活用していますか?
型を活用できていないコード
Java において型はクラスで表現されます.
クラスのコンストラクタを使ってインスタンスを作り, インスタンス内の値を使ってシステム特有の処理を行います.
各処理は, 処理を行うために ある程度制約のついた値 を要求します.
例えば, ユーザの新規作成にユーザ名とメールアドレスが必要だとします.
このときどちらも値が null
ではユーザ作成はできませんし, "hoge@fuga"
という値はメールアドレスとして使うことはできません.
null
や "hoge@fuga"
という値を入力して処理が行われてしまわないよう, 一般的にバリデーションを仕込むことになるでしょう.
例えば, 以下のようなコードを書くと思います.
// bad-example-1: ユーザ作成処理
public User createUser(String userName, String mailAddress) {
if (userName == null || userName.isEmpty()) {
throw new IllegalArgumentException("ユーザ名が不正です");
}
if (mailAddress == null || mailAddress.matches(MAIL_ADDRESS_REGEX)) {
throw new IllegalArgumentException("メールアドレスが不正です");
}
return this.repository.createUser(userName, mailAddress);
}
これは比較的よく書かれるコードで, 問題なく動作するように思えます.
しかし, このコードでは型を活用できているとは言えません.
最大の問題点は, userName
/ mailAddress
が String
型であることです.
String
型とは文字列長 2147483647 までの任意の文字列を表す型ですが,
ユーザ名やメールアドレスは本当に String
型として扱ってもよいものでしょうか?
また, ユーザの作成に加えてユーザの更新を実装する必要がある場合にも問題が起こります.
// bad-example-2: ユーザ作成処理と更新処理
public User createUser(String userName, String mailAddress) {
if (userName == null || userName.isEmpty()) {
throw new IllegalArgumentException("ユーザ名が不正です");
}
if (mailAddress == null || mailAddress.matches(MAIL_ADDRESS_REGEX)) {
throw new IllegalArgumentException("メールアドレスが不正です");
}
return this.repository.createUser(userName, mailAddress);
}
public User updateUser(String userId, String userName, String mailAddress) {
if (userId == null || userId.isEmpty()) {
throw new IllegalArgumentException("ユーザ ID が不正です");
}
if (userName == null || userName.isEmpty()) {
throw new IllegalArgumentException("ユーザ名が不正です");
}
if (mailAddress == null || mailAddress.matches(MAIL_ADDRESS_REGEX)) {
throw new IllegalArgumentException("メールアドレスが不正です");
}
User user = this.repository.getUserById(userId);
user.setNewName(userName);
user.setNewMailAddress(mailAddress);
return this.repository.saveUser(user);
}
そしてこのようなコード重複を回避するために, UserUtil
のようなものが作られるでしょうか.
// bad-example-3: コード重複回避用のクラス
public class UserUtil {
public static void checkUserId(String userId) throws IllegalArgumentException {
if (userId == null || userId.isEmpty()) {
throw new IllegalArgumentException("ユーザ ID が不正です");
}
}
public static void checkUserName(String userName) throws IllegalArgumentException {
if (userName == null || userName.isEmpty()) {
throw new IllegalArgumentException("ユーザ名が不正です");
}
}
public static void checkMailAddress(String mailAddress) throws IllegalArgumentException {
if (mailAddress == null || mailAddress.matches(MAIL_ADDRESS_REGEX)) {
throw new IllegalArgumentException("メールアドレスが不正です");
}
}
}
あくまでユーザ ID などの情報を String
型として取り扱おうとするとこのようになります.
ユーザ ID などの値を使って処理を書く際,
「この値は別途バリデーションをする必要があったか?」「どこにバリデータがあるのか?」をずっと気にし続ける必要があり,
それはコードの実装者にとって大きな負担となります.
型を活用するコード
このような問題に対処するために, Java のオブジェクト指向ではクラスのコンストラクタを活用します.
ユーザ名とメールアドレスについて, 以下のようなクラスを用意します.
// good-example-1: ユーザ ID を表すクラス
public class UserId {
private final String id;
public UserId(String id) {
if (id == null || id.isEmpty()) {
throw new IllegalArgumentException("ユーザ ID が不正です");
}
this.id = id;
}
@Override
public String toString() {
return this.id;
}
}
// good-example-2: ユーザ名を表すクラス
public class UserName {
private final String name;
public UserName(String name) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("ユーザ名が不正です");
}
this.name = name;
}
@Override
public String toString() {
return this.name;
}
}
// good-example-3: メールアドレスを表すクラス
public class MailAddress {
private static final String MAIL_ADDRESS_REGEX = "[\\w\\-._]+@[\\w\\-._]+\\.[A-Za-z]+";
private final String address;
public MailAddress(String address) {
if (address == null || address.isEmpty() || !address.matches(MAIL_ADDRESS_REGEX)) {
throw new IllegalArgumentException("メールアドレスが不正です");
}
this.address = address;
}
@Override
public String toString() {
return this.address;
}
}
そして上記クラスを使い, ユーザの作成と更新の処理を書いてみます.
// good-example-4: ユーザ作成と更新
public User createUser(UserName userName, MailAddress mailAddress) {
return this.repository.createUser(userName, mailAddress);
}
public User updateUser(UserId id, UserName userName, MailAddress mailAddress) {
User user = this.repository.getUserById(userId);
user.setNewName(userName);
user.setNewMailAddress(mailAddress);
return this.repository.saveUser(user);
}
処理自体が非常にすっきり書けました.
また, ユーザを取得する処理 getUserById
が String
型ではなく UserId
型を要求するようになったため,
誤って getUserById(userName)
と書いてしまってもコンパイルエラーになるため最速で間違いに気づけます.
コードリーディングの面でも非常に有利です.
何も知らない新規メンバーが getUserById
メソッドの実装を読もうとしたとき, 仮引数の型が UserId
型であるため,
getUserById
を実行するための前提条件に真っ先に気づけます.
逆に getUserById
の実装者は, メソッド実行時に絶対に異常値が来ないことを確信できるため, 実装上考慮することが減ります.
(ちょっと息抜き)
この記事の目的は以下でした.
例えば「ユーザ ID やメールアドレスを
String
型のまま引きずり回さない」ための, つまり型を型として活用することを考えるための記事です.
ここまでで「型を型として活用することを考える」ことができたでしょうか?
ここからは, より深くしっかり型を型として活用していくための知識の紹介です.
聞き慣れない用語が連続するため, 一度休憩をはさみましょう 🍵
...
...さて, 引き続きがんばりましょう.
考え方に名前をつける: 値オブジェクト
上記で紹介した UserId
/ UserName
/ MailAddress
は,
ドメイン駆動設計における 値オブジェクト という考え方の一部を使って実装したものです.
値オブジェクトには守るべき性質があります.
代表的なものは以下です.
- イミュータブル (不変) であること
- 等価性により比較されること
これらはいわゆる「値」と同じ性質です.
// イミュータブルであること
Integer i = 100;
i.setValue(100); // ありえないコード
i = 200; // 再代入でしか変更できない
UserId id = new UserId("001");
id.setValue("002"); // ありえないコード
id = new UserId("002"); // 再代入でしか変更できない
// 等価性により比較されること
Integer i = 100;
Integer j = 100;
i.equals(j); // true
UserId id1 = new UserId("001");
UserId id2 = new UserId("001");
id1.equals(id2); // true
関連する考え方の紹介: エンティティ
値オブジェクトは等価性により比較されましたが,
等価性ではなく同一性により比較される関連概念が存在します.
それが エンティティ です.
前述のサンプルコードにおいては, ユーザはエンティティです.
ユーザはユーザ名やメールアドレスが変わっても, 全く別のユーザになるわけではありません.
ユーザ ID が等価であれば, ユーザ名が違っているユーザインスタンス同士でも同一であると判定されます.
User user1 = new User(id1, name1, address1);
User user2 = new User(id1, name2, address2); // ID だけ同一
user1.equals(user2); // true
つまり, ユーザの実装は以下のようになります.
public class User {
private final UserId id;
private UserName name;
private MailAddress address;
public User(UserId id, UserName name, MailAddress address) {
this.id = id;
this.name = name;
this.address = address;
}
@Override
public boolean equals(User other) {
return this.id.equals(other.id);
}
}
エンティティと値オブジェクトの総称: ドメインオブジェクト
エンティティと値オブジェクトは非常に密接に関連しています.
そのため, これらを総称して ドメインオブジェクト と呼びます.
エンティティと値オブジェクトはいくつか細かな違いがありますが,
値に対して制約を設けるなど, 大部分は同じ目的で使われます.
ところで, 今回のサンプルコードではユーザはエンティティでした.
しかし, システムの要件次第ではユーザが値オブジェクトになることもあります.
システムによって同一性判定が必要になったり等価性判定が必要になったりするためです.
これはそれぞれのシステムに応じて実装者が判断する必要があります.
ドメインオブジェクトに収めきれないコード
ここまでで, 今まで普通に書いてきたうちのいくらかのコードはドメインオブジェクトに収められることが分かりました.
しかし, いざドメインオブジェクトを使ってコードを書いていくと,
違和感のあるコードに直面します.
例えば「特定の ID を持つユーザがすでにデータストアに存在するか検査する」という処理をドメインオブジェクト内に書いてみましょう.
public class User {
// (中略)
public boolean existUser(UserId targetId) {
User user = this.repository.getUserById(targetId);
return user != null;
}
}
// ID 009 を持つユーザが居るかどうか調べたい
UserId id = new UserId("009");
// 調べるために User のインスタンスが要るので作る
UserId id1 = new UserId("001");
UserName name1 = new UserName("hoge");
MailAddress address1 = new MailAddress("hoge@example.com");
UserRepository repository1 = getRepository(); // 今まで不要だったものが必要になる...?
User user = new User(id1, name1, address1, repository1);
// ようやく調べられる...けどここまで必要...?
boolean exist = user.existUser(id);
明らかに無駄が多いですね.
ドメイン駆動設計におけるサービス
このような状態を解決するために, 「ドメインサービス」「アプリケーションサービス」という考え方が存在します.
ドメインサービスとは
ドメインサービスは, ドメインオブジェクト内には書きづらい処理を書く場所です.
例えば, ユーザの存在確認を行う処理 (前述) はドメインサービスに書きます.
ドメインオブジェクトもしくはドメインサービスには, それを表すのに必要になる基本的な処理のみを書きます.
それ以外のものは後述のアプリケーションサービスに書きます.
アプリケーションサービスとは
アプリケーションサービスは, ドメインサービスやドメインオブジェクトを使って実現したいアプリケーションの要件を書く場所です.
例えば, HTTP で通信がしたいという要件はアプリケーションレベルの話なのでアプリケーションサービスに書くことになるかと思います.
(MVC の Controller などとはまた違うものなので注意してください)
おわりに
型を活用するための第一歩としてドメイン駆動設計の紹介をしました.
ドメイン駆動設計は「設計」というだけあり, プロダクトの設計をソースコードレベルで支えていくための知識ですが,
オブジェクト指向とかなり関連が深いので (というかオブジェクト指向の具体的なやり方のうちの1つ),
ただ綺麗なコードを書きたい!というだけの方でもドメイン駆動設計の書籍を読んでみることをおすすめします.
オブジェクト指向 / ドメイン駆動のために筆者が参考にさせていただいた書籍をご紹介します.
Discussion