新旧データの「同じもの」を、Identity型とDomainで判定する
識別方法が異なる新旧データを比較するために、Repositoryは候補取得に留め、同一性・曖昧さ・fallback判定をIdentity型とDomainへ集約した設計を整理します。
対応判定はRepositoryではなくDomainに置く
システムを段階的に更新していると、新しい識別子を持つデータと、それを持たない過去データが同時に存在する期間があります。このとき必要なのは、単に両方を取得することではありません。識別方法の異なる二つのレコードを、どの条件なら「同じもの」とみなしてよいかを決めることです。
この問題では、Repository は比較候補を取得するところまでとし、新旧データの対応判定は Domain に置くようにしました。
図を読み込み中...
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 を作ります。
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 |
|---|---|---|
| 旧形式 | 従来属性による識別 | 従来属性による互換キー |
| 新形式 | 追加識別子による強い識別 | 旧形式からも作れる互換キー |
記事用に商品データへ置き換えると、生成処理は次のような形です。
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 を作ると、比較の向きごとに別のアルゴリズムを書く必要がなくなります。
図を読み込み中...
比較対象は、primary key と、一意な fallback key で引ける lookup にしておきます。fallback key が複数件に対応する場合、そのキーは unique_by_fallback へ入れません。
@dataclass(frozen=True)
class IdentityLookup:
by_primary: Mapping[IdentityKey, ItemRecord]
unique_by_fallback: Mapping[FallbackKey, ItemRecord]
解決順序は、完全一致を優先し、それがなければfallbackを試すだけです。ただし、橋渡しの前に比較元と比較先の両方で一意性を確認します。
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 にすれば、新から旧も同じ規則で探せます。
図を読み込み中...
この「両側で一意なときだけ結ぶ」が重要です。たとえば、一つの旧データに対応しそうな新データが二件あれば、どちらかを選ぶ根拠はありません。逆向きでも同じです。シームレスに扱うとは、曖昧さを隠して自動的に結ぶことではなく、向きによらず同じ安全基準で結果を出せることだと考えました。
ロールアウト日はIdentity生成の補助条件にする
ロールアウト日の判定も必要ですが、今回の設計の中心ではありません。日付は「どちらの primary_key を作るか」を決める補助条件です。
def should_use_variant_identity(checked_at: datetime) -> bool:
checked_on = checked_at.astimezone(APP_TIMEZONE).date()
return checked_on >= ROLLOUT_DATE
現在時刻ではなくデータ自身に記録された日時を使い、境界日とタイムゾーンの扱いを一箇所へ閉じ込めました。比較処理は日付を知らず、完成した Identity だけを受け取ります。
Domainに置くことで何が良くなったか
この分割によって、単にファイルの置き場所が変わったわけではありません。変更理由が分かれました。
図を読み込み中...
- 取得条件や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を作る |
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 へ移せばよい、という話ではありません。特に次の三点は明示しておく必要があります。
- fallback keyにはドメイン上の意味が必要です。 たまたまDBにあるカラムを連結しただけでは、Repositoryの都合をDomainへ移しただけになります。「なぜこの組み合わせなら過去データと同じ可能性があるのか」を説明できる属性を使います。
- 曖昧さを呼び出し側が区別したいなら、
Noneだけで返しません。 未一致と候補複数で後続処理が変わる場合は、Matched、NotFound、Ambiguousのような結果型にします。 - fallbackには終了条件が必要です。 過去データの保持期間が終わった後も互換ロジックを残すと、Identityの意味が複雑なまま固定されます。いつ削除できるかも移行設計に含めます。
重要なのは「Domain層を作ること」そのものではありません。Repository の取得責務と、データを同一とみなす意味判断を分け、その判断を小さな型と純粋な関数で読める形にすることです。
まとめ
新旧データのマッピングでは、Repository が両方のデータを取得できても、それだけでは対応関係を決められません。完全一致を優先するのか、いつfallbackしてよいのか、候補が複数ならどうするのかは、ドメインの判断です。
今回の設計では、次の形に整理しました。
- Repositoryは比較候補の取得に留める
- Domainで各データを
Identity(primary_key, fallback_key)へ変換する - primary keyの完全一致を優先する
- fallbackは比較元と比較先の両方で一意な場合だけ使う
- 同じ解決関数で旧から新、新から旧の両方を扱う
- ロールアウト日はIdentityを作るための補助条件に留める
この形なら、新旧の形式差をserviceやrepositoryの分岐として広げず、「同じものとみなせるか」という判断を一箇所で説明し、テストできます。新旧データを扱うときは、取得方法を考えるだけでなく、同一性をどの層が判断するのかを先に切り分けることが重要です。