こんにちは。開発ブログ運営担当のktです。
前回の記事「オブジェクト設計スタイルガイドを読んで 〜前編〜」では、1〜3章のポイントをまとめました。
今回はその続きとして、4章以降で特に気になったポイントを紹介します。
引き続き各章ごとに「本のチャプター」「重要なポイント」「サンプルコード」「私の感想」という形式でまとめています。
サンプルコードは Java で書いていますが、他のオブジェクト指向言語にも応用できる内容です。
4章「オブジェクトの操作」
4.1「エンティティ:変更を追跡し、イベントを記録する識別可能なオブジェクト」
前編で紹介したサービスオブジェクトはイミュータブルに設計するのが理想でしたが、エンティティは時間とともに状態が変化するためミュータブルになります。エンティティには特有のルールがあり、状態を変更するメソッドはコマンドメソッドと呼ばれます。コマンドメソッドの戻り値は void にします。これは「このメソッドは副作用(状態変更)を持つ」ということを呼び出し側に明示するためです。メソッド名は命令形にします。また、エンティティの内部情報をそのまま公開するのではなく、変更履歴(イベント)を残して公開することで、他のオブジェクトがエンティティの変更内容を知ることができます。
✅ エンティティの設計例
public class UserAccount {
private String name;
private final List<Object> recordedEvents = new ArrayList<>();
// 状態変更メソッド:戻り値は void、名前は命令形
public void changeName(String newName) {
if (newName == null || newName.isBlank()) {
throw new IllegalArgumentException("Name must not be blank");
}
this.name = newName;
// 変更履歴をイベントとして記録
recordedEvents.add(new NameChanged(newName));
}
// 変更履歴を公開(内部フィールドを直接公開しない)
public List<Object> releaseEvents() {
// ① 現時点のイベントを別リストにコピーして返す
List<Object> events = new ArrayList<>(recordedEvents);
// ② エンティティ内部のリストはリセットする
// → 二重送信(二重ディスパッチ)を防ぐため
// → イベントの永続化はエンティティの責務ではなく、受け取った側が行う
recordedEvents.clear();
return events;
}
}
✨感想
なるほどと思ったのですが、実際には変更履歴を残すような実装はあまりしてきませんでした。イベントソーシングを採用しているシステムでは当たり前の考え方かもしれませんが、一般的なCRUDシステムではあまり見かけないパターンだと思います。取り入れるかどうかはケースによるかなと感じました。
4.2「バリューオブジェクト:置き換え可能、匿名、イミュータブルな値」
バリューオブジェクトは変更すべきではなく、値を別の値に変換したければ、新しい値を持つ新しいオブジェクトとして作成するべきと記述されています。
❌ NGな例(バリューオブジェクトを変更している)
public class Money {
private int amount;
// NG:既存オブジェクトを変更してしまっている
public void add(int value) {
this.amount += value;
}
}
✅ OKな例(新しいオブジェクトとして返す)
public class Money {
private final int amount;
public Money(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("Amount must not be negative");
}
this.amount = amount;
}
// OK:変更された値を持つ新しいオブジェクトを返す
public Money add(int value) {
return new Money(this.amount + value);
}
}
✨感想
実務ではバリューオブジェクト自体を使ったことはありますが、エンティティのフィールドとして細かくバリューオブジェクトを使い分けたことがありませんでした。例えば String のままにしている郵便番号や電話番号をバリューオブジェクトに置き換えることで、ドメインの表現力や堅牢性がどう向上するか検証してみたいです。
4.6「ミュータブルオブジェクトではモディファイアメソッドはコマンドメソッドとする」
モディファイアメソッドはプロパティを新しい値に更新するメソッドで、戻り値は void にしておくべきと記述されています。また、コマンドメソッドとして動詞から始まる命令形の名前にします。
public class ShoppingCart {
private List<String> items = new ArrayList<>();
// コマンドメソッド:動詞から始まる命令形、戻り値は void
public void addItem(String item) {
items.add(item);
}
public void removeItem(String item) {
items.remove(item);
}
}
✨感想
この節ではなぜそうするかが詳しく書かれていませんでした。4.7 と合わせて読むことで「ミュータブルは命令形(コマンド)・戻り値void、イミュータブルは宣言的(結果の状態)・新しいオブジェクトを返す」という対比ルールとして整理できました。このルールに従うことで、呼び出し側はメソッドを呼んだときにオブジェクトの状態が変わるのか、それとも新しいオブジェクトが返ってくるのかが名前と戻り値から判断できるようになります。
4.7「イミュータブルオブジェクトではモディファイアメソッドは宣言的な名前にする」
イミュータブルオブジェクトのモディファイアメソッドは、何をしてほしいかを指示するのではなく、操作の結果どうなってほしいかを宣言する名前にします。例えば、位置を表す Position クラスのメソッドは moveLeft() ではなく toTheLeft() にする方が適切とされています。そして戻り値では Position の新しいオブジェクトを返します。
public class Position {
private final int x;
private final int y;
public Position(int x, int y) {
this.x = x;
this.y = y;
}
// NG:命令形(「左に移動せよ」という指示)
// public Position moveLeft() { ... }
// OK:宣言的(「左に移動した結果の位置」を表す)
public Position toTheLeft() {
return new Position(this.x - 1, this.y);
}
public Position toTheRight() {
return new Position(this.x + 1, this.y);
}
}
✨感想
英語が得意ではない私には、moveLeft() と toTheLeft() のどちらも「左に移動する」という意味に捉えられてしまい、違いがわかりにくいと感じました。ただ、toTheLeft() は「操作を指示する」のではなく「新しい状態を指し示す」ものであるという視点で捉えると、4.6 の命令形との対比が明確になります。「〜せよ(命令)」ではなく「〜した状態(結果)」という視点で名前をつけるという考え方で整理できました。英語の微妙なニュアンスを意識しながら命名する必要があるので、英語力も大切だと改めて感じました。
5章「オブジェクトの使用」
5.1「メソッドを実装するためのテンプレート」
メソッドを実装するときに参考になるテンプレートとして、以下の順序が紹介されています。
- 事前条件のチェック
- 失敗のシナリオ
- ハッピーパス
- 事後条件のチェック
voidもしくは特定の型の値の返却
public class OrderService {
public Order placeOrder(CustomerId customerId, List<OrderItem> items) {
// 1. 事前条件のチェック
if (customerId == null) {
throw new IllegalArgumentException("CustomerId must not be null");
}
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("Items must not be empty");
}
// 2. 失敗のシナリオ(業務ルール違反など)
Customer customer = customerRepository.findById(customerId);
if (customer.isSuspended()) {
throw new CustomerSuspendedException(customerId);
}
// 3. ハッピーパス
Order order = Order.place(customerId, items);
orderRepository.save(order);
// 5. 戻り値
return order;
}
}
💡 ハッピーパスとは?
ハッピーパスとは、エラーや例外が発生しない正常系の処理フローのことです。事前条件・失敗シナリオを先に弾いておくことで、ハッピーパスの処理には余計な条件分岐が入らずシンプルに書けます。コードを読む人は「ここまで来たら正常な状態である」と安心して読み進められます。
💡 5.1.1「事前条件のチェック」について
クライアントから提供された引数が有効かどうかを確認するのが事前条件のチェックです。ただし、引数をバリューオブジェクトに置き換えることで、チェックはオブジェクトのコンストラクタで実施済みになるため、メソッド側での事前条件チェックが不要になります。
💡 5.1.4「事後条件のチェック」について
メソッドのユニットテストがあれば、そのメソッドが正しい値を返しているかどうかはテストで検証できるため、ほとんどのメソッドで事後条件のチェックは不要とされています。
💡 5.1.5「戻り値」について
何を返すか分かったら早めに返す。値を保持したままいくつかの if をスキップしてから返すのはやめる、と記述されています。早期リターンの考え方です。
// NG:結果を変数に持ち続けてから最後に返す
public String classify(int score) {
String result;
if (score >= 90) {
result = "A";
} else if (score >= 70) {
result = "B";
} else {
result = "C";
}
return result;
}
// OK:分かった時点で早めに返す
public String classify(int score) {
if (score >= 90) return "A";
if (score >= 70) return "B";
return "C";
}
✨感想
全てのメソッドがこのテンプレートに当てはまるわけではないかもしれませんが、メソッドを書くときの指針として覚えておこうと思います。
5.2.1「カスタム例外クラスは必要な場合のみ使う」
例外のインスタンスを作成するために名前付きコンストラクタを使用すると、同じ例外クラスを再利用して異なる失敗の理由を表すことが簡単になります。
public class InvalidOrderException extends RuntimeException {
private InvalidOrderException(String message) {
super(message);
}
// 名前付きコンストラクタで失敗の理由を表現
public static InvalidOrderException becauseItemsAreEmpty() {
return new InvalidOrderException("An order must have at least one item");
}
public static InvalidOrderException becauseCustomerIsSuspended(CustomerId customerId) {
return new InvalidOrderException("Customer " + customerId + " is suspended");
}
}
💡 なぜ「異なる失敗の理由を表すことが簡単」になるのか?
名前付きコンストラクタを使わない場合、呼び出し側でメッセージを組み立てて例外を生成することになります。
// 名前付きコンストラクタなし:呼び出し側でメッセージを書く必要がある
throw new InvalidOrderException("An order must have at least one item");
throw new InvalidOrderException("Customer " + customerId + " is suspended");
この場合、同じ失敗の理由でも呼び出す場所によってメッセージの文言が微妙にばらつく可能性があります。また、どんな失敗パターンが存在するかをコードから一覧で把握しにくくなります。
名前付きコンストラクタを使うと、失敗の理由がメソッド名として例外クラスに集約されます。呼び出し側は理由を選ぶだけでよく、メッセージの管理は例外クラス側に任せられます。どんな失敗パターンがあるかも例外クラスを見るだけで把握できます。
// 名前付きコンストラクタあり:呼び出し側はパターンを選ぶだけ throw InvalidOrderException.becauseItemsAreEmpty(); throw InvalidOrderException.becauseCustomerIsSuspended(customerId);
6章「情報の取得」
6.3「内部状態を公開するようなクエリメソッドは避ける」
JavaBeans 仕様のオブジェクトのプロパティを単純に返すゲッターを用意するのではなく、クライアントがゲッターで取得したデータを使ってさらに計算や判断をしている場合は、その処理をオブジェクト自身が行えないか考慮するべきとされています。そうすることで、オブジェクトが表す概念に関する知識をオブジェクト内部に集約できます。
// NG:内部状態を公開し、クライアント側で判断している
public class Order {
public List<OrderItem> getItems() {
return items;
}
}
// 呼び出し側で判断ロジックが散らばる
if (order.getItems().size() > 10) { ... }
if (order.getItems().isEmpty()) { ... }
// OK:オブジェクト自身が判断する
public class Order {
public boolean isTooLarge() {
return items.size() > 10;
}
public boolean isEmpty() {
return items.isEmpty();
}
}
💡実務での活かし方
- コードレビュー時に「このゲッター、呼び出し側でさらに何か判断してない?」という観点を持つと設計改善のヒントになる。
- 「Tell, Don’t Ask(聞かずに命令せよ)」の原則と同じ考え方。
6.7「クエリメソッドからはコマンドメソッドは呼び出さず、ほかのクエリメソッドのみを呼び出す」
クエリは副作用を持たないはずなので、コマンドメソッドを呼んでしまうと副作用が発生してしまいます。ただし例外もあり、例えば一意のIDを生成して返すようなメソッドは、IDを返すと同時にそのIDを使用済みとして保存(副作用)しておく必要があります。こういう例外があることも認識しておくことが大切です。
// NG:クエリメソッド内でコマンドメソッドを呼び出している
public class ReportService {
public Report getReport(ReportId id) {
Report report = reportRepository.find(id);
markAsViewed(id); // ← コマンドメソッドを呼び出してしまっている(副作用が発生)
return report;
}
}
// OK:クエリはクエリだけ。副作用が必要なら呼び出し側で明示的に行う
public class ReportService {
public Report getReport(ReportId id) {
return reportRepository.find(id); // クエリのみ
}
public void markAsViewed(ReportId id) { // コマンドは別メソッドに分離
// ...
}
}
7章「タスクの実行」
7.2「コマンドメソッドでやることを限定し、イベントを使用して二次的なタスクを実行する」
1つのメソッドで多くのことを行わないようにしましょう、という内容です。例えばパスワードを変更するメソッドで、変更したパスワードをデータベースに登録する処理と、それに関するメール送信を一緒に行っているケースがあります。メール送信は二次的なタスクです。
メール送信を別メソッドに切り出すだけでは、パスワード変更を実行しているクライアントにメール送信を呼び出す責任を転嫁してしまいます。これを解決する方法として、イベントディスパッチャを利用する方法が紹介されています。
public class UserAccount {
private String passwordHash;
private final EventDispatcher eventDispatcher;
public UserAccount(EventDispatcher eventDispatcher) {
this.eventDispatcher = eventDispatcher;
}
public void changePassword(String newPasswordHash) {
this.passwordHash = newPasswordHash;
// イベントを発行するだけ。メール送信の責務は持たない
eventDispatcher.dispatch(new PasswordChanged(this.id));
}
}
// リスナーが二次的なタスクを担当
public class PasswordChangedListener {
public void on(PasswordChanged event) {
emailSender.sendPasswordChangedNotification(event.getUserId());
}
}
✨感想
1つのメソッドに責務を混在させない解決策として、イベントディスパッチャを使う方法は興味深いと思いました。処理が分離される反面、二次的なタスクが離れた場所に実装されるので、処理のトレース性(追跡可能性)が低下する懸念があります。将来コードを読む人が処理の全体像を追うのが難しくなる可能性があるため、イベントのドキュメント化とチーム内の共通認識を持っておくことが重要だと感じました。イベントの発行が明示的にわかるような設計上の工夫も合わせて必要だと思います。自前でイベントディスパッチャを作成したことはないので、一度実装してみたいです。
9章「サービスの振る舞いの変更」
9.2「振る舞いを交換可能にするためにコンストラクタ引数を導入する」
サービスクラスの振る舞いを変更したくなったとき、クラスを直接変更すると壊してしまう可能性があります。そこで、振る舞いを入れ替え可能にするために、インターフェースを定義してコンストラクタ引数で注入する方法が紹介されています。
例えば設定ファイルを読み込んでパラメータを返すクラスで、テキストファイル・JSONファイル・XMLファイルにも対応したい場合、「ファイルを読み込む」という抽象的な概念をインターフェースとして定義し、それぞれの実装クラスを作成します。
// 振る舞いのインターフェース
public interface ConfigReader {
Map<String, String> read(String filePath);
}
// 実装クラスを差し替え可能にする
public class TextConfigReader implements ConfigReader { ... }
public class JsonConfigReader implements ConfigReader { ... }
// コンストラクタ引数で受け取ることで振る舞いを交換できる
public class AppConfigService {
private final ConfigReader configReader;
public AppConfigService(ConfigReader configReader) {
this.configReader = configReader;
}
public Map<String, String> loadConfig(String filePath) {
return configReader.read(filePath);
}
}
✨感想
これはファクトリークラスと組み合わせてよく見る形だと思います。Strategyパターンとも呼ばれるこのアプローチは、テストのしやすさにも直結するので実務で意識的に使っていきたいです。
9.6「オブジェクトの振る舞いを変更するために継承を使用しない」
既存のオブジェクトの振る舞いを変更するためにクラス継承を使用すると、いくつかのデメリットがあります。
- サブクラスと親クラスが密に結合してしまう
- サブクラスは
protectedメソッドだけでなく、publicメソッドもオーバーライドできてしまう
継承ではなく委譲を利用して振る舞いを変更する方が柔軟性があります。継承は型の階層を定義するためにのみ使用するべきとされています。
// NG:振る舞いを変えるために継承している
public class LoggingOrderService extends OrderService {
@Override
public Order placeOrder(CustomerId customerId, List<OrderItem> items) {
logger.info("placing order for " + customerId);
Order order = super.placeOrder(customerId, items);
logger.info("order placed: " + order.getId());
return order;
}
}
// OK:委譲を利用する(デコレーターパターン)
public class LoggingOrderService implements OrderServiceInterface {
private final OrderServiceInterface wrapped;
public LoggingOrderService(OrderServiceInterface wrapped) {
this.wrapped = wrapped;
}
@Override
public Order placeOrder(CustomerId customerId, List<OrderItem> items) {
logger.info("placing order for " + customerId);
Order order = wrapped.placeOrder(customerId, items);
logger.info("order placed: " + order.getId());
return order;
}
}
✨感想
テンプレートメソッドパターンを初めて見たときは「コードが再利用できていていいな」と思ったのですが、実際に利用していると継承だと振る舞いを変更したい例外パターンが出てきたときに苦しくなることがありました。
10章「オブジェクトフィールドガイド」
10章では、典型的なWebアプリケーションで見かけるオブジェクトについて説明されています。それぞれの役割と責務を整理します。
| オブジェクト | 主な役割 | 呼び出すもの |
|---|---|---|
| コントローラ | 外部とコアをつなぐ | アプリケーションサービス / リードモデルリポジトリ |
| アプリケーションサービス | タスクを実行する | ライトモデルリポジトリ / エンティティ |
| ライトモデルリポジトリ | エンティティを永続化する | DBなど(詳細を隠蔽) |
| リードモデルリポジトリ | 画面用データを返す | DBなど |
| エンティティ | ドメイン概念を表しデータと振る舞いを持つ | — |
| バリューオブジェクト | 値に意味と振る舞いを与える | — |
| リードモデル | 画面ユースケースに合わせたデータ構造 | — |
10.1「コントローラ」
コントローラは外の世界のクライアントとアプリケーションのコアとの間の接続を取り持ちます。コントローラは提供された入力に基づき、必要な情報を取得してアプリケーションサービスやリードモデルリポジトリを呼び出します。
- アプリケーションサービス:コントローラが何らかのタスク(状態変更・メール送信等)を実行するときに呼び出す
- リードモデルリポジトリ:クライアントから要求された情報を返す場合に使用する
✨感想
MVCのCにあたるオブジェクトで、この役割は初心者でも理解しやすいですが、コントローラで何でもやりがちなので注意が必要です。コントローラはあくまで「つなぎ役」に徹することが大切だと改めて確認できました。
10.2「アプリケーションサービス」と10.3「ライトモデルリポジトリ」
アプリケーションサービスはコンストラクタ引数として依存関係を注入してもらい、タスク実行に必要なデータはメソッド引数として提供されます。コントローラはクライアントから送られたデータをプリミティブ型でそのまま渡す場合もあれば、まとめて渡すためにDTOを利用する場合もあります。アプリケーションサービスが実行するタスクは単一にしておくことが重要です。
ライトモデルリポジトリ(Write(書き込み)モデル)はアプリケーションサービスがドメインオブジェクトを変更・永続化するために使用されます。オブジェクトがどのように永続化されるかという詳細は公開せず、save() や add() といった汎用的なメソッドを提供します。
// アプリケーションサービスの例
public class PlaceOrderService {
private final OrderRepository orderRepository; // ライトモデルリポジトリ
private final CustomerRepository customerRepository;
// コンストラクタで依存を注入
public PlaceOrderService(OrderRepository orderRepository, CustomerRepository customerRepository) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
}
// タスクはメソッド引数で受け取る(単一のタスク)
public void place(CustomerId customerId, List<OrderItem> items) {
Customer customer = customerRepository.findById(customerId);
Order order = Order.place(customer, items);
orderRepository.save(order); // 永続化の詳細を隠蔽
}
}
10.4「エンティティ」
エンティティは永続化されたオブジェクトのことです。アプリケーションのドメイン概念を表し、関連するデータを含み、そのデータに関する振る舞いを提供します。また名前付きコンストラクタを持つことで、エンティティを生成するためのドメイン固有の名前を使うことができます。
✨感想
実際のシステムでは「エンティティ」という名前がついていても、名前付きコンストラクタや状態変更メソッドを持たず、ただのDTOになっているケースが多いと感じています。DTOとしてのエンティティはデータの入れ物にすぎず、ドメインのルールや振る舞いは別のサービスクラス等に散らばりがちです。本書が推奨する振る舞いを持つエンティティでは、名前付きコンストラクタによる生成制御や、コマンドメソッドによる状態変更がエンティティ自身に集約されるため、ドメインの概念がより明確にコードに表れるようになると思います。
10.5「バリューオブジェクト」
バリューオブジェクトはプリミティブ型のラッパーで、値を扱いやすいよう意味と振る舞いを与えます。アプリケーションサービスがバリューオブジェクトのインスタンスを生成し、それをエンティティのコンストラクタやメソッドの引数に渡す場面で利用されることが多いです。
✨感想
実際のシステムでは何らかのコードも String のままエンティティに定義されることが多いですが、それをバリューオブジェクトに変更することでドメインに対する理解しやすさが向上するかもしれません。リファクタリングの際に試してみたいと思います。
10.7「リードモデルとリードモデルリポジトリ」
コントローラやアプリケーションサービスから情報を取得するためにリードモデルリポジトリが利用されます。リードモデルリポジトリのクエリメソッドから返されるリードモデルは、ユースケースで必要なレスポンスに合わせて設計されます。リードモデルは DTO だったり、バリューオブジェクトだったりします。
✨感想
実際のシステムでも、1つの画面に表示する情報を複数のテーブルから取得した際は、エンティティで返すよりもそれ用のオブジェクトを作成して返すことが多かったです。本書でこのパターンが明確に「リードモデル」として定義されているのを知って、自分が感覚的にやっていたことに名前がついた感覚でした。
まとめ
前編・後編を通じて「オブジェクト設計スタイルガイド」のポイントをまとめました。改めて振り返ると、本書のルールは以下の3つの思想に集約されると感じます。
- 責務の分離:コントローラ・サービス・リポジトリ・エンティティそれぞれの役割を明確に守る。1つのクラスやメソッドに複数の責務を混在させない
- 予測可能性(副作用の排除):クエリメソッドは副作用なし、コマンドメソッドは
voidを返す。コンストラクタでは代入以外を行わない。これらのルールで「呼んだときに何が起きるか」を明確にする - 変更のしやすさ:継承より委譲、インターフェースによる振る舞いの交換、バリューオブジェクトによる検証ロジックの集約。変更の影響範囲を小さく保つ設計を心がける
すぐに全てを実践するのは難しいかもしれませんが、設計の判断に迷ったときに振り返れる指針として活用していきたいと思います。ぜひ皆さんも実際に読んでみてください。
スタッフブログはこちら ☆彡
https://softem.com/blog/