02データベース5 min

同じ契約モデルを十回以上書き直しました

込み入ったドメインモデルは一発ではうまくいきません。実データを前にして書き直しながら収束していくもので、そこには正しく見えた判断を撤回することも含まれます。

著者: Salva Castells

カバー画像: Ernst Alban, パブリックドメイン.
この記事の内容

大企業向けの BI ツールのために最初に書いた契約モデルは、SQL で八十行ほどで、悪くないと思っていました。契約がひとつ、開始日、終了日、そこにぶら下がる条件。きれいに読めました。一年後、同じモデルを十回以上書き直していました。名前が嘘をついていたので改名した回も含めて。

計画が悪かったのではありません。ドメインは本物のデータが入ってきて初めて見えるのです。

最初のモデルがいつも正しく見える理由

説明されたドメインに対して書いていて、存在するドメインに対して書いていないからです。「契約には終了日がある」と言われます。それをモデル化します。目にした二十行では動きます。

最初の警告は名前で来ました。モデルは kpi_02_20_revenue_contracts という名前で、kpi_02_20_rent_contracts に改名しました。revenue ではなく rent、つまり会社が受け取る側ではなく支払う側の賃貸契約でした。符号が逆です。名前はテーブルからではなくチケットから来ていました。中身と逆の名前のモデルを相手に一か月作業していたわけです。

間違った名前は飾りの問題ではありません。誰かが読んで、そのとおりだと思い込みます。

本物のデータが入って初めて現れるもの

終了日が落とし穴でした。テーブルにはカラムが二つ、DT_FIRST_CONTRACT_END と DT_CONTRACT_END_NEXT がありました。統合した日付の最初の版はこれです。

case
    when c.DT_FIRST_CONTRACT_END is null then c.DT_CONTRACT_END_NEXT
    when c.DT_CONTRACT_END_NEXT is null then c.DT_FIRST_CONTRACT_END
    when c.DT_FIRST_CONTRACT_END < CURRENT_DATE() then c.DT_CONTRACT_END_NEXT
    else c.DT_FIRST_CONTRACT_END
end as dt_end_merged

網羅的に見えます。そうではありません。両方が null の場合が抜けていますし、最初が過去で二つ目が存在しない場合も抜けています。データ上、この二つはどちらも同じ意味、つまり無期限契約でした。この case はそれらを偶発的な null に送り、あとで「失効」として数えられていました。最終版はその二つの分岐を明示し、is_unlimited フラグに when dt_end_merged is null then True を足しています。ここでの null は情報の欠如ではなく、情報だからです。

そしてそのフラグ自身が、実世界からの逸品をすでに抱えていました。

when year_end_merged in (8909, 8899, 9999) then True

「終了なし」を表すセンチネルの年が三種類。どれも文書化されていません。三つとも本番にあります。

判断を撤回した三つの場面

いちばん示唆的なのは 99 年です。元データの Excel には、年が 99 として入っている終了日があり、チームの誰かがそれは「無期限」の意味だと言っていました。そこで処理を足しました。

case
    when {{ normalize_date('ew.WARUNEK_WA_NY_DO') }} like '%99%' then '2099-12-31'::date
    else {{ normalize_date('ew.WARUNEK_WA_NY_DO') }}::date
end

加えて、その行が日付のウィンドウで落ちないように or ... like '%99%' というフィルタも足しました。三日後に外しました。like '%99%' は 1999 にも 2099 にも、日や月に 99 を含むあらゆる日付にも一致します。二十年前に終わった契約を、有効な revenue の計算に混ぜていたのです。業務ルールとしては正しかったのかもしれませんが、日付の文字列に対する like での実装は正しくありませんでした。

二か月後、同じ問題が別の扉から戻ってきました。再交渉のモデルでの二桁の年です。今度は文字列ではなく、抽出済みの年に対して処理しました。

case
  when year(normalized_next_possible_termination_old_contract) < 100
    then year(...) + 2000
  else year(...)
end

これも無傷では残りませんでした。二週間後には -- ! を前に付けてファイル内でコメントアウトされていました。同じカラムを巡る三周目です。

もうひとつ大きなロールバックは、契約と条件の inner join でした。条件を持たない契約は現れません。left join に変えるとダッシュボードの数字が変わり、それこそが KPI を依頼した人と交わすべき会話です。条件のない契約を数えるのか、数えないのか。最初は私がひとりで inner と決めていて、自分が何かを決めていることにすら気づいていませんでした。

モデルが安定したと分かる基準

コミットの種類で分かります。最初の一年、コミットは構造的でした。join が変わり、フィルタが入っては出て、カラムが改名されます。プロジェクトの終盤、コミットは別の種類になりました。100 倍の乗数を外す、二つの case をひとつに統合する、カラムのドキュメントを書く。これは、下からは動かなくなり、上でだけ動くモデルです。

もうひとつのサインは、ドキュメントの .yml が曖昧な四つの箇条書きから、正確な選択基準を並べたものに変わることです。対象エンティティ、契約の種類、通貨、何を除外し、なぜ除外するのか。そのリストを SQL を見ずに書けるようになったとき、モデルは本当に存在しています。それまであるのはクエリです。

いま最初の CREATE TABLE の前にやること

以前はモデルを書いてからデータを見ていました。いまは逆で、この順です。

  • 条件に関わるカラムそれぞれの null を数える。 null が 40 パーセントあるカラムは日付ではなく、二つの概念の混ざり物です。
  • センチネル値を探す。 select distinct year(...) order by 1 desc。9999 や 8899 や 2099 が出てきたら、そこに文書化されていない業務ルールが隠れています。
  • SQL より先に、フィルタを文章で書く。 どの契約が対象かを一文で言えないなら、where は矛盾する六つの条件とコメントアウトされた二つで終わります。
  • 正規化は型に対して行い、文字列に対しては行わない。 日付に対する like はすべて時限爆弾です。

ドメインモデルは設計するものではありません。データと交渉するものであり、データは譲りません。何かを撤回したときはいつも、聞くべきことを自分で決めてしまっていたときでした。