事務所への来客——予約どおりの15時
雑居ビルの三階。階段を上がって、ドアの前で立ち止まる。スマホの時刻は15:00。メールには「木曜の15時に来たまえ」とだけ書かれていた。差出人は「ロック」。署名もなければ、会社概要のURLもない。
社外の技術ブログのコメント欄でLCIの名前を見つけたのは、先月の障害の事後分析レポートを読み漁っていた夜のことだ。「この手の問題ならLCIに相談してみては」——それだけの情報を頼りにサイトを見つけ、メールを送った。返信は一行。
ドアをノックする。
「開いている」
押し開けると、デスクの上にCasioの関数電卓——fx-991——の筐体と基板が並んでいた。男がひとり、虫眼鏡を片手に基板のチップを覗き込んでいる。くたびれたジャケット。エナジードリンクの缶は1本だけ。飲みかけ。
「このチップはBCD演算ユニットだ。10進数をそのまま扱う。浮動小数点を使わない。だから電卓は 0.1 + 0.2 を正確に 0.3 と表示できる」
虫眼鏡から目を離さずに言う。
「——一方、君のサーバーは?」
「あの、15時のお約束で伺いました。決済チームの——」
「ああ、メールの」ようやく虫眼鏡を置いた。「金額が1円ずれる件と、通貨が混ざる件だね。——見せたまえ、ワトソン君」
一拍、間が空いた。
「……それ、やめていただけますか」
ロックさんは気にする素振りすらなく、椅子をくるりと回してデスクに向かった。
「コードを見せたまえ、と言っているんだ」
まあ、呼び方より中身だ。バッグからノートPCを取り出す。
2つの障害を説明する。淡々と、だが語尾に力がこもる。
「先月、請求金額が1円ずれる障害が本番で出ました。原因は税率計算の浮動小数点丸め誤差です。修正はしましたが、対症療法です」
「修正の翌週、今度は別の箇所で、USD建ての送金額にJPY建ての手数料が加算されるバグが出ました。コード上は $amount + $fee で、どちらもただの数値です。通貨が別変数で管理されていて、足し算の時点ではチェックがありません」
「2つの障害の根っこが同じなのか別なのか、わたしにはわかりません。わかっているのは、金額の計算が信用できない状態だということです」
ロックさんはノートPCの画面をこちらに向けるよう促しながら、静かに言った。
「2つの事件は根が同じだ。金額がValue Objectになっていない——つまり、金はただの数字として放置されている。数字には名前がなく、通貨がなく、振る舞いがない。名前のない金は、幽霊と同じだよ」
容疑者の浮上——幽霊通貨の正体
ロックさんがノートPCの画面を覗き込む。わたしの決済モジュールのコードが映っている。
| |
「price は何型だね?」ロックさんが $sum += $item->{price} * $item->{quantity} の行を指でなぞる。
「Numです。浮動小数点数」
「そして currency は別のフィールドにいる。add_fee を見たまえ。$self->total + $fee_amount——この $fee_amount の通貨は誰が保証しているかね?」
「……呼び出し側の責任です。ドキュメントには『同一通貨で渡すこと』と書いてあります」
ロックさんがデスクの上の電卓基板をちらりと見た。
「ドキュメントで防ぐ。——先日の依頼人も同じことを言っていたよ」
何の話かわからなかったが、「ドキュメントで防ぐ」が不十分だという指摘は理解できた。
「容疑者は二人いる」ロックさんが言った。「第一の容疑者——浮動小数点数。証拠を見せよう」
ロックさんは自分のThinkPad——電卓の隣に置いてあった年代物——のターミナルを開いた。
| |
画面を凝視する。息が止まった。
「……知識としては知っていました。IEEE 754。でも——」
「知っていることと、自分のコードに当てはまると認識することは別の能力だよ、ワトソン君。もう1つ」
| |
「0.01を100回足して……1にならない」自分でも声のトーンが上がったのがわかった。
「税率8%を掛けた瞬間、結果は近似値になる。その近似値を sprintf '%.0f' で丸めたとき、1円上に行くか下に行くかは入力値の組み合わせで変わる。再現条件が複雑すぎて人間には追えない。——だが整数なら、1 + 1 は常に 2 だ」
「つまり、金額を最初から整数で……セント表現?」
「そうだ。$19.99は整数1999。¥1000はそのまま整数1000。日本円には小数がないからな。最小通貨単位の整数で保持すれば、加減算から浮動小数点が消える」
ロックさんは一呼吸置いて、続けた。
「第二の容疑者——通貨の不在」
わたしのコードの add_fee を指さす。
「$self->total + $fee_amount。Perlはこの2つの数値を足す。通貨のことは何も知らない。金額と通貨が別の変数にいる限り、コードは通貨を無視する自由を持つ」
言葉を切って、こちらを見る。
「金額と通貨は、パスポートの写真と名前のようなものだ。剥がした瞬間、誰の顔かわからなくなる」
「だからMoneyオブジェクトで一体化する、ということですか」
「正確には、分離不可能にする。金額だけを取り出して裸で演算することを、構造的に不可能にするのだ」
鮮やかなリファクタリング——Money Value Object
ロックさんがThinkPadをわたしのノートPCの隣に並べた。
「Moneyクラスを作る。金額は整数。通貨は文字列。どちらも ro——不変だ」
ステップ1: 骨格
| |
「fallback => 0 って何ですか?」
「定義していない演算子が使われたとき、Perlが勝手に推測して動かすのを禁止する宣言だ。fallback => 1 なら $money / $money2 のような未定義の演算が暗黙的に動き得る。fallback => 0 なら即座にエラーだ。許可していない操作がエラーになることこそが安全だ」
「ホワイトリスト方式ですね。許可した演算だけが動く」
ステップ2: 通貨チェックと加減算
| |
「_add の冒頭を見たまえ。まず $other がMoneyオブジェクトかどうかを確認する。次に通貨が同じかを確認する。どちらかが満たされなければ、即座に死ぬ」
「$amount_usd + $fee_jpy を書いたら croak する」
「正確には、$usd_money + $jpy_money を書いたら croak する。裸の数値とは足せないし、異なる通貨とも足せない。二重の防壁だ」
あの通貨混合バグが頭をよぎった。USD建ての送金額にJPY建ての手数料を加算してしまったコード。もしMoneyオブジェクトを使っていたら——コードが動く前にエラーになっていた。
ステップ3: スカラー乗算
| |
「掛け算の $other がMoneyだったらエラーにしてる。Money × Moneyを禁止してるんですね」
「100円 × 200円は何になるかね? 20000 円円? ——意味がないだろう。Moneyの掛け算はスカラー倍だけが正当だ。3個買うなら $price * 3。税込み計算なら $price * 1.08。許可する演算と禁止する演算を型で宣言するのがValue Objectの仕事だ」
「int($self->amount * $other + 0.5) は……四捨五入ですか?」
「最も単純な丸めだ。本番では丸め戦略を引数で受け取るか、別のメソッドに切り出すのが望ましい。Ward Cunninghamは『Moneyオブジェクト自体は丸めるべきではない。丸めはトランザクションのコンテキストで行うべき』と言っている。今回は構造の説明を優先して簡略化した」
ステップ4: 等価性と文字列化
| |
「== は金額と通貨の両方が一致して初めて true だ。USD 1999 と JPY 1999 は等しくない。——これがValue Objectの等価性だ」
属性の値で等しさを判定する。Entity vs Value Objectの概念——どこかで読んだ記憶がある。MoneyにIDはない。金額と通貨が同じなら、同じMoneyだ。
ステップ5: ファクトリメソッド
| |
「人間は 19.99 と書きたがるし、表示も $19.99 でしてほしい。from_major は人間向けの数値を整数に変換し、to_major は逆方向だ。内部は常に整数、外部とのやりとりにだけ変換を使う」
「JPYの minor_unit が1になってる。日本円は小数点以下がないから、そのまま」
「ISO 4217では通貨ごとに小数点以下の桁数が定義されている。日本円は0、米ドルは2、バーレーン・ディナールは3だ。この %CURRENCY_MINOR_UNIT が通貨ごとの違いを吸収する」
MooX::StrictConstructor にも目が止まった。タイプミスが即座にエラーになる仕組み——不変オブジェクトでは構築時の正しさが全て、という話をどこかで聞いたような気がする。
「Moneyは不変だ。$money->amount(500) とは書けない。金額を変えたければ、新しいMoneyを作る」ロックさんが言った。「不変性はMoneyの前提条件であって、オプションではない。100円が勝手に200円に変わる紙幣を信用する人間はいないだろう?」
確かに。金額が可変なら、共有されたMoneyオブジェクトの値がどこかで書き換えられるリスクがある。不変であれば安全に共有できる。
classDiagram
class Money {
-Int amount
-Str currency
+amount() Int
+currency() Str
+from_major(amount, currency)$ Money
+to_major() Num
+_add(other) Money
+_subtract(other) Money
+_multiply(scalar) Money
+_equal(other) Bool
+_stringify() Str
}
class Invoice {
+Money total_amount
+add_tax(rate) Money
+add_fee(Money fee) Money
}
Invoice --> Money : 金額はすべて Money
note for Money "amount + currency 一体化\n整数セント表現\n演算子オーバーロード\n通貨不一致 = 即croak"
事件の終わり——テストが証明する金額の正体
ターミナルを開いた。ロックさんの指示でテストを書く。
| |
テスト実行。全テストパス。グリーン。
モニターの All tests successful を見ながら、息を一つ吐いた。
「……全部通りました」
「通貨不一致はエラーになる。Money同士の掛け算もエラーになる。浮動小数点の丸め誤差は、そもそも発生しない」
ロックさんが静かに続けた。
「犯人は裸の数値だった。名前のない金が、名前のあるMoneyになった。それだけのことだ」
「それだけのこと、ですか」わたしは画面を見つめたまま言った。「でも、それだけで2つの障害が両方消える」
「構造が正しければ、バグのカテゴリ全体が消える。1件のバグを潰すのではなく、バグが棲める場所を消すのだ」
探偵の調査結果——そしてドメインの深淵
ロックさんがThinkPadを閉じた。わたしもノートPCをバッグにしまいかける。
だが、一つだけ聞きたいことがあった。
「ロックさん。ドメインの深淵って、結局なんなんですか」
自分でもなぜそんな言葉が出たのかわからない。ただ、金額が「ただの数値」として放置されていた状況——名前もなく、境界もなく、振る舞いもない——それを「深淵」と呼びたくなった。
ロックさんは関数電卓の基板をデスクに戻しながら、少し間を置いた。
「型のない世界だよ。値が値としての権利——名前も、境界も、振る舞いも——を持たない世界だ。金額はただの数字。住所はただの文字列。注文はただのハッシュ」
それから、こちらを見た。
「ワトソン君、深淵を覗き返すには 型を与える 以外にないのだ」
数字に名前を与え、境界を定め、振る舞いを付与する。それはMoneyだけの話ではない。もっと大きな——うまく言葉にできないが、設計全体を貫く一本の線のようなものが見えた気がした。
「報酬は——そうだな」ロックさんがデスクの上を見渡す。「10進数で正確に表現できないドリンクを1本。0.1リットルのエスプレッソだ」
「……それ、普通のエスプレッソですよね」
「普通かどうかは浮動小数点に聞いてくれたまえ」
ドアに手をかけ、小さく苦笑した。
帰りの電車。スマホのメモアプリに Money->new(amount => 1999, currency => 'USD') と打ち込んでみる。
金額に名前がつくだけで、こんなに安心できるものなのか。数字が型になった瞬間、1円の幽霊は消えた。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| 浮動小数点数での金額計算 | 整数セント表現(最小通貨単位) | 加減算から丸め誤差が構造的に消滅 |
| 金額と通貨の分離管理 | Money Value Object(金額+通貨一体化) | 異なる通貨の演算が即座にエラー |
| 裸の数値による暗黙的な型変換 | 演算子オーバーロード+fallback => 0 | 未定義の操作がエラーになる安全設計 |
| ドキュメントによる通貨チェック | _assert_same_currency による構造的保証 | 実行時に通貨不一致を自動検出 |
推理のステップ
- 整数化: 金額を最小通貨単位の整数で保持する。USD $19.99 →
1999、JPY ¥1000 →1000。通貨ごとの小数桁数(ISO 4217)に応じて変換係数を定義する - 一体化:
amount(整数)とcurrency(文字列)を1つのMoneyオブジェクトに封じ込める。is => 'ro'で不変性を保証する - 演算子定義:
overloadで+,-,*,==,!=,<=>を定義する。加減算では通貨チェック、乗算ではMoney × Money禁止を組み込む - 未定義操作の禁止:
fallback => 0で定義していない演算を即座にエラーにする - ファクトリメソッド:
from_major/to_majorで人間向け数値との変換口を用意する。内部は常に整数 - StrictConstructor:
MooX::StrictConstructorでコンストラクタのタイプミスを検出する。不変オブジェクトは構築時の正しさが全て
ロックより
金は、数字の中で最も嘘をつきやすい存在だ。小数点以下の幽霊が1円を食い、通貨の境界を持たない裸の数値が国境を無視する。
Value Objectとは、値に権利を与える行為だ。名前を与え、境界を定め、振る舞いを付与する。Moneyはその最も切実な実践であり——ドメインの深淵に型の光を差し込む最初の一歩だ。
ワトソン君、金を扱うなら、金に名前をつけたまえ。名前のない金は誰のものでもない。そして誰のものでもない金は、いつか必ず消える。
