---
title: "新旧データの「同じもの」を、Identity型とDomainで判定する"
slug: "separating-domain-logic-for-complex-rules"
description: "識別方法が異なる新旧データを比較するために、Repositoryは候補取得に留め、同一性・曖昧さ・fallback判定をIdentity型とDomainへ集約した設計を整理します。"
publishedAt: "2026-07-15"
updatedAt: null
status: "published"
category: "engineering"
tags:
  - "domain-modeling"
  - "architecture"
  - "testing"
  - "data-mapping"
articleType: "technical-note"
source: "voice-interview"
publicRisk: "medium"
reviewNotes: "業務固有情報を抽象化し、新旧データの比較ロジックを一般化した設計事例として整理。"
series: null
episode: null
audience: "engineers"
lang: "ja"
aiAssisted: true
humanReviewed: true
canonicalUrl: null
coverImage: null
external:
  qiita: null
  zenn: null
  note: null
  x: null
  youtube: null
  podcast: null
---

## 対応判定はRepositoryではなくDomainに置く

システムを段階的に更新していると、新しい識別子を持つデータと、それを持たない過去データが同時に存在する期間があります。このとき必要なのは、単に両方を取得することではありません。識別方法の異なる二つのレコードを、どの条件なら「同じもの」とみなしてよいかを決めることです。

この問題では、**Repository は比較候補を取得するところまでとし、新旧データの対応判定は Domain に置く**ようにしました。

```mermaid
flowchart LR
  Repository["Repository<br/>比較候補を取得する"] --> Service["Service<br/>比較処理を調停する"]
  Service --> Domain["Domain<br/>同じものとみなせるか判定する"]
  Domain --> Result{"判定結果"}
  Result -->|primary key が一致| Matched["matched"]
  Result -->|fallback key が一意| Bridged["bridged"]
  Result -->|候補が複数| Ambiguous["ambiguous / 結ばない"]
```

Domain では、新旧それぞれの識別方法を条件分岐のまま持たず、`Identity` という値に変換します。`Identity` は通常使う `primary_key` と、過去データとの互換性を保つ `fallback_key` を持ちます。

この形にすると、旧データから新データを探す場合も、新データから旧データを探す場合も、同じマッチング規則を使えます。形式ごとの分岐を増やすのではなく、同一性の表現と対応判定を共通化できることが、この設計の利点です。

## なぜRepositoryだけでは解決しきれないのか

Repository は、条件に合うレコードを DB から取得したり、永続化形式をアプリケーションの型へ変換したりする場所です。完全一致するキーがあるなら、Repository や SQL の JOIN だけでも扱えます。

しかし、新旧データの橋渡しには次の判断が含まれていました。

- 新しい識別子があれば、そちらの一致を優先する
- 新旧をまたぐ場合だけ、互換用のキーへfallbackする
- fallbackの候補が複数あるなら、推測で一件に決めない
- 比較の向きが逆でも、同じ安全性を保つ

これらは「どの行を取得するか」ではなく、**二つのデータを業務上同じものと扱ってよいか**という判断です。SQL に書くこと自体はできますが、書けることと、その責務を Repository に置くことは別です。

今回の役割分担は、次のようにしました。

| 層 | 担当すること | 担当しないこと |
| --- | --- | --- |
| Repository | 比較候補の取得、保存、永続化形式の変換 | 新旧データを同じものとみなす判断 |
| Service | 取得、比較、レスポンス生成の流れを組み立てる | マッピング条件そのもの |
| Domain | Identityの生成、完全一致、fallback、一意性の判定 | DBやHTTPの都合 |

Repository にマッピングまで持たせると、取得方法と対応判定が一体になります。将来 DB の取得方法だけを変えたい場合にも、旧データとの互換ルールを読み解かなければなりません。反対に Domain へ分けると、候補をメモリ、DB、別APIのどこから取得しても、対応判定そのものは変えずに使えます。

## Identity型にprimary keyとfallback keyを持たせる

新旧データを一つの比較処理へ渡すために、まず各レコードから不変な `Identity` を作ります。

```py
from dataclasses import dataclass

IdentityKey = tuple[str, int, str]
FallbackKey = tuple[str, int, str]

@dataclass(frozen=True)
class Identity:
    primary_key: IdentityKey
    fallback_key: FallbackKey
```

`primary_key` は、そのデータ形式で最も強く識別できるキーです。新データでは追加された識別子を使い、旧データでは従来の属性を使います。

一方の `fallback_key` は、新旧の両方から作れる互換用のキーです。新データにもあえてこのキーを持たせることで、旧データと比較するときだけ同じ土俵へ降りられます。

| データ | primary_key | fallback_key |
| --- | --- | --- |
| 旧形式 | 従来属性による識別 | 従来属性による互換キー |
| 新形式 | 追加識別子による強い識別 | 旧形式からも作れる互換キー |

記事用に商品データへ置き換えると、生成処理は次のような形です。

```py
def build_identity(
    item: ItemRecord,
    checked_at: datetime,
) -> Identity:
    normalized_label = normalize_label(item.label)
    fallback_key = ("sku_label", item.sku_id, normalized_label)

    if should_use_variant_identity(checked_at):
        variant_id = normalize_variant_id(item.variant_id)

        if variant_id is not None:
            return Identity(
                primary_key=("variant", item.sku_id, variant_id),
                fallback_key=fallback_key,
            )

    return Identity(
        primary_key=("legacy", item.sku_id, normalized_label),
        fallback_key=fallback_key,
    )
```

ここで良かったのは、`if old ... else new ...` という分岐を、比較処理のあちこちへ持ち込まなかったことです。形式差は `Identity` を組み立てる時点で吸収し、その後の処理は二つのキーだけを見ます。

これは、振る舞いを何でもクラスへ詰め込む設計ではありません。`Identity` は比較に必要な意味を持つ小さなデータで、生成と解決は副作用のない関数として扱います。関数型のイディオムを大げさに導入せず、**データを正規化してから同じ関数へ流す**考え方だけを使っています。

## 同じ規則で旧から新へ、新から旧へ対応付ける

`Identity` を作ると、比較の向きごとに別のアルゴリズムを書く必要がなくなります。

```mermaid
flowchart LR
  Old["旧データのIdentity<br/>primary: legacy key<br/>fallback: bridge key"]
  New["新データのIdentity<br/>primary: variant key<br/>fallback: bridge key"]
  Old -->|旧から新を探す| Resolver["同じresolve関数<br/>primary優先<br/>fallbackは一意な場合だけ"]
  New -->|新から旧を探す| Resolver
  Resolver --> Old
  Resolver --> New
```

比較対象は、primary key と、一意な fallback key で引ける lookup にしておきます。fallback key が複数件に対応する場合、そのキーは `unique_by_fallback` へ入れません。

```py
@dataclass(frozen=True)
class IdentityLookup:
    by_primary: Mapping[IdentityKey, ItemRecord]
    unique_by_fallback: Mapping[FallbackKey, ItemRecord]
```

解決順序は、完全一致を優先し、それがなければfallbackを試すだけです。ただし、橋渡しの前に比較元と比較先の両方で一意性を確認します。

```py
def resolve_matching_item(
    source: ItemWithIdentity,
    source_lookup: IdentityLookup,
    target_lookup: IdentityLookup,
) -> ItemRecord | None:
    exact_match = target_lookup.by_primary.get(source.identity.primary_key)

    if exact_match is not None:
        return exact_match

    fallback_key = source.identity.fallback_key

    if fallback_key not in source_lookup.unique_by_fallback:
        return None

    return target_lookup.unique_by_fallback.get(fallback_key)
```

この関数へ旧データを `source` として渡せば旧から新を探せます。引数を入れ替えて新データを `source` にすれば、新から旧も同じ規則で探せます。

```mermaid
flowchart TD
  Start["sourceのIdentityを受け取る"] --> Exact{"targetのprimary keyに<br/>完全一致するか"}
  Exact -->|はい| ReturnExact["一致したデータを返す"]
  Exact -->|いいえ| SourceUnique{"source側でfallback keyが<br/>一意か"}
  SourceUnique -->|いいえ| NoMatch["対応付けしない"]
  SourceUnique -->|はい| TargetUnique{"target側でfallback keyが<br/>一意か"}
  TargetUnique -->|はい| ReturnFallback["fallbackで対応付ける"]
  TargetUnique -->|いいえ| NoMatch
```

この「両側で一意なときだけ結ぶ」が重要です。たとえば、一つの旧データに対応しそうな新データが二件あれば、どちらかを選ぶ根拠はありません。逆向きでも同じです。シームレスに扱うとは、曖昧さを隠して自動的に結ぶことではなく、**向きによらず同じ安全基準で結果を出せること**だと考えました。

## ロールアウト日はIdentity生成の補助条件にする

ロールアウト日の判定も必要ですが、今回の設計の中心ではありません。日付は「どちらの `primary_key` を作るか」を決める補助条件です。

```py
def should_use_variant_identity(checked_at: datetime) -> bool:
    checked_on = checked_at.astimezone(APP_TIMEZONE).date()
    return checked_on >= ROLLOUT_DATE
```

現在時刻ではなくデータ自身に記録された日時を使い、境界日とタイムゾーンの扱いを一箇所へ閉じ込めました。比較処理は日付を知らず、完成した `Identity` だけを受け取ります。

## Domainに置くことで何が良くなったか

この分割によって、単にファイルの置き場所が変わったわけではありません。変更理由が分かれました。

```mermaid
flowchart LR
  Router["Router<br/>HTTP"] --> Service["Service<br/>ユースケースの流れ"]
  Service --> Repository["Repository<br/>候補の取得"]
  Service --> Domain["Domain<br/>Identityと対応判定"]
  Repository --> DB["Database"]
  Domain -. "DBやHTTPへ依存しない" .-> Rule["純粋なデータと関数"]
```

- 取得条件やDB構成が変わった場合は、Repositoryを変更する
- APIの入出力や処理順が変わった場合は、Serviceを変更する
- 同一性、fallback、一意性の定義が変わった場合は、Domainを変更する

特に、Domain が SQLAlchemy、Pydantic、FastAPI を知らないことが効きました。マッピング規則を小さな入力だけでテストでき、レビューでも「この二件を同じものと扱ってよいか」という本来の論点へ集中できます。

Repository へ複雑な JOIN を追加して結果だけ返す設計では、なぜ候補が除外されたのか、どの曖昧さを避けたのかが見えにくくなります。Domain の `Identity` と解決関数として表すことで、その判断をコード上の仕様として残せました。

## テストはマッピング仕様として並べる

テストも、ロールアウト日の前後より、Identity とマッピングの組み合わせを中心にしました。

| ケース | 期待する結果 |
| --- | --- |
| primary keyが一致する | fallbackを使わず一致する |
| 旧から新を探し、fallbackが両側で一意 | 対応付ける |
| 新から旧を探し、fallbackが両側で一意 | 対応付ける |
| 比較元に同じfallbackが複数ある | 対応付けない |
| 比較先に同じfallbackが複数ある | 対応付けない |
| primary keyは不一致でfallbackもない | 対応付けない |
| 追加識別子が空 | 旧形式としてIdentityを作る |
| ロールアウト境界日 | 新形式のprimary keyを作る |

```txt
TestBuildIdentity
  ├ test_builds_new_primary_key_with_legacy_fallback
  ├ test_builds_legacy_primary_key_before_rollout
  └ test_treats_empty_variant_id_as_missing

TestResolveMatchingItem
  ├ test_primary_key_match_takes_priority
  ├ test_maps_old_to_new_when_both_sides_are_unique
  ├ test_maps_new_to_old_when_both_sides_are_unique
  ├ test_rejects_ambiguous_source_fallback
  └ test_rejects_ambiguous_target_fallback
```

この並びにすると、テスト名自体が新旧マッピングの仕様になります。DB や HTTP を起動せずに確認できるため、fallbackの条件を変更するときも影響範囲を追いやすくなりました。

## この設計で気をつけたいこと

この設計なら何でも Domain へ移せばよい、という話ではありません。特に次の三点は明示しておく必要があります。

1. **fallback keyにはドメイン上の意味が必要です。** たまたまDBにあるカラムを連結しただけでは、Repositoryの都合をDomainへ移しただけになります。「なぜこの組み合わせなら過去データと同じ可能性があるのか」を説明できる属性を使います。
2. **曖昧さを呼び出し側が区別したいなら、`None`だけで返しません。** 未一致と候補複数で後続処理が変わる場合は、`Matched`、`NotFound`、`Ambiguous` のような結果型にします。
3. **fallbackには終了条件が必要です。** 過去データの保持期間が終わった後も互換ロジックを残すと、Identityの意味が複雑なまま固定されます。いつ削除できるかも移行設計に含めます。

重要なのは「Domain層を作ること」そのものではありません。Repository の取得責務と、データを同一とみなす意味判断を分け、その判断を小さな型と純粋な関数で読める形にすることです。

## まとめ

新旧データのマッピングでは、Repository が両方のデータを取得できても、それだけでは対応関係を決められません。完全一致を優先するのか、いつfallbackしてよいのか、候補が複数ならどうするのかは、ドメインの判断です。

今回の設計では、次の形に整理しました。

- Repositoryは比較候補の取得に留める
- Domainで各データを`Identity(primary_key, fallback_key)`へ変換する
- primary keyの完全一致を優先する
- fallbackは比較元と比較先の両方で一意な場合だけ使う
- 同じ解決関数で旧から新、新から旧の両方を扱う
- ロールアウト日はIdentityを作るための補助条件に留める

この形なら、新旧の形式差をserviceやrepositoryの分岐として広げず、「同じものとみなせるか」という判断を一箇所で説明し、テストできます。新旧データを扱うときは、取得方法を考えるだけでなく、同一性をどの層が判断するのかを先に切り分けることが重要です。
