返金が、止まった
「お客様から、同じメールが二通届くと——」。その報告がカスタマーサポートのチャンネルに流れたのは、注文キャンセル機能をリリースした、翌朝のことだった。
わたしは自社ECの注文システムを預かっている。チームのリードで、もう七年。人が物を買って、金を払って、箱が届く——その一連を回す、会社の背骨みたいなシステムだ。
最初、CSのチャンネルは静かにざわついていただけだった。「キャンセル確認メールが二通きた、と問い合わせが」。わたしは、表示側の不具合だろうと高をくくっていた。だが午前の半ばには、別の声が混じりはじめた。「在庫が、合いません」。キャンセルされた商品が、戻っていたり、戻っていなかったり。数が、ずれている。
確認メールが二通。在庫の数が、ずれる。どちらも、昨日足したキャンセル処理に触っている。わたしは返金のバッチを止めた。間違った数で返金を走らせるわけにはいかない。止めた瞬間、数百件のキャンセルが宙吊りになった。お客様の手元で、返金が、止まった。事業が、止まったということだ。
正直に言えば、わたしには自負があった。このシステムは、わたしがチームと一緒に、何年もかけて育ててきた。引き継いだ当初は、注文も在庫も課金も発送も、何もかもが一つの巨大なかたまりに詰まっていた。それを少しずつ、在庫・課金・通知・配送と、独立したサービスに切り分けてきた。一つひとつのサービスは、今では誇れるくらい綺麗だ。テストもある。役割も、はっきり分けた。
なのに、キャンセルを一つ足しただけで、なぜこうなる。
藁にもすがる気持ちで、取引先が前に世話になったという「出張整備の親方」に連絡を入れた。コードの整備を、出張でやる人がいる、と。半信半疑だった。昼過ぎ、その人はフロアに現れた。作業着の上に革のエプロン、片手に金属のツールボックス。世間話はしない。わたしが「見てもらえますか」と言うと、小さくうなずいた。
わたしは、つい先回りして弁護していた。「言っておくと、各サービスは、それぞれ綺麗に分けてあるんです。在庫も、課金も、通知も、配送も。役割はちゃんと——」。
親方は、わたしのモニタにも、燃えているCSのチャンネルにも、すぐには目をくれなかった。代わりに、わたしの方を見て、静かに口を開いた。
「——綺麗な部品が、四つ」。そして、続けた。「その四つは、互いに、何本の線で繋がってる」。
わたしは、答えに詰まった。
何本。……数えたことが、なかった。部品を磨くことはずっと考えてきた。在庫を綺麗にし、課金を綺麗にし。けれど、その部品と部品の間を結ぶ線が、いま何本あるのか——意識したことが、一度もなかった。
親方は答えを待たず、コードを開いた。まず、キャンセルで足したあたりだ。
| |
在庫サービスは、課金・通知・配送のサービスを、自分のフィールドに直接抱えている。注文が来れば在庫を確保し、確保できたら、その手で課金サービスを呼ぶ。在庫切れなら、その手で通知を呼ぶ。
課金サービスも、同じ作りだった。
| |
通知(NotificationService)と配送(ShippingService)の二つも、同じ作りだ。SendConfirmation や Arrange といったメソッドを持ち、やはり互いに他のサービスを直接抱えている。四つとも、そうやって手を繋ぎ合っている——だから、この四つを別々のパッケージに分けられない。在庫が課金を参照し、その課金もまた在庫を参照する。こういう相互参照があると、Go はその二つを別のパッケージに置けない。これを循環インポートという。仕方なく、四つを一つのパッケージに押し込んでいた。それ自体が、絡まりの証拠だ。
見てほしいのは、Refund と、Refund が呼んでいる Restore だ。返金サービスの Refund は、返金して、在庫の Restore を呼んで、そして客にキャンセル確認を送る。ところが、呼ばれた側の Restore も、在庫を戻したあとで、客にキャンセル確認を送っている。
「ここです」と、わたしは言った。声が小さくなった。「キャンセル確認を送っているのが……二箇所、ある」。
Refund を書いたのは、課金まわりの担当だ。Restore を書いたのは、在庫まわりの別のメンバーだった。どちらも善意で、「自分の処理の締めに、お客様へ確認を送っておくべきだ」と考えた。それぞれの判断は、間違っていない。ただ、二人とも、相手も同じことをしているとは、知らなかった。
「ちゃんと別々のサービスに分けたんです」と、わたしは言った。「なのに、なぜ、二重に……」。
とりあえず、一通に戻す
親方は、応急処置から入った。Restore の中の、キャンセル確認を送る一行を、コメントアウトした。
| |
「これで、メールは一通になる」。親方の手つきは、ドライだった。
実際、ローカルで動かすと、キャンセル確認はきっちり一通だけ飛んだ。わたしは、少しほっとした。「直った……?」。
「これは応急だ」。親方は、手を止めて言った。「配線そのものが、絡んでる。これは、今日のメールを一通に戻しただけだ」。
わたしは、食い下がった。「でも、重複は消えました。Restore から送るのをやめれば、二度と二通にはならないですよね」。最後まで、言い切った。
親方は、Refund と Restore の二箇所を、順番に指でさした。「キャンセル確認を送る係が、いま、二人いた。今日は、そのうちの一人を消した。だが——」。指が、配送サービスのあたりへ動く。「明日、配送にもキャンセル通知を足したくなったら、三人目の係ができる。その三人目が、また送る。消して回るのか、毎回」。
わたしは、黙った。
そうだ。重複が起きたのは、たまたま Refund と Restore の二箇所が送っていたからじゃない。「誰がキャンセル確認を送るのか」という判断が、サービスのあちこちに、ばらばらに置かれているからだ。一箇所消しても、判断は散らばったまま。次に何かを足せば、また別のどこかで、同じことが起きる。
「とりあえず一通に戻ったのに、何がまずいんですか」。わたしは、自分で答えが見えはじめているのを感じながら、それでも訊いた。
「報せる係が、配線の中に散らばってる」。親方は短く言った。「散らばってる限り、足すたびに増える」。
六組、握り合ってる
「原因を、見せる」。親方はそう言って、画面を上にスクロールした。各サービスの、構造体の頭——他のサービスを抱えているフィールドの並びを、指で、一本ずつ手繰りはじめた。
「在庫と、課金。在庫と、通知。在庫と、配送」。指が、三つの組をなぞる。「課金と、通知。課金と、配送」。さらに二つ。「通知と、配送」。最後の一つ。
「六組」。親方は言った。「どの二つも、直接、手を握り合ってる。配線ってのは、二点を結ぶ一本だ。四つの部品で、六本」。
さっき答えられなかった問いの、答えだった。何本繋がっているか。六本。わたしのシステムは、四つの部品が、互いを直接つかんで、六本の線でぐるぐる巻きになっていた。
「これが原因だ」と親方は言った。これが、その状態の名前だった。
spaghetti coupling(スパゲッティ結合)——部品同士が互いを直接参照し合い、一つを変えると、繋がった全部に変更が波及してしまう状態。皿の上で絡まったスパゲッティのように、一本を引っぱると、関係ないはずの麺まで一緒に動く。
「キャンセルを一つ足すのに」と親方は続けた。「お前さんは、この六組の配線を、ぜんぶ気にして手を入れなきゃならなかった。在庫にもキャンセルの段取り、課金にも、通知にも。だから、送る係が二人になっても、気づけない。全部が見えないからだ」。
親方は、指を立てて数えた。「部品が一つ増えるたび、握り合う組は増える。四つで六組。五つなら十組。六つなら十五組。増えるのは、機能じゃない。配線のほうだ」。
わたしは、マウスから手を離した。画面の、その六組の線を、すぐには見られなかった。膝の上で、手を握った。
「……部品は、一個ずつ、綺麗にしたんです」。やっと、それだけ言った。「在庫も、課金も、それぞれは。でも」——言葉に詰まった。「繋ぎ方だけ、昔のままだった。直結で。部品を磨くことばっかり考えて、配線は……増えるに、任せてた」。
親方は無言のまま、私の肩越しにモニタを指差し、ツールボックスから取り出した赤ペンで、手元のメモ用紙に素早く線を走らせた。
「いいか。あんたがやったのは、こういうことだ」
親方の描いた図は、かつて私が誇らしく思った「独立した部品たち」のはずだった。しかし、そこに引かれた線はあまりにも不格好で、まるで車体の底でとぐろを巻く古い銅線の束のようだった。
親方の問いに答えられなかった理由が、ここで、自分でも腑に落ちた。数えたことがなかったのは、見ていなかったからだ。
思えば、このシステムは最初、何でも一つに詰まったかたまりだった。God Object——一つのものに役割が集まりすぎて、どこを触っても全体に響いてしまう、あの状態。それを部品に割ったのが、わたしがこのシステムでやった、最初の仕事だった。割ったのに。割った部品を、今度は六本の線で、また縛り直していた。
絡まりを、絵にするとこうだ。4つのサービス(INVENTORY/在庫、BILLING/課金、NOTIFICATION/通知、SHIPPING/配送)が、互いを直接つかみ合っている(線一本が、結合一組を表す)。

「最初は、これで十分だと思ってたんです」と、わたしは言った。「サービスが、二つか三つの頃は」。
「二つなら、一本」と親方は言った。「三つなら、三本。そこまでは、直結でも手が回る。四つで、手が、足りなくなった」。
報せる先は、一つでいい
「直結を、やめる」。親方は、メモ用紙にもエディタにも図を描かず、ただ言葉で、組み直しの方針を置いた。「部品同士には、もう手を握らせない。代わりに、真ん中に一つ、捌く係を立てる。部品は、その係にだけ報せる。次に誰を動かすかは、係が決める」。
車でいう、中継ボックスだ。電装品を一つひとつ直結でつなぐのをやめて、配線をいったん一つの箱に集める。それと同じことを、コードでやる。
これが、その定石の名前だった。
Mediator(メディエーター・仲介者)——部品同士を直接やり取りさせず、仲介役を一つ立てて、すべての連絡をそこ経由にする整備の定石。
親方は、まず、仲介役との約束事を書いた。
| |
interface——ここで出てくる——は、「このメソッドさえ持っていれば、仲間として扱う」という約束事だ。ここでは Notify 一つ。誰が何を起こしたか(sender と Event)を、仲介役に知らせる口だけを、決めている。なお sender(誰が報せたか)は、このあとの仲介役では実は使っていない——次に誰を動かすかは「何が起きたか(ev)」だけで決まるからだ。あとで送り主ごとにログを残したくなったとき用に、口だけ開けてある。
次に、各サービスを、この約束に合わせて組み替えた。在庫サービスは、こう変わった。
| |
在庫を確保する中身——s.stock[o.SKU] -= o.Qty——は、前と一文字も変えていない。変わったのは、その後ろだ。前は、確保できたら自分で課金サービスを呼んでいた。今は、「確保した」と仲介役に報せて、終わり。次に課金を動かすかどうかは、自分では決めない。
課金サービスの Refund は、もっと分かりやすく変わった。
| |
Refund が、返金するだけになった。在庫を戻す呼び出しも、キャンセル確認を送る呼び出しも、消えている。二重通知の片方が、ここから、いなくなった。
そして、捌く係——仲介役の本体だ。
| |
最後に、これらをどう繋ぐかだ。親方は、組み立ての部分を見せた。
| |
配線をセットするのは、この組み立ての一箇所だけになった。サービス側には、もう「どのサービスを持つか」という配線が、一本も書かれていない。
そして、繋いだあとの流れは、こうだ。客が注文すると、まず在庫の Reserve が呼ばれる。在庫は確保できたら、自分で次を呼ぶ代わりに「確保した(EventStockReserved)」と仲介役に報せる。報せを受けた仲介役は、switch でその次——課金——を呼ぶ。課金できたら、今度は課金が「課金した(EventCharged)」と報せ、仲介役がまた次——確認通知と配送——を呼ぶ。バトンが、毎回いったん仲介役を経由して、次の部品へ渡っていく。部品は、隣の部品を知らない。知っているのは、渡し先の仲介役だけだ。
わたしは、EventOrderCanceled のところを、何度か読んだ。
「キャンセルの通知が……ここ、一行だけになってる」。声に出していた。「Refund からも、Restore からも、送るのが消えて。キャンセルのときに何をするかが、この三行に、全部ある」。
「報せる先が、一つになった」と親方は言った。「誰がキャンセル確認を出すか。ここを読めば、それで全部わかる。配線の中に、埋もれない。だから——二回には、ならない」。
二重通知が消えたのは、フラグで止めたからでも、片方を消したからでもなかった。「誰がそれを呼ぶか」という判断が、仲介役の一箇所に集まったから、構造として、二回になりようがなくなった。それが、応急処置との違いだった。
一つ、引っかかっていたことを訊いた。前に別の現場で、在庫の動きを複数の画面に知らせるのに、Observer を入れたことがあった。Observer——起きたことを一本の線で報せておいて、聞きたい側が勝手に拾う仕組みだ。それと、これは何が違うんだろう。
「どっちも、“知らせてる"ように見えるんですけど」と、わたしは訊いた。
「Observer は、知らせて終わりだ」と親方は言った。「誰が拾うかは、知らせる側は気にしない。一方通行の、放送だ。これは——拾った先で、次に誰を動かすか、仲介役が決める。放送じゃなく、交通整理だ。線の数は似てても、向きが違う」。
放送と、交通整理。Observer は、起きたことを撒くだけ。Mediator は、起きたことを受けて、次にどの部品を動かすかを、選んで指図する。だから仲介役の中には switch がある。誰の次は、誰か。それを、決めている。
そこまで腑に落ちて、わたしは、怖いことに気づいた。「でも……全部、この仲介役に集めたら」。言いながら、最初に割ったかたまりのことを、思い出していた。「これ、最初に割った、あのかたまり——God Object——に、逆戻りしませんか。仲介役が、また、何でも屋になる」。
親方は、否定しなかった。「戻る。書き方を、間違えれば」。
あっさり認めたので、わたしは、かえって身構えた。
「だから」と親方は続けた。「仲介役には、“順番"だけ持たせる。在庫を実際に減らすのも、金を取るのも、中身は、部品に置いたままだ。仲介役が書くのは、『確保できたら、次は課金』——誰の次は誰か、それだけ。ここに『在庫の減らし方』を書きはじめたら、その時はじめて、かたまりに戻る」。
わたしは、もう一度、仲介役のコードを見た。switch の中には、本当に「次は誰か」しか書いていない。在庫の数を触る行も、金額を計算する行も、一つもない。「捌くだけ」と、わたしは言った。「仕事の中身は、持たせない」。
親方は深くうなずき、メモ用紙の別の場所に、新しい図を力強く描き殴った。
「これが、中継ボックスを置いた後の姿だ」
ペン先が紙を叩く乾いた音が、静かなフロアに響く。今度の図は、さっきの絡まったスパゲッティとは全く違っていた。鋼鉄のプレートたちが、真ん中の一点を介して、まるで理路整然と並ぶ計器のように接続されている。
「配線が、整理された……」
私は呟いた。部品から伸びる線は、もう互いを締め付けるように絡み合ってはいない。
仲介役(ORDER MEDIATOR)を真ん中に据え、絡まりが解けると、こうなる。

六組あった線が、四本になった。どの部品も、仲介役とだけ、繋がっている。
矢印が、すべて「サービス→仲介役」の一方向になった。さっきまでは、在庫と課金が互いを指し合って、輪っかになっていた——だから、別々のパッケージに分けられなかった。その輪が、切れた。これで、各サービスを別のパッケージに切り出しても、もう循環インポートにはならない。仲介役という一点を介して、依存が一方通行になったからだ。
在庫がずれていたのも、根は同じだった。キャンセルのとき、在庫を戻す段取りも、確認を送る段取りも、あちこちのサービスに散らばっていた。だから、戻りすぎたり、戻し漏れたりした。それが今は、仲介役の EventOrderCanceled の一箇所に、順番に並んでいる。後始末が、もう散らばらない。
鍵は、あんたのものだ
試運転は、テストで通した。
確定の流れ——在庫を確保し、課金し、確認を送り、配送を手配する——の結果は、整備の前と後で、ぴったり同じだった。最終的な在庫の数も、課金した額も、配送の伝票も、一致する。挙動は、変えていない。変えたのは、繋ぎ方だけだ。
そして、今朝の事故。キャンセルを一回かけて、確認メールが何通飛ぶか。
| |
整備前のコードには、「キャンセルで確認が二通飛ぶ」ことを、そのまま書き留めたテストを残した。今朝の事故の、記録だ。整備後のテストは、同じ条件で、確認が一通だけになることを確かめている。同じ操作、違う結果。緑が、並んだ。
「新しいサービスを足すときは」と親方は言った。「もう、部品同士は触らない。仲介役の switch に、一行、足すだけだ」。ポイントの付与でも、領収書の発行でも、配線するのは、その一箇所。六組を気にして回ることは、もう、ない。
わたしは、緑のテストを見ながら、自分のシステムの来歴を、口にしていた。「在庫を、別チームのAPIでも差し替えられるようにしたのも——外から挿せるようにした、あれ。注文の状態を、注文自身に持たせたのも。あれも、これも……全部、結合を、緩める話だったんだ。一個ずつ、緩めてきた。最後に残ってたのが、緩めた部品同士の、繋ぎ方だった」。
親方は、ツールボックスの蓋を閉じた。金属の、低い音がした。「あの、お礼は」と、わたしは切り出した。
親方は、いつもそうしているのか、報酬の話には乗らなかった。代わりに、短く言った。「もう、私を呼ぶな。——それが、一番の褒め言葉だ」。
それから、わたしのデスクの端に置いてあった、サーバルームの鍵を、わたしの方へ滑らせた。預かっていた車を、客に返すみたいに。「鍵は、あんたのものだ」。
去り際、親方は——これまで、こういう人は振り返らずに出ていくものだと思っていたが——一度だけ足を止めて、わたしの方を、まっすぐに見た。
「いい部品に、育てたな」。そして、付け加えた。「あとは、繋ぎ方だけ、だった」。
それだけ言って、出ていった。わたしは、その背中に、浅く頭を下げた。
仲介役のコードを、もう一度、上から読む。部品から伸びる線が、全部、真ん中の一点に集まっている。どこにも、絡まっていない。次にこのシステムが何かと絡んだとしても——今度は、自分で、解ける気がした。
整備記録簿
| こんな異音・症状が出たら | 入れるべき整備(パターン) | まだ様子見でいい |
|---|---|---|
| 機能を一つ足すたび、関係ないサービスまで巻き込んで複数箇所を直す。サービス同士が互いを直接参照している | ✓ 仲介役(Mediator)を立て、各サービスは仲介役にだけ通知する | |
| 同じ通知が複数回飛ぶ・抜ける。「誰がそれを呼ぶか」がサービスのあちこちに散っている | ✓ 「次に誰を動かすか」を仲介役の一箇所(switch)に集約する | |
| サービスを別パッケージ・別モジュールに切り出そうとすると循環インポートになる | ✓ 依存を「各サービス → 仲介役」の一方向にして、循環を断つ | |
| サービスが二〜三個で、相互参照も浅い | ✓ 直結のままで十分。仲介役は過剰 |
整備手順
- 各サービスが、他のどのサービスを直接参照しているかを数える。「二点を結ぶ一本」の組がいくつあるか(N個の部品なら最大で N×(N−1)÷2 組)。組が増えて、一つの変更が全体に波及しはじめていたら、spaghetti coupling を疑う。
- 仲介役の約束事を定義する。
type Mediator interface { Notify(sender string, ev Event) }のように、「起きたことを知らせる口」を一つ決める。イベントはtype Event stringと定数で並べ、取り違えを防ぐ。 - 各サービスから、他サービスへの直接参照(フィールド)を消し、
mediator一本にする。処理の最後で「自分で次を呼ぶ」のをやめ、mediator.Notify(...)で「起きたこと」を知らせるだけにする。業務の中身(在庫を減らす・課金する)は、サービスに残す。 - 仲介役の本体に、「誰の次は誰か」を
switchで書く。在庫を確保できたら課金、課金できたら通知と配送、というように、順番だけを一箇所に集める。同じ処理を呼ぶ判断(通知など)が散らばっていたら、ここに一本化する。 - 整備の前後で、正常系の結果(在庫数・課金額・配送)が変わらないことをテストで確かめる。今回直したかったバグ(重複・抜け)は、前のコードに「事故の記録」として書き留め、後のコードで解消されることを回帰テストで示す。
親方より
部品を一つずつ綺麗にするのは、正しい。だが、部品が綺麗でも、繋ぎ方を直結のまま増やせば、いつか手が足りなくなる。四つで六組、五つで十組。増えるのは、配線のほうだ。一つ変えるたびに全部に響くようになったら、それが頃合いだ。部品同士に手を握らせるのをやめて、真ん中に、捌く係を一つ立てろ。
ただし、やりすぎ注意。仲介役に業務ロジックまで書きはじめたら、それは新しい God Object だ。仲介役は、「次に誰か」の調停だけに、保て。中身は、部品に置いておけ。
