この記事でいいたいこと
複数ソースから取得したデータを「合成(merge)」する処理を書くとき、つい 1 つの関数に全部のロジックを詰め込みがちです。今週、Rust であるドメインを設計するなかで、merge を「合成する対象の粒度」ごとに階層的に分解し、それぞれのドメインオブジェクトに単一の責務として持たせるという設計を実践しました。
結果として、各オブジェクトが「自分の粒度でどう合成されるか」だけを知っていればよくなり、テストも読みやすさも段違いに良くなりました。その学びを、架空の「複数拠点の観測記録を統合する」という題材でまとめます。
何が問題だったか
やりたいことは「2 つのデータ集合をマージする」だけ。でも実際のデータには複数の粒度が絡んでいました(以下は架空の例です)。
- 記録には 拠点A / 拠点B / 種別不明 という区分がある
- それぞれの区分の中に 複数日付 のデータが並ぶ
- さらに 1 日付の中に 気温・降水量などの複数フィールド がある
これを 1 つの merge 関数で処理しようとすると、こうなります。
// アンチパターン: すべての粒度を 1 関数で処理する
fn merge_all(a: Group, b: Group) -> Group {
// 拠点A・拠点B・不明のループ
// その中で日付のマッチング
// さらにその中でフィールドごとの Option の合成
// ... ネストが深く、責務が混ざる
}
区分のループ・日付のマッチング・フィールドの合成という 3 つの異なる関心事 が 1 か所に混ざり、テストも「全部入り」でしか書けません。どこかを直すと関係ない部分まで壊れる、典型的な密結合です。
解決方針: 「合成の粒度」でドメインを分ける
ポイントは、データが階層構造なら merge も同じ階層で分けるということです。各層は自分のすぐ下の層に合成を委譲するだけにします。
Group(拠点A/拠点B/不明)
└─ Records(日付のリスト)
└─ Record(1日付=複数フィールド)
それぞれの層に、その粒度でのマージ責務だけを持たせます。
第1層: フィールドの合成(一番小さい単位)
1 日付分のデータ。責務は「フィールドごとに、値がある方を優先して埋める」だけ。
#[derive(Clone, PartialEq, Debug)]
struct Record {
date: u32,
temperature: Option<i64>,
rainfall: Option<i64>,
}
impl Record {
// 自分の値を優先し、無ければ相手で補完する
fn merge(self, other: Record) -> Record {
Record {
date: self.date,
temperature: self.temperature.or(other.temperature),
rainfall: self.rainfall.or(other.rainfall),
}
}
}
この層は「日付のマッチング」も「拠点区分」も一切知りません。知っているのは Option::or によるフィールド単位の優先ルールだけです。
第2層: 日付リストの合成
日付のリスト。責務は「同じ日付どうしを突き合わせる」ことだけ。同じ日付が見つかれば下の層(Record::merge)に委譲し、無ければそのまま残します。
#[derive(Clone, PartialEq, Debug)]
struct Records {
list: Vec<Record>,
}
impl Records {
fn merge(self, other: Records) -> Records {
let self_dates: Vec<u32> = self.list.iter().map(|r| r.date).collect();
// 自分側の各日付は、相手に同日付があれば「合成」して返す
let mut merged: Vec<Record> = self
.list
.into_iter()
.map(|r| match other.list.iter().find(|o| o.date == r.date) {
Some(o) => r.merge(o.clone()), // ← 下の層へ委譲
None => r,
})
.collect();
// 相手側にしか無い日付を追加する
merged.extend(
other.list.into_iter().filter(|o| !self_dates.contains(&o.date)),
);
Records { list: merged }
}
}
この層はフィールドの中身をどう合成するかを知りません。「同じ日付なら Record に任せる」と委譲するだけ。関心事は「キー(日付)のマッチングと集合の合成」に閉じています。
第3層: 区分グループの合成(一番外側)
拠点A / 拠点B / 不明の 3 区分をまとめたもの。責務は「対応する区分どうしをマージする」ことだけ。中身は下の層(Records::merge)に丸投げします。
#[derive(Clone, PartialEq, Debug)]
struct Group {
station_a: Records, // 拠点A
station_b: Records, // 拠点B
unknown: Records, // 種別不明
}
impl Group {
fn merge(self, other: Group) -> Group {
Group {
station_a: self.station_a.merge(other.station_a),
station_b: self.station_b.merge(other.station_b),
unknown: self.unknown.merge(other.unknown),
}
}
}
この層のコードは驚くほど短くなります。「区分ごとに対応するものを合成する」という 1 つの意図しか書かれていないからです。
この分け方が効いてくるポイント
1. 各層が「自分の粒度の言葉」だけで書ける
-
Groupは「区分」の言葉で語る -
Recordsは「日付」の言葉で語る -
Recordは「フィールドの優先ルール」の言葉で語る
読むときに「今はどの粒度の話か」がコードの階層と一致しているので、認知負荷が下がります。
2. テストが層ごとに独立する
単一責務にすると、各層のテストが「その層のロジック」だけを対象にできます。下の層はモックにして、その層が本当に検証したいことだけを検証します。
たとえば Group::merge のテストは「区分ごとに正しく下の層へ委譲しているか」だけを見ればよく、フィールドの合成ルールまで一緒に検証する必要がありません。
// Group のテスト: 中身の合成は Records に委譲されることだけを確認する
// (下の層はモックにして、フィールドの優先ルールはここでは検証しない)
#[test]
fn group_merge_delegates_per_section() {
let mut station_a = mock_records();
station_a
.expect_merge()
.returning(|_| merged_station_a()); // 委譲先の結果を差し込む
// station_b / unknown も同様...
let actual = group.merge(other);
assert_eq!(actual, expected); // 「区分ごとに合成結果が入る」ことだけ検証
}
Record::merge のテストは逆に、Some/None の組み合わせというフィールド優先ルールだけを網羅すればよくなります(両方 Some / 自分だけ Some / 相手だけ Some / 両方 None の 4 パターンなど)。責務が分かれているから、テストの観点も自然に分かれます。
3. 変更の影響範囲が閉じる
「フィールドの優先ルールを変えたい」となっても、触るのは Record だけ。日付のマッチングや区分の扱いには一切波及しません。責務の境界が、そのまま変更の防波堤になります。
学びの一般化
今回の経験を抽象化すると、次のように言えます。
データが階層構造を持つなら、そのデータに対する振る舞い(merge など)も同じ階層で分割し、各層は「自分の粒度の関心事」だけを持ち、それより細かい合成は下の層へ委譲する。
これはまさに 単一責任の原則(Single Responsibility Principle) のドメインモデルへの適用です。「1 つのオブジェクトが変更される理由は 1 つだけであるべき」という原則を、merge という 1 つの操作を通して具体的に体感できました。
チェックリストとして自分に問うようにしています。
- この関数は 2 つ以上の粒度 のことを同時に気にしていないか?
- ネストが深いのは、本来別の層に分けるべき責務が混ざっているサインでは?
- テストが「全部入り」でしか書けないなら、責務が分かれていない証拠では?
まとめ
- merge のような合成処理は、データの階層に合わせて分解すると単一責務に落とし込める
- 各層は「自分の粒度」だけを扱い、細かい合成は下の層へ委譲する
- 結果として、コードは各層で読みやすくなり、テストは独立し、変更の影響範囲が閉じる
- これはドメインモデルにおける単一責任の原則の、具体的で実践的な適用例だった
「合成のロジックを 1 か所に集める」のではなく「合成の責務を粒度ごとに分けて配置する」。この視点の転換が今週いちばんの学びでした。