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