Featured image of post コードメカニック【Strategy】決済を足すだけで料金計算が止まった〜switchの配線をやり方の差込口へ〜

コードメカニック【Strategy】決済を足すだけで料金計算が止まった〜switchの配線をやり方の差込口へ〜

決済種別を一つ足すたび巨大なswitchを開けていた料金計算が、コンビニ後払いの追加で本番panic。型による分岐をStrategy(関数値とmap)に組み直し、本体を触らず種類を足せるようにするまでの整備記録。

出したばかりの決済が、目の前で止まった

「コンビニ後払い」を本番に出したのは、その日の十五時だった。

実装そのものは三日で終わっていた。決済種別に Konbini という定数を足し、注文画面に選択肢を足し、DBにカラムを足す。よくある追加作業だ。私はこの手の「決済を一つ増やす」タスクを、この一年で何度もやってきた。手は速い方だと思っている。

最初の一時間は静かだった。十六時を過ぎた頃、カスタマーサポートから最初の連絡が来た。コンビニ払いを選んだ注文だけ、「エラーが発生しました」と出て先に進めない、という。

ログを見た。

1
2
3
4
panic: unknown payment kind: konbini

goroutine 1 [running]:
.../pricing.CalculateCharge(...)

スタックトレースは料金計算——CalculateCharge を指していた。原因はすぐに分かった。料金計算の switch に、konbini の枝を足し忘れたのだ。未知の種別なので、defaultpanic——プログラムがその場で強制終了する処理——に落ちている。一行足せば直る。

直せる。なのに、その関数を開いたとき、画面を見て手が止まった。case が、もう何個も並んでいた。


Slackのインシデントチャンネルに「コードを直す人がいる」と名前が挙がっていた。藁にもすがる気持ちで連絡すると、十五分ほどで、その人は来た。三十代くらいだろうか。落ち着いた身のこなしの女性で、薄いノートPCを一台だけ提げていた。

「決済が止まってます。原因はもう分かってて、料金計算の switchcase を足し忘れただけなんです。念のため、見てもらえますか」

私は早口だったと思う。主導権はこっちにある、確認だけしてほしい、という気持ちがどこかにあった。

「その料金計算、全部見せてください」

panic の行じゃなくて?

「ここです、この default の——」

「足りないのは case じゃない。その関数を、全部」

静かだが、引かない人だった。その落ち着きを見ているうちに、自然と「親方」と呼びたくなった。

ファイルを開くと、まず決済種別と注文の型が並んでいる。決済種別(Kind)は文字列の定数で、注文(Order)はその種別と金額を持つだけの構造体だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type Kind string

const (
    Credit  Kind = "credit"
    Bank    Kind = "bank"
    PayPal  Kind = "paypal"
    Carrier Kind = "carrier"
    Konbini Kind = "konbini" // 今日足した種別
)

type Order struct {
    Kind   Kind // 決済種別
    Amount int  // 本体価格(円)
}

その下に、料金計算の本体がある。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 決済種別ごとの料金(手数料込み)を計算する。決済を足すたびに育ってきた
func CalculateCharge(o Order) int {
    switch o.Kind {
    case Credit:
        return o.Amount + o.Amount*3/100 // クレジット手数料3%
    case Bank:
        return o.Amount // 銀行振込は加算なし
    case PayPal:
        return o.Amount + o.Amount*36/1000 // PayPal 3.6%
    case Carrier:
        return o.Amount + o.Amount*5/100 // キャリア決済 5%
    // ↑ ここに konbini を足し忘れた
    default:
        panic("unknown payment kind: " + string(o.Kind))
    }
}

スクロールしても、case が続く。クレジット、銀行振込、PayPal、キャリア決済。それぞれが手数料率や固定費で数行ある。一画面には収まらない。

「これ、決済が増えるたびに、ここに足してきたやつで……」

自分でも言い訳がましいと思いながら、口から出た。

case を一つ足せば、今は直りますよね。なんで関数全体を見るんですか」


とりあえず一行で止める

親方は何も言わず、konbini の枝を一つ足した。

1
2
    case Konbini:
        return o.Amount + 200 // コンビニ後払いは固定手数料200円

再デプロイ。コンビニ払いの注文が通るようになった。サポートからの問い合わせが止まる。

「直った。やっぱり case 漏れでしたよね。ありがとうございます」

少し得意げだったかもしれない。

「火は消えました。でも、また足すことになります」

親方は CalculateCharge を、上から下までゆっくりスクロールして見せた。クレジット、銀行振込、PayPal、キャリア決済、そして今足したコンビニ。case が縦に並んでいる。

「決済を一つ増やすたびに、この関数を開けて、ここに case を足して、関数全体をテストし直してきた。今日もそうした。次の決済でも、同じことをします」

「……まあ、そうですね。でも決済を足すんだから、料金計算に足すのは当然じゃないですか」

私は少し反論していた。

「足すのが当然なのは『新しい決済のやり方』です。おかしいのは、今あるやり方が全部この一本に詰まっていて、毎回ここを開けないと種類を足せないこと。さっきの足し忘れも、それが原因です」

「決済の料金計算なんだから、料金計算の関数に書くのが自然じゃないんですか」

応急処置で直ったばかりで、私はまだ、どこか他人事だった。


この関数は、二つの仕事をしている

親方は CalculateCharge を指した。

「この関数は、二つの仕事をしています。一つ目、種類を見て『どのやり方か』を選ぶ。二つ目、選んだやり方で金額を計算する」

「種類が増えて困っているのは、一つ目だけです。なのに二つ目——個々の計算——まで毎回この一本に巻き込んで、まとめて触っている」

そこで私は、自分なりの直し方を思いついた。手は動く方だ。

「あ、じゃあ種類ごとに関数を分ければいいんですね。creditChargebankCharge って切り出して、switch の中ではそれを呼ぶだけにする。そうすればスッキリする」

「半分は正しい。種類ごとの計算を関数に切り出すのは、やります」

親方は、switch を残したまま中身だけ関数呼び出しに変えた版を、その場で書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func CalculateCharge(o Order) int {
    switch o.Kind {
    case Credit:
        return creditCharge(o)
    case Bank:
        return bankCharge(o)
    case PayPal:
        return paypalCharge(o)
    case Carrier:
        return carrierCharge(o)
    case Konbini:
        return konbiniCharge(o)
    default:
        panic("unknown payment kind: " + string(o.Kind))
    }
}

「これでも、次に『あと払いPayPay』を足すときは?」

私は画面を見た。

「……switchcase PayPay: return paypayCharge(o) を足す。あ」

自分の声が止まった。

「結局、この関数を開けてる。case を足す場所が、ここに残ってる」

「そう。中身を関数に出しても、『種類を選ぶ switch』がここに残る限り、種類を足すたびにこの関数を開ける。case の足し忘れも、また起きます」

これは、と親方は続けた。

「型による分岐の爆発——種類ごとの違いを switchif-else で並べて、種類が増えるたびにその分岐の本体を直す形です。種類を足すことと、本体を直すことが、べったりくっついている」

決済を足すのが、この一年の私の仕事だった。足すたびにこの関数を開けるのも、当然だと思っていた。当然すぎて、疑ったこともなかった。でも親方が言っているのは——開けなくて済む形がある、ということだ。

「テストは」と親方が聞いた。

「一応あります。でも CalculateCharge のテストは、種類が増えるたびに増えてます。クレジットのケース、銀行振込のケース……今日、コンビニのケースも足しました。種類の数だけ、この関数のテストが膨らんでる」

リファクタリング前の構造を整理すると、以下のようになります。種類を選ぶ仕事と個々の計算処理が、一つの強固な筐体(関数)の中にハンダ付けされたように密結合しています。

Monolithic Switch: Calculating charge for each payment type is tightly coupled inside the CalculateCharge function, requiring it to be modified for every new type.

この状態では、新しい決済を足すたびにメインの筐体を開けて回路を組み直す必要があり、配線のミスが全体を停止させるリスクを常に抱えていました。


配線を抜いて、差込口に変える

「種類を選ぶ switch を、表に置き換えます。やり方は外に出す」

親方はまず、一行の型を定義した。

1
type Pricer func(Order) int

「関数に……型を付ける? 関数って、渡せるんですか」

これは素朴に驚いた。私にとって関数は「呼ぶもの」で、「持ち運ぶもの」ではなかった。

「Goでは関数も値です。int を変数に入れるように、関数を変数に入れて、後で呼び出せる。Pricer は『Order を受け取って金額を返す、一つのやり方』という型です」

「やり方そのものに、名札を付けるみたいな」

「そうです」

親方は、種類ごとの計算を独立した関数に切り出した。さっき私が言った「分割」を、今度は switch から切り離して。

1
2
3
4
5
func creditCharge(o Order) int  { return o.Amount + o.Amount*3/100 }   // クレジット 3%
func bankCharge(o Order) int    { return o.Amount }                    // 銀行振込 加算なし
func paypalCharge(o Order) int  { return o.Amount + o.Amount*36/1000 } // PayPal 3.6%
func carrierCharge(o Order) int { return o.Amount + o.Amount*5/100 }   // キャリア決済 5%
func konbiniCharge(o Order) int { return o.Amount + 200 }              // コンビニ後払い 固定200円

そして、「種類 → やり方」の表を作った。

1
2
3
4
5
6
7
var pricers = map[Kind]Pricer{
    Credit:  creditCharge,
    Bank:    bankCharge,
    PayPal:  paypalCharge,
    Carrier: carrierCharge,
    Konbini: konbiniCharge,
}

最後に、本体を書き換える。

1
2
3
4
5
6
7
func CalculateCharge(o Order) (int, error) {
    price, ok := pricers[o.Kind]
    if !ok {
        return 0, fmt.Errorf("unknown payment kind: %s", o.Kind)
    }
    return price(o), nil
}

pricers[o.Kind] で、左が二つ……priceok

「Goのmapは、キーで引くと二つ受け取れます。『値』と、『そのキーが表にあったか』。ok が false なら、その種類は表に無い」

「なるほど。値が二つ返るから、ok で『表にあったか』を確かめられるのか」

私はもう一度、書き換わった本体を眺めた。switch が、無い。

「配線が……表の差込口に変わったんだ。種類を持ってくれば、対応する差込口にやり方が挿さってる。本体は、挿し口を引くだけだ」

親方は黙ってうなずいた。

「次に『あと払いPayPay』を足してみてください」

私は paypayCharge を一つ書いて、pricers に一行足した。

1
2
3
4
5
6
func paypayCharge(o Order) int { return o.Amount + o.Amount*35/1000 } // あと払いPayPay 3.5%

var pricers = map[Kind]Pricer{
    // ...既存の5種別はそのまま...
    PayPay: paypayCharge, // 足したのはこの一行。CalculateCharge は開かない
}

書き終えて、私は引っかかった。

「関数を一つと、表に一行……でも、さっき親方が見せた switch を残す版でも、case を一行足すだけでしたよね。行数は同じだ。何が違うんですか」

親方は三つ挙げた。

「一つ。switch を残す版は、種類を足すたびに『選び分ける本体』そのものを書き換えます。書き換えたら、その流れをまた確かめ直す。表の版で足すのは、表への一行と、独立した関数だけ。種類を引く仕組み——pricers[o.Kind] を引く部分——は一字も変わらない。だから確かめ直すのは、足した関数一つで済む」

「二つ。switch の足し忘れは defaultpanic で本番が落ちる。表の足し忘れは、ok が false になって error で返る。落ちるか、受け止められるか。さっきあなたが踏んだのは、前者です」

「三つ。種類ごとのやり方が、本体に溶け込んだ流れじゃなく、独立した関数になった。だから一つずつテストできる」

私はもう一度、自分の手元を見た。

同じ一行でも、足している場所が違う。switch のときは『選び分ける本体』に手を入れていた。今は『表』に名前を登録しているだけで、選び分ける仕組みには触っていない。しかも足し忘れても、panic じゃなく error で返る。

Goの switch は、Kind に種類を足しても case の追加を強制してくれない。コンパイルは通ってしまう。だから defaultpanic は、足し忘れを実行時まで隠す。表なら、登録漏れは ok で判定できて、事故が一箇所のエラーに集まる。今朝、私が踏んだのは、その隠れた panic だった。

「関数じゃなくて、ちゃんとした型……interface にしなくていいんですか」

「やり方が金額計算一つなら、関数で十分です。名前を付けたい、あるいは後で種類ごとに『画面表示名』や『手数料の内訳』も持たせたくなったら、そのとき一メソッドの interface に格上げすればいい。interface は『このメソッドさえ持っていれば、どんな型でも同じ差込口に挿せる』という約束です」

1
2
3
type Pricer interface {
    Charge(o Order) int
}

「形は同じです。関数でも、interface を満たす型でも、表で引いて呼ぶ構造は変わりません」

それから親方は、念を押すように言った。

「種類は、途中で移り変わったりはしません。クレジットの注文が、会計の途中でコンビニ払いに化けたりはしない。種類は注文ごとに最初から決まっていて、その『やり方』を表から引くだけ。差し替えているのは、計算のやり方まるごとです」

親方がやったこれには、名前があった。Strategy(ストラテジー)——処理のやり方を交換可能な部品として外に出し、本体は受け取って実行するだけにする、振る舞いを扱うデザインパターンの一つだ。

リファクタリング後の構造は以下の通りです。本体から分岐回路が取り除かれ、各処理が独立したカートリッジ(関数)としてプラグボード(マップ)に差し込まれる構造に変わりました。

Decoupled Strategy: The CalculateCharge function dynamically maps payment types to interchangeable pricing strategy cartridges, keeping the core dispatcher code completely untouched.

このように「選択」と「実行」が完全に分離されました。この美しい配線整理によって、テストの書きやすさはどのように変化するでしょうか。


三行になった料金計算

テストを書いてみた。

以前は CalculateCharge の巨大な switch を、種類ごとに網羅してテストしていた。種類が増えるたびに、この関数のテストも増えた。

今は違う。種類ごとのやり方は独立した関数だから、一つずつテストできる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func TestEachPricer(t *testing.T) {
    tests := []struct {
        name   string
        pricer Pricer
        amount int
        want   int
    }{
        {"credit 3%", creditCharge, 1000, 1030},
        {"bank none", bankCharge, 1000, 1000},
        {"konbini +200", konbiniCharge, 1000, 1200},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := tt.pricer(Order{Amount: tt.amount}); got != tt.want {
                t.Errorf("got %d, want %d", got, tt.want)
            }
        })
    }
}

そして、今朝の事故をテストに残した。未登録の種別で呼んだとき、前は panic だった。今は error が返る。

1
2
3
4
5
6
func TestCalculateCharge_UnknownKind_ReturnsError(t *testing.T) {
    _, err := CalculateCharge(Order{Kind: "unregistered", Amount: 1000})
    if err == nil {
        t.Fatal("未登録の種別はエラーで返るはず(before版ならここで panic していた)")
    }
}

同じ「足し忘れ」が、本番を止める事故から、テストで捕まえられる戻り値に変わった。

1
2
3
$ go test ./...
ok  code-mechanic/type-switch-strategy/after   0.006s
ok  code-mechanic/type-switch-strategy/before  0.009s

「種類は、これから何個でも増やせます。本体は、もう開けなくていい」

次に新しい支払い方法が来ても、あの巨大な関数を探して開けることはない。関数を一つ書いて、表に一行足す。それだけだ。

「次に決済を足すとき、約束してください。CalculateCharge を開かないこと。やり方を一つ書いて、表に挿す。それだけでいい」

「本体は触りません。表に足します」

親方はノートPCを閉じた。

一人になって、画面を眺めた。料金計算の本体が、ほんの数行になっていた。さっきまで、画面に収まりきらなかったのに。


整備記録簿

こんな異音・症状が出たら入れるべき整備(Strategy)まだ様子見でいい
種類を一つ足すたびに、同じ巨大な関数を開けて case を足している
switchdefaultpanic/種類の追加漏れが実行時まで分からない
中央の関数のテストが、種類が増えるたびに膨らんでいく
種類が二〜三個で今後も増えない/各種類の処理がほぼ同じ

整備手順

  1. 中央の関数がしている二つの仕事——「種類を選ぶ」と「やり方で計算する」——を見分ける
  2. 種類ごとの計算を、それぞれ独立した関数に切り出す(type Pricer func(Order) int という「やり方」の型を与える)
  3. 「種類 → やり方」を map[Kind]Pricer の表にする
  4. 中央の関数は「表を引いて実行するだけ」にする。表に無い種類は ok チェックで error に変える(default: panic を捨てる)
  5. 新しい種類は、関数を一つ書いて表に一行足す。本体は開かない

親方より

switch は、種類を選ぶだけならいい。だが種類ごとの『やり方』まで一本の配線に束ねると、種類が増えるたびに本体を開けて配線を握り直すことになる。やり方は外に出して、表の差込口に挿せ。本体の配線に触らず種類を足せるようになったら、それが整備の済んだエンジンだ。次にその配線へ手を伸ばしたくなったら——分岐をまた一本に束ね直しかけてるサインだと思え」

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。