Java | オブジェクト指向:不正な setter の問題

Java Java
スポンサーリンク

なぜ「不正な setter」が問題になるのか

setter 自体は悪ではありません。
ただ「何でもかんでも public な setter を生やす」「ルールも何もなく値を変えられるようにする」と、
オブジェクト指向の根っこである「オブジェクトが自分の一貫性を守る」という前提が崩れます。

不正な setter とは、ざっくり言うと
「そのオブジェクトにとってありえない状態にも、平気で変えてしまえる setter」
のことです。

結果として、

オブジェクトの状態が壊れやすい
どこで壊れたか追いにくい
ドメインルールがどこにも集約されない

というツラさを呼び込みます。

ここから、具体例ベースで噛み砕いて話していきます。


例題で見る「不正な setter」が生む壊れ方

何でも変えられる貧血ユーザ

よくある User クラスを見てみます。

final class User {

    private Long id;
    private String name;
    private String email;
    private int point;

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public int getPoint() { return point; }
    public void setPoint(int point) { this.point = point; }
}
Java

一見「ちゃんとカプセル化している」ように見えますが、
この setter たちは何のチェックもせずに、どんな値でも受け入れます。

呼び出し側は簡単にこうできます。

User u = new User();
u.setName(null);           // 名前が null
u.setEmail("aaa");         // メールアドレスっぽくない文字列
u.setPoint(-100);          // マイナスポイント
Java

本来、業務的には

名前は必須
メールアドレスは一定の形式
ポイントは 0 以上、最大 10,000 まで

みたいなルールがあるかもしれません。
でも setter はそのルールをまったく知らないので「何でもどうぞ」と通してしまいます。

つまり、オブジェクトの中に「不正な状態」が簡単に入り込める構造になっています。
これが「不正な setter」の一番分かりやすい問題です。

いつ壊れたか分からない地獄

さらに厄介なのは、どこからでも好き放題 setter が呼べることです。

u.setPoint(100);   // Aクラス
...
u.setPoint(-50);   // Bクラス
...
u.setPoint(300);   // Cクラス
Java

「いつ」「どこで」「なぜ」この値になったのかが、
クラスの外のいろんな場所に散ってしまいます。

バグが出たときに、

どこが勝手に setter を叩いたのか
どの順番で値が変わったのか

を追いかけるのは、かなりしんどい作業になります。


不正な setter が壊しているもの(重要な深掘り)

一貫性(不変条件)を守る責任が消える

良いオブジェクトは、「自分が常に正しい状態でいる責任」を持ちます。

例えば、注文クラスなら

明細が 1 件以上ある
合計金額と明細の合計が矛盾しない
状態(NEW, SHIPPED, CANCELED)が整合している

などの「不変条件」があります。

ところが不正な setter があると、こう書けてしまいます。

final class Order {

    private List<OrderLine> lines;
    private int totalPrice;

    public void setLines(List<OrderLine> lines) { this.lines = lines; }
    public void setTotalPrice(int totalPrice) { this.totalPrice = totalPrice; }
}
Java

呼び出し側で

order.setLines(List.of());      // 明細 0 件
order.setTotalPrice(10_000);    // でも totalPrice は適当に 10000 に
Java

といった「矛盾した注文」を平気で作れてしまいます。

本来、Order クラスが「そんな状態は存在してはいけない」と言うべきなのに、
何でも受け入れてしまう setter のせいで、その責任が放棄されている状態です。

ルールの「居場所」が分からなくなる

本来、

ポイント計算のルールは User に
注文の整合性ルールは Order に

のように、「どこを見れば何が分かるか」が明確であるべきです。

しかし不正な setter がある設計では、

データ構造はドメインクラス(User, Order)
ルールはどこかの Service クラスや Util クラス

というふうに分裂してしまいます。

「ポイント仕様が変わったら、どこを直せばいい?」
「キャンセルルールが変わったら、どこを見るべき?」

に対して、コードを見ても答えが返ってきません。
いちいち grep して if を探しに行くことになります。


どう直すかの基本方針:setter をやめて「メソッドにルールを持たせる」

単純に「setter を消せ」と言っているわけではない

大事なのは「値を変えちゃダメ」ではなく
「値の変更を、そのオブジェクトの文脈に沿った操作にする」ことです。

先ほどの User を、ルールを持ったメソッドに書き換えてみます。

final class User {

    private final Long id;
    private String name;
    private String email;
    private int point;

    User(Long id, String name, String email, int point) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("名前は必須");
        }
        if (!email.contains("@")) {
            throw new IllegalArgumentException("メール形式がおかしい");
        }
        if (point < 0) {
            throw new IllegalArgumentException("ポイントは0以上");
        }
        this.id = id;
        this.name = name;
        this.email = email;
        this.point = point;
    }

    void changeName(String newName) {
        if (newName == null || newName.isBlank()) {
            throw new IllegalArgumentException("名前は必須");
        }
        this.name = newName;
    }

    void changeEmail(String newEmail) {
        if (!newEmail.contains("@")) {
            throw new IllegalArgumentException("メール形式がおかしい");
        }
        this.email = newEmail;
    }

    void addPoint(int added) {
        if (added < 0) {
            throw new IllegalArgumentException("マイナスは不可");
        }
        int newPoint = this.point + added;
        if (newPoint > 10_000) {
            newPoint = 10_000;
        }
        this.point = newPoint;
    }

    Long id() { return id; }
    String name() { return name; }
    String email() { return email; }
    int point() { return point; }
}
Java

ここでは、

名前を変えたい
メールを変えたい
ポイントを増やしたい

という “意味のある操作” ごとに専用メソッドを用意して、その中でルールをチェックしています。

呼び出し側は

user.changeName("山田太郎");
user.changeEmail("yamada@example.com");
user.addPoint(100);
Java

のように、「何をしたいか」がメソッド名として見える形になります。

これが「setter をやめて、意味のある操作メソッドにする」という基本方針です。


setter があっても許されるケースと、その注意点

DTO や外部 I/O 専用のクラス

画面とのやりとり、API のリクエスト/レスポンスだけに使う DTO などは、
「ただのデータ入れ物」と割り切って setter を持たせても構いません。

final class UserRequestDto {
    public String name;
    public String email;
}
Java

あるいは Jackson などのライブラリでシリアライズ/デシリアライズするために、
引数なしコンストラクタ+setter を要求されることもあります。

ただし、その DTO のままドメインの中心に持ち込まず、
必ず「ルールを持ったドメインオブジェクト」に変換することが大事です。

User toDomain() {
    return new User(null, this.name, this.email, 0);
}
Java

DTO に setter があるのはいいとして、
ドメインモデル(User, Order, Account など)側に安易な setter を生やし始めると、一気に不正な状態の温床になります。

ORM の都合で必要になる場合

JPA エンティティなどでは、ライブラリの制約上、
引数なしコンストラクタや setter が必要になることがあります。

その場合でも、

プロテクト/パッケージプライベートにして外部から触らせない
ビジネスロジックの中では極力使わない
実際の操作用には別のメソッド(changeEmail, addPoint など)を用意する

といった工夫で、「ドメイン的に不正な状態になれるルート」を塞いでいくことができます。


まとめ:不正な setter を見たときに自分に問いかけてほしいこと

不正な setter の問題は、

「何でも自由に書き換えられるドア」を開けっぱなしにした結果、
オブジェクトの一貫性も、ルールの居場所も、デバッグのしやすさも失うことにあります。

setter を書く前に、こんなふうに自分に問いかけてみてください。

このフィールドは、どんな値でも本当に入っていいのか
本当はルールがあるのに、それをすっ飛ばしていないか
「こういう操作」という名前付きメソッドにしたほうが自然ではないか
このオブジェクトは、どういう状態なら“必ず正しい”と言えるのか

それに「確かにルールあるな」と思ったら、
そのルールをクラスの中に閉じ込める方向で設計し直してみてください。

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