■ 1. 概念設計の位置づけ
- 概念設計とは:
- そのアプリケーションの世界にそもそも何が存在するのかを定めること
- 何と何を同じものとして扱い、何と何を別のものとして扱うのかという判断
- 上流工程の基本設計とは別物:
- クラスをきれいに分けるとかDDDを導入するといった話より少し手前にある内容
- UML図など所与の要件を実装へ落とし込む設計技法とは異なる
- 判断の当否が決めるもの:
- 判断が合っていると、その後の実装は驚くほど素直になる
- 外すと一つ一つの実装が正しくても、仕様が増えるたびに少しずつ帳尻合わせが必要になる
- 執筆の動機:
- 概念の整理がなぜどのように重要なのかという一点を取り上げた文章や書籍が見つからなかった
■ 2. Acme社のECサイト: 第一の世界線
- 初期モデル:
- OrderがproductId、quantity、unitPrice、shippingStatusを持つ
- 注文の明細を表すと同時に、その商品の配送状態も保持する
- 本稿では簡単のため、一商品の注文明細をOrderと呼ぶ(通常はOrderLineと命名される)
- 配送中の破損という出来事:
- カスタマーサポートから同じ商品をもう一つ送ってほしいと連絡が来る
- quantityを2にしてはいけない、顧客が買ったのは1個であるため
- 0円のOrderという回避策:
- quantity 1、unitPrice 0のOrderをもう1つ作れば動く
- しかし妙な存在が生まれ、購入とは呼べないデータが注文データに混ざる
- 妙な存在が生む疑問:
- 顧客がその商品を二個購入したといえるのか、購入点数に数えるのか
- 売上集計では除外するのか、返品処理ではどうするのか
- ユーザーの購買行動によって生まれたデータではないのに注文データに混ざっている
- 後から推薦システムを作る場合、このレコードはノイズになりかねない
- 帳尻合わせの連鎖:
- モニタリングではunitPrice 0のレコードを除外し、売上にもカウントしない
- コストには関わるため、配送件数の集計ではカウントする
- 推薦システムでは0円のOrderを学習データから外すよう申し送る
- これから関係するコードや運用のあちこちで、処理ルールを決めて少しずつ帳尻を合わせることになる
■ 3. 第二の世界線: OrderとFulfillmentの分離
- 概念の分割:
- 何をいくつ、いくらで買ったかをOrderと名付ける
- その約束をどう履行したかをFulfillmentと名付ける
- 注文とその履行は別々の概念であると認識し、区別する
- モデル:
- OrderはproductId、quantity、unitPriceのみを持つ
- FulfillmentはorderId、quantity、statusを持ち、statusにはfailedを含む
- 再送への対応:
- 最初のFulfillmentは破損したという事実をfailedとして残す
- もう一度届けるためのFulfillmentを1つ作れば、状況をありのまま表現できる
- 得られる帰結:
- 再送対応のために0円の注文を無理やり作る必要がない
- モニタリングにおかしな条件分岐も要らない
- 売上はOrderを見ればよく、配送コストはFulfillmentを見ればよい
- 推薦システムは実際の購買行動を反映するOrderに対して学習すればよい
- 何年後の開発者にも通じる:
- この0円注文は実は注文ではないという事情を未来の開発者へ申し送る必要がない
- OrderとFulfillmentを同じ意味のデータとして扱う理由がない
- 推薦の学習データに両者を混ぜる理由がそもそもなく、混ぜることの説明がつかない
- 二つの世界線の違い:
- 再配送機能の実装力の差ではない
- システムの中に何という概念を置いたか、現実の出来事をどういう概念で捉えたかの違いである
- 現実の出来事の単純さ:
- 受注担当が1個の注文を受け、1回目の配送で破損が起き、配送担当が2回届けただけである
- 第一の世界線でコードやルールが複雑化したのは、Orderに二つの意味を背負わせていたためである
- 対応が自然であることの価値:
- 第二の世界線は再配送という新機能をうまく実装したわけではない
- 最初から売買と履行を別のものとして認識していたため、Fulfillmentを1つ増やすだけで済む
- 急場凌ぎではなく、これしかないという当たり前の対応方法である
- だからその対応のツケが後のコードや運用へ波及しない
■ 4. 未来の仕様が先に実装済み
- 分割発送の要望:
- 物流チームから、3個注文された商品のうち1個だけ先に発送したいと要望が来る
- 残り2個は入荷してから発送する
- ユーザーが注文した数量は3個のまま、届けるタイミングだけが二つに分かれる
- コード変更が不要:
- quantity 1のFulfillmentとquantity 2のFulfillmentを持たせるだけでよい
- Orderに分割発送モードという状態を追加する必要はない
- partiallyShipped = trueのようなフラグも要らない
- すでにある機能だけで、何ら不自然さもなく運用を始められる
- 予測ではなく認識の結果:
- 分割発送は当初の仕様になく、Fulfillmentも将来の分割発送を予測して導入されたわけではない
- 注文の履行という概念が存在すると認識し、その概念として自然な形に実装しただけである
- その結果、まったく別の理由から後日やってきた仕様まで最初からモデルの中で実現されていた
- 偶然ではない理由:
- 人が思いつく仕様は、その人が現実をどう認識しているかというメンタルモデルの範囲内で自然なものになる
- コードが表現する概念が現実の捉え方と噛み合っていれば、後の自然な仕様が既存の概念に収まるのは当然の帰結である
- 新規コードの不要化:
- 新しいコードをほとんど書かず、既存の概念をそのまま使うだけで対応できることすらある
- まるで最初からその使い方を見越していたかのようになる
■ 5. バグが生まれる前に消える
- 概念分離の第二の効果:
- 適切な概念設計がもたらすものは未来の仕様追加・変更への強さだけではない
- 概念を正しく切り分けると、注意して防ぐ必要があったバグがそもそも発生しにくくなる
- 第一の世界線での集計事故:
- ordersのquantityを合計するというごく自然なクエリに交換品まで含まれてしまう
- unit_price > 0という条件も危うい
- クーポンやキャンペーンによる本物の0円購入が将来現れれば、正しい購入まで消えてしまう
- 本質的な問題:
- 条件式が足りないことではなく、購入ではないものがOrderとして存在していることである
- システムのあちこちで本物のOrderだけを選ぶ方法を覚えておかなければならなくなる
- 推薦でも分析でも返品でも、新しく加わるコードのたびに同じ注意が必要になる
- 分離後の集計:
- ordersのquantity合計は、そのまま購入数量を意味する
- 再配送を何回してもOrderは増えない
- fulfillmentsのquantity合計は、実際に履行された数量を意味する
- ルール遵守に依存しない仕組み:
- 交換品は売上から除外するというルールを全員が正しく守っているのではない
- Fulfillmentにはそもそも顧客がいくらで買ったかという意味を持たせない
- 交換品という配送上の出来事が、売買を表す場所に入り込まない
- 注意力への非依存:
- 正しさを後続コードを書く人の注意力に委ねなくてよくなる
- バグを予防したり検知したりするまでもなく、そもそもバグの存在余地を減らせる
■ 6. 概念テスト
- 概念は新機能を考える道具になる:
- 概念がうまく定まると、実装時だけでなく新しい機能を考えるときの道具にもなる
- 新しい要望をすぐに画面やAPIやデータベースの話へ落とさない
- まず、いまシステムに存在する概念を使って言い直してみる
- 言い直しの手順:
- ユーザーに何をできるようにしたいのかを定める
- システムの世界では誰が、何に対して、何をすることになるのかを正確な言葉で表現する
- その文章が、それぞれの概念にとって本来できて当然のことになっているかを見る
- 電子書籍アプリの例:
- Bookという概念はタイトルがあり、著者がいて、ページを持ち、読むことができる
- ユーザーが本を好きな順番で本棚に並べられるようにしたいという機能を考える
- 不自然な表現:
- Bookが自分が棚の何番目に表示されるかを持つという表現は少し妙である
- 棚の何番目に置かれているかは、本そのものにとって本質的な性質ではない
- 同じ本が別の本棚にも置かれるかもしれず、本棚がなくてもBookはBookである
- 自然な表現:
- BookshelfがBookを並べる、あるいはBookshelf上にBookのPlacementがあると表現する
- 実装者でなくても判断できる:
- この違和感はコードを一行も書かなくても見つけられる
- プロダクトデザイナーが提供したい体験を、システム内の概念で正確な文章にしてみればよい
- 判定の意味:
- 既存の概念にとって自然なら、その機能は現在の世界観の中にきれいに収まる可能性が高い
- どう言い換えても誰かに不自然な責務を負わせなければ表現できないなら、立ち止まった方がよい
- 機能そのものがおかしいのかもしれない
- その機能によって初めて姿を現した、まだ名前のない概念があるのかもしれない
- 実装可否のテストではない:
- 今のクラス構造で実装できるかというテストではない
- その機能が、この世界について我々が置いた概念と矛盾せずに語れるかというテストである
- 意図の保存という利点:
- 機能を概念の言葉で表現しておけば、なぜそういう形になっているのかも残る
- 当時の企画書も議論も知らない後任でも、概念そのものから機能の意味をかなり復元できる
- 概念は、その機能をどう理解すべきかという意図を時間や組織の境界を越えて運ぶ語彙になる
■ 7. 概念の手掛かり: 関係者の視点
- 機械的に正解を出す方法はない:
- ただし概念の境界を見つけるための手掛かりはある
- 今回の例には、そのうち特に分かりやすいものが二つ現れている
- 同じ注文が立場で違って見える:
- 注文受付担当には、顧客が何を、いくつ、いくらで買ったかという情報に見える
- 物流担当者には、何を、いくつ、まだ届ける必要があるかを表す情報に見える
- 頭の中の概念の差:
- 両者は同じ注文について話しているが、頭の中で操作している概念は同じではない
- 注文受付システムが変更するものと、倉庫・配送システムが変更するものも違う
- 疑うべき兆候:
- 担当する人、組織、システムコンポーネントが違うなら、メンタルモデルも違っていないかを疑う
- 別々の主体が、別々の理由で、別々のタイミングに変更するものは異なる概念である可能性が高い
- 組織の境界との違い:
- 組織の境界をそのままクラスの境界にすればよいという話ではない
- 扱う人が変わった途端に語彙も操作も関心事も変わる場所には、概念の境界が隠れていることが多い
■ 8. 概念の手掛かり: 名前
- 曖昧な名前でも当面は困らない:
- 注文商品、購入商品、発送商品は日常会話なら多少曖昧に混ぜても通じる
- コードでもOrderItemのような名前を一つ置けば、しばらくは困らない
- 忠実な言語化が最大のコツ:
- 概念を忠実かつ的確に言語化した名前をつけることが何よりのコツである
- 正しい名前は、その概念が何であるか、何ができるはずか、そこからの含意までを決める
- 注文は注文であって、配送チケットではない
- 丁寧な言語化が区別を露わにする:
- 前者は顧客が何を、いくつ、いくらで買うと成立させた明細である
- 後者はその約束のうち何を、いくつ届けるかを表す履行の単位である
- 丁寧に言葉にすると、同じ名前で呼び続ける方が難しくなり、注文と履行に分かれる
- 現場の言葉に区別が現れている:
- 営業や注文受付では受注と言い、倉庫では出荷と言い、サポートでは再送と言う
- 同じレコードの状態を違う言葉で呼んでいるように見える
- 実際には関係者が最初から違う対象を頭に浮かべているのかもしれない
- ドメイン言語の価値:
- DDDでいうドメイン言語の価値は、業界用語をそのままコードへ持ち込めることだけではない
- 言葉の違いは概念の違いを発見するセンサーになる
- まあこれで通じるという名前ではなく、できるだけそれしかない名前を探す
- 名前が責務を決める:
- 名付けた瞬間、そこに何を乗せてよく、何を乗せてはいけないかもある程度決まる
- Fulfillmentという名前は、顧客がいくら払ったかを持たせてよいはずがないと教えてくれる
- 支払は受注の責務であり、履行の責務ではない
- 逆に配送状況や再送の記録は、Fulfillmentが持って当然のものとして扱える
- 命名は境界を詰める作業:
- 命名は単なるラベル付けではない
- 関係者が現実をどう概念として認識するかを左右する
- そのコードで何ができて何ができないかまで、あらかじめ大まかに決めてしまう
- 命名への投資:
- 命名にはいくらでも時間を投じてよい
- 認識の齟齬がなくなり、後のバグや新機能実装による帳尻合わせに頭を悩ませずに済む
- 命名が曖昧なまま詰め切れないなら、そもそも概念レベルで何かが矛盾している可能性がある
■ 9. 結び
- 言葉遊びに見える設計:
- 概念設計というと少し抽象的に聞こえ、一見して言葉遊びのようにも見える
- しかしその設計は、その後かなり長い間、何を普通のケースとして書けるかを決め続ける
- 何を特殊ケースとして扱うか、どんな申し送りが必要か、どんなバグが書けてしまうかも決める
- 現実が破綻していないことが根拠:
- 現実世界そのものは破綻せず回っている
- 現実を無理なく正しく反映した適切な概念が置かれていれば、コードも破綻しない
- コードの命運を握る:
- 概念設計が適切かどうかが、その後のコードの命運を握る
- うまくいけば未来の仕様が何もしなくても実装されており、未来のバグが生まれる余地ごと消える
■ 10. 参考文献
- 本稿の位置づけ:
- 一定の経験があるソフトウェアエンジニア同士では、誰に教わらずとも通じ合う暗黙の了解である
- そのエッセンスはオブジェクト指向プログラミングやDDDなど主立ったパラダイムのたびに繰り返し現れている
- 既存の文章の多くは所与の仕様を実装へ落とす設計技法に紙面を割き、裏側の信念は説明されてこなかった
- 概念設計の価値についての一冊:
- Jackson, D. (2023). The Essence of Software: Why Concepts Matter for Great Design. Princeton University Press.
- 邦訳は『優れたデザインにとってコンセプトが重要な理由』(中島震 訳、丸善出版)
- UXデザイナーやプロダクトデザイナー寄りの視点から概念設計の重要性を解説した良書
- 概念体系が整理されていれば、デザイナーも実装詳細を聞かずに破綻しない仕様を考えられる
- 本稿はプログラミングに近い観点、同書は具体例を多数集めたケーススタディ的な内容で補完的に読める
- 命名の価値についての書籍:
- 仙塲大也 (2024)『改訂新版 良いコード/悪いコードで学ぶ設計入門』技術評論社
- 特に第11章が本稿の内容に関係する
- 概念を分離できていない命名を特定する質問や、よくない命名の解消方法などHowとWhatが厚い
- 他の章も参考になるため全編を読む価値がある
- Boswell D., & Foucher T. (2012)『リーダブルコード』オライリー・ジャパン
- 良い命名について詳しく書かれた、広く読まれた古典に近い一冊
- その他の参考:
- 本稿は純粋なオリジナルでも特定の一つの受け売りでもない
- 様々な設計パラダイムが本当に伝えたかったエッセンスの一つを自分なりに言語化したものである
- Vernon, V. (2015)『実践ドメイン駆動設計』翔泳社
- Chiusano, P., & Bjarnason, R. (2015)『Scala関数型デザイン&プログラミング』インプレス
- Alexander, C. (2013)『形の合成に関するノート/都市はツリーではない』鹿島出版会
- Emacs LispやClojureに触れてきたことも良かった
■ 11. 補遺1: KISS/YAGNIとの関係
- YAGNI違反ではないかという疑問:
- Fulfillmentのような概念を最初から切り出すのはYAGNIに反するように見えるかもしれない
- 仕様上はどちらも正解:
- 最初のOrderが持つshippingStatusも、配送状況を確認できるという当初の仕様を満たしていた
- Fulfillmentの切り出しは、同じ仕様を満たすもう一つの実現方法にすぎない
- 原則の素朴な解釈が招く誤り:
- KISSはコードをシンプルに保てという原則である
- YAGNIはいつか使うと思ってもどうせ使わないという原則である
- いま必要なコード量が最も少ない方を選ぶとだけ解釈すると、第一の世界線が魅力的に見える
- 概念に対して自然な実装は最小変更より少しリッチになりがちで、過剰にも見えてしまう
- 最小変更の代償:
- 目先の変更量は確かに最小で済む
- しかし代償として帳尻合わせがコードと運用のあちこちに波及していく
- 長期的には、その不自然さがずっと悪影響を発し続ける
- 原則自体は誤っていない:
- KISSやYAGNIは余計な機能や抽象化を作らないための原則である
- 現実を無理なく表現できる概念設計に沿って実装することは、全くもって余計ではない
- むしろ満たさないとまずいことが起きる、一種の実装上の大原則である
- 概念に対してコードが自然であることを崩すほどコード量を減らすことまでは求めていない
- 両立のさせ方:
- まず現実を無理なく表現できる概念を置く
- その制約の内側で、必要な機能に対して最もシンプルで直接的な実装を選ぶ
■ 12. 補遺2: 練習問題
- 問題1 解約しても使えるSaaS:
- Subscriptionがactiveかcancelledのstatusのみを持ち、activeならPro機能を利用できる
- 解約しても支払済みの月末まではProを使えるようにするという仕様が来る
- 翌月には障害のお詫びとして、契約していないユーザーにもProを1か月無料付与したいという仕様が来る
- このSubscriptionが何と何の二役をしていたかを問う
- 問題2 承認済み文書の編集:
- Documentがbodyとapproved: booleanを持つ
- 承認後に本文を編集したら再承認が必要にするという仕様が来る
- 編集時にapproved = falseとすれば実装できる
- しかし、もっと自然な概念の切り方がないかを問う
- 問題3 再試行するJob:
- Jobがstatus、startedAt、errorを持ち、statusはrunning、succeeded、failedを取る
- タイムアウトしたら自動で再試行するという仕様が来る
- 一回目は失敗し、二回目は成功した
- このJobがfailedなのかsucceededなのかを問う