💤

ファットモデルにしないための設計

2023/12/16に公開

プロダクトの開発が進んでいくと、システムにとって重要なデータのモデルクラスはどんどん肥大化していきみんな大嫌いなファットモデルが誕生していきがちです。本記事では私が個人的にファットモデルを避けるためにやっている設計を紹介します。ファットモデルの話ではありますが、考え方的にはモデル以外でも適用可能だと思います。

なぜファットモデルになるのか?

単に責務分割ができてないって話もありますが、場合によっては責務としては妥当であるにも関わらずファットモデルになってしまっているケースも少なくないのかなと思います。10年とか運用しているプロダクトのモデルクラスなんかはそんな感じになっているのではないかなと思います。
この問題の原因はオブジェクト指向プログラミングの入門的な部分でよく紹介されるそのクラスのフィールドに対する操作はメソッドにやらせるべしという考え方が深く根付きすぎている点にあるのかなと思います。これを忠実に守りすぎると、モデルが重要なデータであればあるほど勝手にファットなモデルへと変貌していきます。

ではどう考えてメソッドは実装すべきなのでしょうか?本資料では簡単な例としてインスタンス同士が同じ値であるかどうかを示すequals系のメソッドを例に考えを説明していきます。

equals系のメソッドのよくある問題

まず、equalsメソッドには大きく分けて2つの形式があります。1つは「そのクラスのすべてのフィールドを参照して等価性を判定する」あるいは「クラスのインスタンスのアイデンティティとなるようなフィールドを参照して等価であることを判定する」といったようなプロダクトコード全体で一貫したルールで判定する形式です。そしてもう1つは「特定のフィールドのみを参照して等価であること判定する」といった特定のコンテキストでのみで有効な形式の判定です。
簡単な例を示すと以下のようになります。

// 形式1(すべての値を参照する場合)
type Obj struct {
    A int
    B int
    C int
}

func (this *Obj) Equals(other *Obj) bool {
    return this.A == other.A && this.B == other.B && this.C && other.C
}
// 形式1(アイデンティティがある場合)
type Obj struct {
    ID int
    X int
    Y int
}

func (this *Obj) Equals(other *Obj) bool {
    return this.ID == other.ID
}
// 形式2
type Obj struct {
    A int
    B int
    C int
}

func (this *Obj) EqualsForContextX(other *Obj) bool {
    return this.A == other.A && this.B == this.B
}

func (this *Obj) EqualsForContextY(other *Obj) bool {
    return this.A == other.A && this.C == this.C
}

モデルクラスのスコープをどこからでも参照可能なpublicなクラスであると仮定した場合、前者はクラスに実装しても全く問題ないように思います。なぜなら一貫したルールを持っているからこそ用途も明確で、複数ヶ所からの参照されていても変更に強い状態を保てるからです。
しかし、後者の場合はコンテキストに強い依存を持ちつつも、広いスコープを持つため以下のような問題が生じます。

  • 用途の狭さ(再利用性の乏しさ)に対してスコープが広すぎるためにそのメソッド存在意義を読み取りづらい
  • 用途を伝えるために冗長、かつ、ぎこちない命名になりやすくかえって分かりづらい名前になる可能性
  • 似たような別のメソッドが実装がされやすく、名前の複雑度が増す可能性
  • 想定外の用途(コンテキスト)でメソッドを再利用される可能性が高まる
  • 想定外の用途で再利用した場合に、全く関係のない要因による修正を強制される可能性(フラグによる回避などがされるケースではメソッドそのものが肥大化する場合も)

後者のequalsメソッドはどう作るべきか

コンテキストをパッケージとして分離し、そのパッケージのスコープにそれぞれのequalsメソッドを実装するだけでこの問題は解決できます。以下はgoの例なのでメソッドではなく関数として分離しています。

package model

type Obj struct {
    A int
    B int
    C int
}
package x

func equals(this, other *Obj) bool {
    return this.A == other.A && this.B == this.B
}
package y

func equals(this, other *Obj) bool {
    return this.A == other.A && this.C == this.C
}

equals系メソッドから考えるファットモデル原因

上記のequals系メソッドの問題が起きてしまうのは、冒頭で触れたそのクラスのフィールドに対する操作はメソッドにやらせるべしという考えのみを使ってメソッドを設計しており、コンテキストやスコープの概念を設計に盛り込めていないことが原因です。そしてこれは、他のメソッドでも同様に当てはまる話であると考えます。つまり、ファットモデルになってしまう原因はコンテキストとスコープの考慮不足であると私は考えます。

ファットモデルを避けるには

ここまでの説明から、ファットモデルを避けるためにはメソッドをそのクラスにフィールドに対する操作と捉えてるだけではなく、メソッドを動作させるコンテキストと公開するスコープを考慮してメソッドをクラスの外に分離してあげることが大事だと言うことがわかるかと思います。
「いやいや、フィールドの参照をクラスから分離したらまずいんじゃないの?」と考える人もいるかもしれませが、私はこれは問題ないと考えます。なぜなら、フィールドをクラスの中に局所化して参照するモチベーションは、複数のコンテキストにおいて共通のルールを抽出し再利用性を高めつつ修正の容易性も高めることだと考えているからです。これは先程の例とは全く逆の問題に対する解決策であると言いえます。

まとめ

まとめると、ファットモデルを作らないためには実装するメソッドを以下のように作り分けることが有効な手段となるかなと思います。

  • プロダクトコード全体で有効な普遍的な処理であるならクラスに直接実装する
  • ある特定のコンテキストにおいてのみ必要な処理であるなら、特定のパッケージ内部の関数等に分離する

Discussion