引数の順番ミスっただけで、別人の口座に送金しちゃってない?💔

引数の順番ミスっただけで、別人の口座に送金しちゃってない?💔

これ絶対あるじゃん??これ絶対あるじゃん??

  • 関数の引数にstringが3個とか4個、平気で並んでる
  • 同じ型の引数の順番を入れ替えても、型チェックは通っちゃう
  • 「このuser_idproduct_id、どっちがどっちだっけ?」ってレビューで毎回同じ会話してる
  • バリデーション、呼び出す側でも書いて、関数の中でも書いて、どっちが本番か誰も知らない

あるよね? あるよね絶対??

これ全部、Primitive Obsession(プリミティブオブセッション)、以降「スッピンstring」って呼ぶね、が原因なんだよね。化粧もしてないスッピンで外に出してる、みたいなイメージ。


てかシンプルに言うとねてかシンプルに言うとね

意味がぜんぶ違うものを、stringとかintっていう同じ箱に全部投げ込んじゃってるっつーこと。

「それの何がまずいの?」ってなる気持ち、わかる! 動くし、IDEの補完もしてくれるし、テストも通る。

でもね、stringって書いてあるだけの引数って、**「中身が何か、プログラムは誰も知らない」**状態なの。名前も電話番号もクレカ番号も全部同じ封筒に入ってて、取り違えても誰にもバレない。ヤバくない? で、そのまま本番でやらかすって話、ちゃんとするね。


ちょっと待って、先に全然関係ない話していい?🏠ちょっと待って、先に全然関係ない話していい?🏠

田中さんって新入社員がいる銀行の窓口を想像してほしいんだけどさ。

この銀行、送金の伝票のフォーマットがガチでイカれてたの。

伝票にはこう書いてあった。

  • 番号①:______
  • 番号②:______
  • 番号③:______
  • 数字:______

番号①が送り元の口座、番号②が送り先の口座、番号③が受取人の顧客番号、数字が送金額。だけど、伝票にはどれも「番号」「数字」としか書いてなかったの。意味は入社時に配られた小冊子を暗記しろって運用。怖くない?


ある日、佐藤さんってお客さんが来て「うちの取引先に50万円送金してほしい」って言ったんだよね。田中さん、真面目だから伝票を見ながら一生懸命端末に打ち込んだの。

その日の夕方、佐藤さんから電話が来た。

「田中さん、送金先、違うんですけど」

田中さん、番号①と番号②を打ち間違えてた。送り元と送り先が逆。50万円が取引先じゃなくて、佐藤さん自身の別の口座に入ってた。

......と思ったら、もっとヤバかった。


「田中さん、今日うち、700万円の請求書の支払いもあって。その送金先、大丈夫ですか?」

え、待って。今日の午前中に同じ作業、10件くらいやった気がする......

田中さん、急いで午前中の記録を全部見直した。そしたら、3件で送り元と送り先が入れ替わってたの。

しかも運悪く、そのうち1件の「逆転先」は、佐藤さんの口座じゃなくて全然知らない人の口座。金額700万円。

は? 待って、他人の口座に700万って、どうやって取り戻すの??


計算式を触ったわけじゃない。ただ番号を順番通り打っただけなのに、なんでこんなことになるの......

お客さん激怒、支店長呼び出し、法務部まで動いて送金の取り戻し。田中さん、翌週辞めたんだって。マジで。

これが本番で起きてるやつが、スッピンstringってこと。


じゃあコードで見てみよっか👀じゃあコードで見てみよっか👀

さっきの銀行の話、コードに戻すとこうなるんだよね。田中さんの伝票 = この関数。

def transfer_money(
    from_account: str,
    to_account: str,
    user_id: str,
    amount: str,
) -> None:
    # 4つともstr。プログラムは区別できない
    db.execute(
        "INSERT INTO transfers VALUES (?, ?, ?, ?)",
        (from_account, to_account, user_id, amount),
    )

呼び出すとこはこう。

transfer_money(
    "ABC123",      # from_account のつもり
    "XYZ789",      # to_account のつもり
    "USR-42",      # user_id のつもり
    "500000",      # amount のつもり
)

ここで、新人エンジニアが引数の順番をミスったとするよね。

transfer_money(
    "XYZ789",      # ← from と to が逆!
    "ABC123",
    "USR-42",
    "500000",
)

型チェック通る。IDEも警告出さない。レビュアーも気づかない。

だって全部strじゃん。プログラムにとっては「4つの文字列を並べた」以外の情報がないの。区別のしようがない。えぐくない?

わたしも昔これやって、自分のサービスで顧客IDと商品IDを逆に渡したことがあってさ。管理画面で顧客一覧を開いたら、氏名欄に「iPhone 15 Pro」って表示されてたの。あのとき画面を見て血の気が引いた感じ、今でも思い出せるんだよね。本番で気づいたときの「やっちまった」感、マジでえぐいって。


やばいのまだあんだけど、バグに気づけない問題😭やばいのまだあんだけど、バグに気づけない問題😭

スッピンstringの怖さって、「取り違える」だけじゃなくて、**「取り違えてもテストでも本番でも気づけない」**のがほんとにやばい。

田中さんの銀行で「今日の送金記録、全部正しいか確認して」って言われたとする。

番号①と番号②が口座番号として有効な文字列かどうか、は確認できるじゃん。番号③が顧客番号の形式かどうかも確認できる。でも——番号①と番号②が入れ替わってないかは、どうやって確認する?

ここが一番えぐいとこで。番号①も番号②も形式が同じ口座番号なんだよね。どっちがどっちに入ってるべきか、伝票からはわかんないの。

本人が「あ、これ逆だったかも」って気づかない限り、誰も止められない。詰みじゃん。


コードでも同じことが起きる。ちょっと見てみて?

def test_transfer_money():
    transfer_money("ABC123", "XYZ789", "USR-42", "500000")
    assert last_transfer().from_account == "ABC123"
    assert last_transfer().to_account == "XYZ789"

このテスト、書いてる本人すら「引数の順番、合ってるよね?」ってフワッと書いてるのよ。しかもassertの右辺もstrだから、この文字列が送り元として正しいかどうかの保証はどこにもない。関数の中でfromtoを入れ替えるバグがあっても、テストはスルーで通るパターンすらある。マジで? マジで。

テストが「型が合ってるからOK」になっちゃって、意味的な取り違えを検出できない。スッピンstringって、壊れる設計じゃなくて「壊れてても誰にもわかんない」設計なんだよね。


分けてた会社の話もするね〜✨分けてた会社の話もするね〜✨

田中さんの銀行とは別に、最初からちゃんと伝票に「意味」を書いてた隣の銀行の話もするね。

隣の銀行の伝票、ぜんぜん違ったの。

まず、送り元と送り先で伝票そのものが別だった。送り元専用の伝票には右上にでっかく「送り元」って印字してあって、送り先専用の伝票には「送り先」って印字してある。受付の端末も、「送り元」の伝票を「送り先」のスロットに入れたら**「伝票の種類が違います」って弾く**ようになってた。

さらに、顧客番号の欄は口座番号とは形式が全然違うから、口座番号を顧客番号の欄に書こうとしても通らない。金額の欄は数字しか受け付けない。

同じ日に佐藤さんが来て「50万円送金したい」って言ったんだけど、別の窓口の鈴木さんは余裕だった。

送り元の口座番号を「送り元」の伝票に書いて、送り先の口座番号を「送り先」の伝票に書いて、端末に通すだけ。仮に鈴木さんが間違えて伝票を逆のスロットに入れても、端末が「種類が違います」って止めてくれる。人間がミスっても、仕組みが最後に防いでくれるじゃん。

深夜のSlackも謝罪の電話もなくて、普通に定時で帰ったっていう。マジで平和。


コードに戻すとこうなるんだよね。隣の銀行 = このコード。さっきの比喩でいう「送り元の伝票」「送り先の伝票」がSourceAccountDestinationAccount

from dataclasses import dataclass
import re

@dataclass(frozen=True)
class AccountNumber:
    value: str

    def __post_init__(self):
        if not re.fullmatch(r"[A-Z]{3}\d{3}", self.value):
            raise ValueError(f"口座番号の形式が違う: {self.value}")

# 「送り元の伝票」と「送り先の伝票」を型で分ける
@dataclass(frozen=True)
class SourceAccount:
    number: AccountNumber

@dataclass(frozen=True)
class DestinationAccount:
    number: AccountNumber

@dataclass(frozen=True)
class UserId:
    value: str

    def __post_init__(self):
        if not self.value.startswith("USR-"):
            raise ValueError(f"顧客番号の形式が違う: {self.value}")

@dataclass(frozen=True)
class Money:
    amount: int
    currency: str = "JPY"

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("金額はマイナスにできない")

def transfer_money(
    from_account: SourceAccount,
    to_account: DestinationAccount,
    user_id: UserId,
    amount: Money,
) -> None:
    ...

呼び出し側はこう。

transfer_money(
    from_account=SourceAccount(AccountNumber("ABC123")),
    to_account=DestinationAccount(AccountNumber("XYZ789")),
    user_id=UserId("USR-42"),
    amount=Money(500_000),
)

ここがポイントで。SourceAccountDestinationAccount別の型じゃん。仮にfromとtoを入れ替えようとしたら——

transfer_money(
    from_account=DestinationAccount(...),  # ← SourceAccountじゃない!
    to_account=SourceAccount(...),         # ← DestinationAccountじゃない!
    ...
)

型チェックが「SourceAccountを期待してるのにDestinationAccountが来たよ」って止めてくれる。 田中さんの700万円事件、ここで防げるじゃん。

AccountNumberUserIdももちろん別の型だから、口座番号と顧客番号の入れ替えも弾ける。AccountNumber("xyz")みたいな不正な値はそもそも作れない。バリデーションが1箇所に閉じ込められてるから、呼び出す側で毎回書かなくていい。

口座番号のルールが変わったらAccountNumberだけ。顧客番号のルールが変わったらUserIdだけ。1つの値 = 1つの型 = 1箇所で管理、になるんだよね。これがマジで効いてくるやつ。


ちゃんとした名前もあるから一応言うね📚ちゃんとした名前もあるから一応言うね📚

ここまで「スッピンstring」って呼んできたやつ、ちゃんとした名前がある。

Primitive Obsession(プリミティブオブセッション)。「stringintみたいな基本の型に、意味の違う『名前のある値』を全部詰め込んじゃうクセ」のこと。

もともと「コードの臭い(Code Smell)」って言葉自体はKent Beckって人が言い出したやつで、それをMartin Fowlerが1999年の『Refactoring』って本にKent Beckと一緒にまとめた章がある。そこで「臭いの1つ」として並んでるのがこれ。直し方として書いてあるのが、

Value Object(値オブジェクト)。「名前のある値は、その値だけの専用の型に閉じ込めとこう」っていう考え方。

さっきの隣の銀行でいう、「送り元の伝票」と「送り先の伝票」を物理的に分けてたやつ、あれ。こっちはEric Evansって人が2003年の『Domain-Driven Design』って本でまとめて広まったやり方。

難しそうに聞こえるけど、要は「このstring、本当に『ただの文字列』なの? それとも名前のある何かなの?」って問い続けるだけなんだよね。


これだけ覚えて帰って🥺これだけ覚えて帰って🥺

合言葉はこれ。ssb(スッピンstringバイバイ)

関数のシグネチャ書くとき、レビューするとき、これだけ問いかけてみて。

「この引数、同じ型が2個以上並んでる? 並んでたら、入れ替えたらバレる?」

たとえばさっきの地獄編のコード、from_account: strto_account: strが並んでたじゃん。同じ型だから入れ替えても型チェックはスルーする。田中さんの700万円がここで生まれたわけ。

天国編でSourceAccountDestinationAccountに分けたの覚えてる? ああいうふうに、「同じ形式の値」でも、意味が違うなら型を分ける。これだけで、入れ替えた瞬間にコンパイラが止めてくれるようになる。

もう1個。

「このstring、作るときにバリデーションしてる場所、何箇所ある?」

答えが2箇所以上なら、バリデーションが散らばってるってこと。Value Objectにして__post_init__で1回だけやるようにすると、**「作れた時点で有効」**って状態になる。以降、有効かどうかは誰も気にしなくていい。それだけっつーこと。


まとめるとこういうことねまとめるとこういうことね

観点スッピンstringValue Object
引数の取り違え型チェック通るから気づけない別の型にすれば型エラーで即止まる
バリデーション呼び出し側で毎回書く(散らばる)作成時に1回だけ。以降は考えなくていい
ドメインルールコメントか口伝で受け継ぐ型そのものがルール
テスト型が合ってるだけで意味はわからない渡すべきものが型で一目瞭然
新メンバー定数や規約を探し回る型定義を読むだけで意味がわかる

「このstring、名前ある?」って問い続けて、名前があるやつは全部Value Objectに閉じ込める——これを一回覚えると、スッピンstringの引数が並んでるの見るたびに「田中さんの伝票じゃん!」って思えるようになるから。マジで。


Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第15回


沼りたい人はこっちも読んで📖沼りたい人はこっちも読んで📖

書籍

  • Martin Fowler 著『Refactoring: Improving the Design of Existing Code』(1999, 第2版2018, Kent Beck 協力) — Primitive Obsessionを「コードの臭い」の1つとして定義した原典。Value Objectへの置き換え方も載ってる。「Bad Smells in Code」章はKent Beckとの共著
  • Eric Evans 著『Domain-Driven Design』(2003) — Value Objectをドメインモデリングの基本要素として体系化した本
  • Dan Bergh Johnsson, Daniel Deogun, Daniel Sawano 著『Secure by Design』(2019, Manning) — Domain Primitives(Value Objectの発展形)を使って入力値のバリデーションとセキュリティを両立させる実践ガイド

一次資料

無料で読める入門記事

  • Refactoring.Guruは日本語版もあって、Primitive Obsessionの解説が読める(サイト内で言語切り替え可能)
  • Zenn・Qiitaで「Value Object Python」「プリミティブ型 執着」で検索すると実装例が見つかる