カウンター席に、先客がいた。
私はこの「コード食堂」で見習いとして働く、駆け出しのエンジニアです。午前の中仕込みを終えて厨房から出ようとしたとき、カウンターの端に男性が座っているのに気づきました。ノートパソコンの前に、プリントアウトした紙を三枚広げています。引き戸は半開きのままで、いつから来ていたのかわかりません。
「……いつからいたんですか」と私は声をかけました。
「すみません、引き戸が開いてたので。邪魔でしたか」と彼は言いました。少し恥ずかしそうに、でも慌てた様子はない。疲れた感じの、落ち着いた人です。
私はシェフに目でサインを送ると、シェフは手を拭きながら出てきて、一言言いました。
「何持ち込んだ」
「通知のコードです」と彼は答えました。「三つのサービスクラスに、通知を作る分岐が重複して書いてあって……先週、一か所に追加するのを忘れて、アラートが届かなくなりました」
この記事で学ぶこと
この記事は、「通知オブジェクトを生成するコードが複数のサービスクラスに散在し、追加のたびに修正漏れが起きた」という問題を、Factory Methodパターンで整理する話です。生成の判断を呼び出し側から切り離し、専用の窓口クラスに集める構造を作ることで、なぜ追加が差し込みだけで済むようになるのかを仕組みから解説します。
| 学ぶこと | ひとことで言うと |
|---|---|
| Factory Methodパターン | オブジェクトを生成する専用メソッドを基底クラスに定義し、“何を生成するか"の決定をサブクラスに任せる技法 |
| Creator(クリエーター) | ファクトリメソッドを持つ基底クラス。生成後のオブジェクトを使う共通処理も持つ |
| ConcreteCreator(具象クリエーター) | ファクトリメソッドを実装し、具体的なオブジェクトを生成するサブクラス |
| Product(プロダクト) | ファクトリメソッドが生成するオブジェクトの共通インターフェース(Moo::Roleで表現) |
| scattered-creation(生成の散在) | オブジェクトを new する判断が複数の呼び出し元に重複している状態。追加のたびに全箇所を修正しなければならない |
対象読者は、次のような人を想定しています。
- PerlとMooの基本(
has、new、extends、with)がなんとなく分かる - 「同じ
if/elsifを複数の場所にコピーしていて、追加のたびに探して修正するのが不安だ」と感じている - 第4作のTemplate Methodパターンを読んで、継承で骨格をまとめる考え方に触れている
技術スタックは Perl / Moo / Moo::Role です。コードはすべて手元で動かし、テストが通ることを確認しています。本文中のモジュールは要点を抜き出して示しているため、実際にファイルへ保存するときは Perl の作法として末尾に 1; を加えてください。
広げっぱなしの伝票
彼が来たのは、通知コードに関わるインシデントが起きた翌週のことだと言いました。事態はもう収拾している。でも「また同じことをやりそうで怖い」と感じて、根本的に直したくて来たのだそうです。
彼が動かしているWebサービスには、ユーザーへの通知機能があります。最初はメール通知だけでした。しばらくして、Slack通知も追加しました。その後、プッシュ通知も追加しました。三つのチャンネルに対応するコードを書いたとき、彼は UserService、OrderService、MonitoringService という三つのサービスクラスに、それぞれ同じような if/elsif の分岐を書いていきました。
「最初は一か所だったんです。サービスを増やすたびに、同じ分岐を書いて……気づいたら三か所になってました」
コードを見せてもらいました。各サービスは起動時にどのチャンネルを使うかが設定されています。ユーザー登録の通知はメール、注文確認はSlack、監視アラートはプッシュ通知——という具合です。UserService の通知処理から始まっています。
| |
同じ構造が、OrderService にも MonitoringService にもありました。三ファイルそれぞれに、「どの通知クラスを new するか」を判断する if/elsif が書かれています。
先週何が起きたか、彼はそのまま話しました。プッシュ通知が新しいチャンネルとして追加されたとき、UserService、OrderService、MonitoringService それぞれの if/elsif にプッシュの分岐を追加する作業が必要でした。UserService と OrderService には書き足した。でも MonitoringService への追加を忘れた。監視アラートがプッシュ通知で届かなくなり、翌朝ようやく気づいたそうです。
「どこからこうなったんでしょうね」と彼は独り言のように言いました。怒っているわけでも、誰かのせいにしているわけでもない。ただ、疲れた顔をしていました。
同じ判断が三か所
シェフは紙を受け取らず、ターミナルに向かいました。
「grep してみるか」
画面に表示されたのは、三つのファイルにそれぞれ EmailNotifier を use している行でした。
| |
「このサービス三つ、全員が EmailNotifier と SlackNotifier と PushNotifier を直接知っている」とシェフは言いました。「通知クラスを三つ抱えて、どう使い分けるかを自分で判断している。これが三か所ある」
私には EmailNotifier と SlackNotifier の中身の違いがわかりません。でも「三か所に同じ判断がある」という言葉を聞いて、少し頭の中で引っかかるものがありました。
シェフが続けました。
「料理に例えると——ホールスタッフ三人が、それぞれ厨房の全担当の顔と仕事内容を覚えている状態だ。新しいシェフが来たとき、三人全員に"新しい人はこういう仕事をする"と教え直さないといけない。一人でも忘れると、その人だけ伝票を渡せなくなる」
散在する生成(scattered-creation)——これが問題の名前です。オブジェクトを new する判断が呼び出し元の複数箇所に重複して書かれており、新しい種類を追加するたびにすべての箇所を修正しなければならない状態のことです。一か所でも見落とすと、その箇所だけ古いまま動き続けます。
「最初から三か所に書いたわけじゃないんです」と彼は言いました。「一か所に書いて、別のサービスが必要になるたびに、同じ if を書いて……」
「それだけ動いていたなら、仕方ない」とシェフは言いました。「問題は、仕組みが変わらないまま数が増えたことだ」
担当窓口を立てる
シェフは画面に向かいながら、厨房の壁を一度だけ指差しました。
壁には、伝票スタンドが三本立っています。「email担当」「slack担当」「push担当」と書いたラベルが、それぞれに刺してあります。
「この食堂の配膳係は、伝票をどのスタンドに差すかを知っている。でも、各スタンドで誰がどう作るかは知らない。担当スタンドが、自分の担当(生成する通知オブジェクト)を知っている」
シェフはコードを書き始めました。
まず、通知クラスの共通インターフェースを定義します。
| |
Product(Moo::Role)——ファクトリメソッドが生成するオブジェクトの共通型です。requires 'send' と書くことで、このRoleを with したクラスは必ず send を実装しなければなりません。実装しなければ実行時エラーになります。
具象通知クラスは、このRoleを消費(with)して作ります。
| |
| |
| |
次に、チャンネルの基底クラスを作ります。
| |
Creator(基底クラス)——create_notifier がファクトリメソッドです。「何を生成するか」はサブクラスに任せ、「生成した後に何をするか」(notifyメソッド)だけを持ちます。create_notifier は実装しなければ実行時エラーになります。
notify メソッドの構造が前回のTemplate Methodパターンに似ていることに気づく方もいるかもしれません。「ファクトリメソッドを呼んで、返ってきたオブジェクトを使う」という骨格を親クラスが持ち、「何を返すか」だけをサブクラスが決める——この構造は、確かにTemplate Methodと重なっています。
そして、各チャンネルの実装クラスを作ります。
| |
| |
| |
ConcreteCreator(具象クリエーター)——create_notifier を実装し、具体的な Notifier サブクラスを生成して返します。EmailNotifier を知っているのは EmailChannel だけで、サービスクラスは知りません。
サービスクラスはこう書き直します。
| |
UserService はもう EmailNotifier も SlackNotifier も use していません。NotificationChannel という基底クラスだけを知っています。どの具象クラスを使うかは、外から channel として渡されます。OrderService も MonitoringService も、同じ構造になります。
シェフは最後に言いました。
「SMSを新たに追加するなら——SmsNotifier を作って Notifier を with して、SmsChannel を作って NotificationChannel を extends して create_notifier を書く。それと、起動時の設定箇所の if/elsif に elsif ($type eq 'sms') を一行追加する。UserService も OrderService も MonitoringService も、変えない」
彼はコードを追いながら聞いていました。私も横で聞いていましたが、一つだけ引っかかっていることがありました。シェフが「どのチャンネルを使うかは外から渡す」と言ったとき、「でも、どれを渡すかは誰かが決めないといけない」と思っていたのです。
彼が口を開くより一瞬早く、私は言ってしまいました。
「あの……その、どのチャンネルを使うかを決める if/elsif は、どこかに残りますよね? 窓口を選ぶのは、結局誰かが判断しないといけないはずで」
言ってから、余計なことを言ったかもしれないと思いました。でも彼が「あ」と言って、私を見ました。
「それです。僕が聞きたかったのは、それです」
少し気まずくなりましたが、シェフが「いい問いだ」と言いました。
「正直に言う。if/elsif は消えない。ただし——それは今、どこにある?」
シェフはもう一つのコードを示しました。
| |
「業務ロジックの外——アプリケーションの起動時か、設定を読む場所の一か所だ。UserService の中ではない。OrderService の中でもない。MonitoringService の中でもない」
「前は何か所あった? 三か所だ。UserService、OrderService、MonitoringService——それぞれが if/elsif を持っていた。一か所でも忘れると、そこだけ古いままになった。今は——一か所だ。そして、その一か所は業務を処理するコードじゃない。“どのチャンネルを使うか"という設定を読む場所だ」
「UserService は EmailChannel を知らない。SlackChannel も知らない。渡された channel の notify を呼ぶだけだ。SMS窓口を追加するとき、UserService は変わらない」
彼は少し考えてから、言いました。
「つまり——if/elsif がなくなったんじゃなくて、業務のコードの中から出ていった、ということですか」
「そうだ」
私は少し遅れて「あ」と思いました。先に言ってしまった問いの答えが、ここに繋がっていたのかと。合っていたのかどうかはわかりませんでしたが、引っかかっていたものが少し取れた感じがしました。
伝票は窓口に渡すだけでいい
「試してみましょう」と彼はキーボードを打ちながら言いました。
| |
どちらも動きました。UserService は一行も変えていません。
それからもう一つ試しました。SMS通知を追加したとしたら、どうなるか。
| |
SmsNotifier と SmsChannel を追加しました。それだけです。
| |
UserService は動きました。OrderService も MonitoringService も同じです。先週みたいに「どこかに追加し忘れる」という状況が、構造的になくなりました。
シェフが一言で締めました。
「配膳係が厨房を知らなくていいのが、この窓口の仕事だ」
彼はしばらくコードを見てから、言いました。
「これなら——同僚に説明できそうです。“追加するときは窓口を一つ立てるだけ、サービスクラスは変えなくていい"って言えば通じると思います」
安堵したように見えました。怒りや焦りではなく、「言葉を手に入れた」という顔をしている(ように見えた)。
シェフの仕込み工程表
今日の「料理」を振り返ります。あなたのコードに同じ匂いがしたら、同じ手順で仕立て直せます。
| 問題(調理ミス) | 技法(パターン) | 効果(仕上がり) |
|---|---|---|
生成のための if/elsif と new が複数サービスクラスに散在(scattered-creation) | Factory Methodパターン | 生成の判断が業務ロジックから切り離され、呼び出し元は具象クラスを知らなくなる |
| 通知チャンネルを追加するたびに全サービスクラスを修正し、修正漏れがインシデントになった | Creator基底クラスに create_notifier(ファクトリメソッド)と notify(共通工程)を置く | 追加は新しい ConcreteCreator クラスを作るだけ。既存のサービスクラスに手を加えない |
UserService が EmailNotifier、SlackNotifier、PushNotifier を直接知っている | Product(Moo::Role)で通知の共通インターフェースを定義する | 呼び出し元は Notifier Role を満たすものを受け取るだけ。どの実装かを知らなくてよい |
工程
- Product Role を定義する:
Moo::Roleとrequiresで、ファクトリメソッドが生成するオブジェクトの共通メソッド(sendなど)を宣言する - 具象Productクラスを実装する:
with 'Notifier'して、sendを実装する。各クラスが独自の接続先や形式を持てる - Creator基底クラスを作る:
create_notifier(ファクトリメソッド)にdieを書いて未実装を強制する。notify(共通工程)で「生成→送信」の骨格を持たせる(Perlは動的型付けなのでエラーは実行時) - ConcreteCreatorサブクラスを実装する:
extends 'NotificationChannel'して、create_notifierに具象Productのnewを書く - サービスクラスを書き直す:
has channel => ...で Creator基底クラスを受け取るようにする。具象クラスを直接useしない - 合成ルートで ConcreteCreator を選ぶ: アプリケーション起動時・設定箇所に
if/elsifを一か所書く。この一か所が「どのチャンネルを使うか」の唯一の判断場所になる
シェフより
伝票は窓口に渡すだけでいい。配膳係が厨房の全担当者の名前と仕事を覚えている必要はない——そういう構造を作れば、新しい担当が来ても配膳係の動きは変わらない。それが Factory Method の仕事だ。
正直に言っておく。「どのチャンネルを使うか」の if/elsif は消えない。アプリケーションの起動時か、設定を読む場所に、一か所だけ残る。Factory Method が解決するのは、その判断が業務ロジックの中に散らばることだ——散らばっている if を一か所に集め、業務を処理するコードから切り離す。場所と数が変わる。「完全に消える」とは言わない。
彼が帰ってから、私はカウンターを拭きながら今日のことを思い返しました。
先に言ってしまったこと——合っていたから良かった、と今は思います。あの「窓口を選ぶのは誰か」という問いを口に出さなければ、シェフの「一か所だ、業務ロジックの外だ」という答えも出てきませんでした。言わなければわからなかった。そう思ったら、少し気が楽になりました。
「伝票は窓口に渡すだけでいい」——彼が帰り際に言った言葉が頭に残っています。同僚にそう説明するのだと言っていた。シンプルで、しかも正確な言い方だと思いました。私が先ほど引き出してしまった問いも、その一言に収まっている気がしました。
