アンチパターンを理解して package by feature へ
はじめに
ニコニコ生放送でフロントエンドを担当している misuken です。
今回は関心が分散してしまう理由やその原理、この問題に対する適切な対処法を通して、package by feature の合理性や、そこで重要になってくる関心の単位などについて解説していきます。
規模の大きなものを扱っている方、分類が苦手な方、分類に関して悩みを感じている方には特に有用です。
前提
- Reactでコンポーネントを管理する例で説明します
- 当然React以外の様々なディレクトリ構成でも応用できます
-
BCD Design の概念も覚えておくとより体系的に理解できます
- 精度の高い明名ができれば、分類の効率も精度も上がります
現実世界で捉える関心の分散
通常、自宅や職場でトイレに行くとき、同じフロアや同じ建物内のトイレに行きます。
もしもトイレだけの建物が隣に建っていて、そこに行かなければならないとなったらとても不便に感じるはずでしょう。
しかし、システムの開発に置き換えると、いつの間にかそのような状態に陥っていることが多々あります。
以下の例を見て下さい。
適切な構成
💭 抽象的な存在
🍽 食卓
🚪 部屋
🚽 トイレ
⭐ 具体的な存在
🏠 家
🏠 家の食卓 🍽
🏠 家の部屋 🚪
🏠 家のトイレ 🚽
🏢 オフィス
🏢 オフィスの食卓(食堂) 🍽
🏢 オフィスの部屋 🚪
🏢 オフィスのトイレ 🚽
問題の構成
🍽 食卓
💭 抽象的な食卓 🍽
🏠 家の食卓 🍽
🏢 オフィスの食卓 🍽
🚪 部屋
💭 抽象的な部屋 🚪
🏠 家の部屋 🚪
🏢 オフィスの部屋 🚪
🚽 トイレ
💭 抽象的なトイレ 🚽
🏠 家のトイレ 🚽
🏢 オフィスのトイレ 🚽
これではオフィスも家も機能しなくなります。こんなオフィスで働きたくないですよね?
これで嬉しいのはトイレの清掃員の方くらいではないでしょうか。。。
😚「いやいや、それはそうだけど、実際の開発ではそんなことしませんよー」
context
HouseContext
OfficeContext
hooks
HouseHooks
OfficeHooks
provider
HouseProvider
OfficeProvider
🤔「あれ?なんか見覚えが・・・」
関心の千切り
このように関心でまとまるべきものを切り刻んで別の軸にまとめてしまうことをキャベツの千切りになぞらえて 関心の千切り と呼ぶことにしました。
一般的には 技術駆動パッケージング や package by layer と呼ばれているようですが、現実には技術駆動やレイヤーではなくとも起こったりもするので、この記事では 関心の千切り と呼んでいきます。
現実的には全てを千切りにすると流石に使い勝手が悪すぎて途中で問題に気付くはずですが、hooks や utils あたりで片足突っ込んでしまい、途中まで千切りしかけているパターンはよくあるはずです。
package by feature
近年は上述のアンチパターンの認識も広がっているようで、合理的なディレクトリ構成として package by feature に関する記事がいくつも投稿されています。(この記事の執筆中にも次々投稿されていました)
ちなみに BCD Design は package by feature でありつつ、いくつものプラスαの恩恵が得られる分類法ということになります。
関心の千切りが起きる理由
関心の千切りが起きる理由は、人はついつい "形式的に似ているもの" でまとめようとしてしまうからです。
「これはユーティリティだから utils へ」
「これは Hooks だから hooks へ」
「これはコンポーネントだから components へ」
しかし、現実には "似ているもの" でまとめて良いグループとそうではないグループがあります。
ここを理解していないと、永遠にハマり続けることになります。
関心の千切りが起きる原理
ここからは理解しやすくするために "似ているもの" を "型" と捉えてみましょう。
何かを指す名前というのは、BCD Designで挙げているように、概ね以下のいずれかに収まります。
型 -> Icon
状況 + 型 -> SearchIcon
関心 + 型 -> UserIcon
関心 + 状況 + 型 -> UserSearchIcon
具体的には以下のようになり、型は Button
Card
Icon
です。
base
Button
Card
Icon
case
SearchButton
SearchIcon
domain
UserCard
UserIcon
UserSearchButton
UserSearchIcon
型の名前は末尾に位置するため、型でまとめようとすると後方一致でまとめることになり、前方の単語はバラバラになります。
Button
SearchButton
UserSearchButton
Card
UserCard
Icon
SearchIcon
UserIcon
UserSearchIcon
前方と後方どちらからまとめるかの違い、これが関心の千切りが起こる原理です。
食卓 部屋 トイレ、context hooks providerでまとめたときも同じことが起きています。
後方一致でまとめて良いもの
後方一致でまとめるのが問題なら、全て前方一致でまとめれば良いかというと、そうではありません。
例えば、型だけで構成されるようなコンポーネントは、組み合わせた名前を後方一致でまとめたほうが管理しやすくなります。
理由としては以下。
- 状況や関心の単語が前方に存在せず分散が発生しないため
- 型の主体を意味する単語が後方に位置するため
- 型の世界では型単位で管理したほうが整合性が合うため
ここで言う整合性とは、次のようなことを指します。
TextBoxField は、TextBox を部品として使用する Field です。
これはTextBox型(TextBox<Field>
)ではなく、Field型(Field<TextBox>
)に属するということです。
関心の単位
一般的に開発者が口にする "ドメイン" とはサービスの関心のことを指しますが、TextBoxにはテキストボックスとしての関心事があるように、◯◯的関心という単位も意識する必要があります。
- UI的関心
- 状況的関心
- サービス的関心
それに加えてUIのみの世界を「文脈を持たない世界」、状況やサービスに依存を持つ世界を「文脈を持つ世界」に分けると以下の図のようになります。(矢印は依存する方向を表し、色は BCD Design の意味に合わせてあります)
このように捉えると、色々と細部が見えてきます。
- 各 Hooks を一箇所(例:
src/hooks
)にまとめようとするとUI的関心が分散する - TextBoxField はあくまで TextBox を部品として利用した Field であり、TextBox ではないため Field 側にまとまる
- 文脈を持つ世界は、文脈を持たない世界のUIを自身の文脈のスペースに置き、独自の依存を反映する場になる
こうしてみると、UIに依存した部分は極力文脈を持たない世界で完結させ、文脈を持つ世界では用意されたUIを使うことに徹することで、それぞれの責務に集中できる構成になっていることがわかります。
また、UI側は「文脈を持たない世界」であるため、package by feature のデメリットに挙げられる "処理の重複" も解決する場所として機能します。
関心の千切り防止チート
ここまでの説明をまとめた 関心の千切り防止チート というものを作ってみました。
これに当てはめて考えれば、どちらに該当するかの見分けがつきやすく、適合しないものがあれば名前が適切であるか?責務がおかしくないか?といった点検にも役立つでしょう。
部品Aを利用するB
"部品Aを利用するB" が成立する場合は後方一致でまとめます。
- TextBox を部品として使用する Field = TextBoxField
- Icon を部品として使用する Button = IconButton
- Image を部品として使用する List = ImageList
関心Aで利用するB
"関心Aで利用するB" が成立する場合は前方一致でまとめます。
- Toast の関心で使用する Provider = ToastProvider
- Toast の関心で使用する Hooks = ToastHooks
- Toast の関心で使用する Context = ToastContext
- Search の関心で使用する TextBoxField = SearchTextBoxField
- User の関心で使用する TextBoxField = UserNameTextBoxField
※ UserName は User 自体が持つプロパティなので、UserName は User の関心事に含まれます
※ 記事公開時に "文脈Aで利用するB" としていましたが、UIは分脈を持たない世界であるため、わかりやすくするため "関心Aで利用するB" に変更しました
utils や hooks には何が入るのか?
src/utils
や src/hooks
というディレクトリを用意するのであれば、そこにはOSSで公開できるレベルのものを配置しましょう。
それらは 汎用的関心 という位置付けになります。
汎用的関心も「文脈を持たない世界」であるため、こちらも package by feature のデメリットに挙げられる "処理の重複" を解決する場所として機能します。
もし、OSSとして公開できない依存があるとすれば、それはサービスに依存した何かが含まれているので、そこを切り出して文脈を持つ世界へ移動するべきということを意味します。
結果としてUIにも、状況にも、サービスにも依存しないものが残るとなると、DOMやデータ操作に関する単語のみの世界になるはずです。(DOMやデータ操作以上に具体的な単語が含まれる場合、責務の漏れ出しが疑われます)
hooks (一般的なライブラリレベルの抽象度のもの)
useClickOutside
usePrevious
utils
ArrayUtil
DateUtil
DomUtil
NumberUtil
StringUtil
上記の構成はさらに適切な分類が可能ですが、長くなるので別記事として執筆予定です。
データ構造でも起こる関心の千切り
これはおまけですが、データ構造でも(config系でも)関心の千切りが起きがちです。
値名でまとめると関心が分散します。
type Data = {
thumbnailUrls: {
office: string;
house: string;
},
names: {
office: string;
house: string;
},
descriptions: {
office: string;
house: string;
},
floors: {
office: number;
house: number;
}
};
関心でまとめると構成がわかりやすくなり、一部を抽象化して再利用できたりもします。
type Building = {
name: string,
description: string,
thumbnailUrl: string,
floors: number,
};
type Data = {
house: Building;
office: Building;
};
何も考えずに "似ているもの" でまとめようとする前に、名前に文脈が含まれていないか立ち止まってみる癖を付けると良いでしょう。(名前に表れていない隠れた意図が無いかも含めて考える必要があります)
ただ単にあるものを組み立てただけの構造ではなく、一番最初に挙げた例 "家には「食卓 部屋 トイレ」がある" という構成のように、文脈に沿って具現化された構成にこそ価値があります。
上の例では、最初 names
だけだったものが、他の項目も増えたときに惰性で千切りが加速するといたシーンが多いので、一番最初が一項目だけだったとしても、その段階で関心でまとめてスケールする流れである成長軸を作っておくことが大切です。(これはYAGNIにはなりません)
まとめ
今回は関心が分散してしまう理由やその原理、この問題に対する適切な対処法を通して、package by feature の合理性や、そこで重要になってくる関心の単位などについて解説しました。
問題の本質に加え、関心の単位や文脈を持つ世界と持たない世界の境界など、新たな視点を手に入れることでこれまで見ていた景色が変わった方もいらっしゃるのではないでしょうか。
一番最初に挙げた例のように、現実世界に置き換えてみれば package by feature が当たり前でも、開発ではいつの間にか関心の千切りになってしまうシーンが多々見られるので、常に意識を持つことが大切です。
この知見はプロジェクトのディレクトリ構成だけでなく、データ構造やconfig周り、さらには資料整理やリアルな部屋の片付けまで応用の効くものなので、日頃から色々なものを対象に練習しておくと様々な場面で役立つことでしょう。
株式会社ドワンゴでは、様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。
Discussion