Java | オブジェクト指向:値オブジェクトとは

Java Java
スポンサーリンク

値オブジェクトとは何か

値オブジェクトは「値そのものを表す小さな不変の型」です。金額、メールアドレス、期間、座標、IDなど、意味とルールを持つ“ただの値”をクラス化して、検証・整合性・振る舞い(比較や計算)を中に閉じ込めます。重要なのは「同じ値なら同じもの」「生成後に変わらない」という性質です。


なぜ値オブジェクトにするのか

意味とルールを型に埋め込む

String や int のままだと「有効な値か」「範囲は正しいか」を毎回チェックする必要があります。値オブジェクトにすれば、生成時に一度検証して以後は安全に使えます。コードに“意味”が現れ、読み手にとって明快になります。

不変性でバグを減らし、比較が正確になる

作ってから変わらないので、途中状態や再代入のバグが消えます。等価性は“同じ値か”で判断でき、コレクションのキーにしても安全です(hashCode が変化しない)。


値オブジェクトの基本設計

不変にする(private final とセッター禁止)

フィールドは private final にし、コンストラクタで検証・確定します。書き換え用のメソッドは作らず、必要なら新インスタンスを返す「変換」だけを提供します。

public final class Email {
    private final String value;

    public Email(String raw) {
        var v = normalize(raw);
        if (!isValid(v)) throw new IllegalArgumentException("invalid email");
        this.value = v;
    }

    public String value() { return value; }

    private static String normalize(String s) {
        return s == null ? "" : s.trim().toLowerCase(java.util.Locale.ROOT);
    }
    private static boolean isValid(String s) {
        int at = s.indexOf('@');
        return at > 0 && at == s.lastIndexOf('@') && at < s.length() - 1;
    }

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Email e)) return false;
        return value.equals(e.value);
    }
    @Override public int hashCode() { return value.hashCode(); }
    @Override public String toString() { return value; }
}
Java

等価性は“値が同じか”で判断する

同一性(同じインスタンスか)ではなく、等価性(値が同じか)を基準にします。equals/hashCode を必ず値に基づいて実装します。


代表例と小さな振る舞い

金額(通貨付き)と計算

通貨の異なる足し算を拒否するなど、ルールを型に埋め込みます。計算は新インスタンスを返す“副作用なし”にします。

public final class Money {
    private final int amount;
    private final String currency;

    public Money(int amount, String currency) {
        if (amount < 0) throw new IllegalArgumentException("amount>=0");
        if (currency == null || currency.isBlank()) throw new IllegalArgumentException("currency");
        this.amount = amount;
        this.currency = currency.trim();
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
        return new Money(this.amount + other.amount, this.currency);
    }

    public int amount() { return amount; }
    public String currency() { return currency; }
}
Java

期間(start/end)と整合性

開始と終了の関係(end >= start)をコンストラクタで保証します。延長などの操作は新インスタンスで返します。

public final class Period {
    private final java.time.LocalDate start;
    private final java.time.LocalDate end;

    public Period(java.time.LocalDate start, java.time.LocalDate end) {
        if (start == null || end == null || end.isBefore(start)) {
            throw new IllegalArgumentException("end >= start");
        }
        this.start = start;
        this.end = end;
    }

    public Period extendDays(long days) {
        if (days < 0) throw new IllegalArgumentException("days>=0");
        return new Period(start, end.plusDays(days));
    }

    public java.time.LocalDate start() { return start; }
    public java.time.LocalDate end() { return end; }
}
Java

コレクション・配列を含む場合の注意点

防御的コピーで不変を守る

可変の配列やリストをそのまま持つと、外から書き換えられて不変が崩れます。受け取り時・返却時にコピーまたは不変ビューにします。

public final class Tags {
    private final java.util.List<String> list;

    public Tags(java.util.List<String> input) {
        var normalized = (input == null ? java.util.List.<String>of() : input).stream()
            .map(s -> s == null ? "" : s.trim())
            .filter(s -> !s.isEmpty())
            .toList();
        this.list = java.util.List.copyOf(normalized); // 不変ビュー
    }

    public java.util.List<String> asList() { return list; } // 変更不可のビュー
}
Java

record の活用(値オブジェクトを簡潔に)

record は不変のデータキャリア

record は自動で private final フィールド、equals/hashCode/toString を生成します。検証が必要ならコンパクトコンストラクタを使います。

public record Point(int x, int y) {
    public Point {
        if (x < 0 || y < 0) throw new IllegalArgumentException("non-negative");
    }
}
Java

配列など可変を持つなら、コンストラクタとアクセサでコピーして不変を保ちます。


境界での使い分けと実務上の指針

どこに導入するか

ID、メール、URL、金額、期間、電話番号、座標など「意味とルールがある単一値」は真っ先に値オブジェクト化します。ドメインロジックの条件分岐や検証が激減し、バグの侵入経路を塞げます。

DTO や永続化との関係

外部入出力(DTO)は可変でも構いませんが、受け取ったらすぐにドメインの値オブジェクトへ変換し、内部は不変で扱います。JPA を使う場合は、埋め込み値(Embeddable)や変換器を使って値オブジェクトのまま保存・復元できる形に寄せると整合性が保てます。


よくある落とし穴と回避

値オブジェクトにセッターを付けると不変が崩れ、型の価値が失われます。必ず不変にし、変更は新インスタンス返しで表現してください。equals/hashCode を未定義のままにすると、同じ値なのにコレクションで正しく扱えません。値に基づく等価を必ず実装します。配列やリストをそのまま持つと外から変更されるので、防御的コピーを徹底します。


仕上げのアドバイス(重要部分のまとめ)

値オブジェクトは「意味とルールを型に閉じ込めた不変の値」。生成時に検証して以後は安全、比較は値で、操作は新インスタンス返し。防御的コピーで不変を守り、record を活用して簡潔に書く。まず“意味のある単一値”から導入すると、ドメインの整合性とコードの明快さが劇的に向上します。

タイトルとURLをコピーしました