人間に頼らず自動でドメインを決める方法
スクリプト言語での開発では2チーム以上になるとモジュラーモノリスにしたくなる
プログラムをモジュールごとに分割することを指す用語にはいくつかの呼称があります。
- マルチモジュール化
- co-location
- モジュラーモノリス
マルチモジュール化は主にネイティブモバイルアプリで使われる呼称で、co-locationは主にWebフロントエンド、モジュラーモノリスは主にバックエンドで使われる用語です。
一般論としてクライアントアプリの開発の方がサーバーサイドよりも比較的早くモジュール分割の話が出てきます。モジュール分割のモチベーションには以下の観点があります。
- (比較的)クライアントアプリ開発で発生しやすい
- (型付けのある言語か)
- ビルドのある言語か
- どちらでも発生する
- チームが2チーム以上あるか
- LLM CLI(Claude Codeなど)からコードを守りたい(New!)
型付け、ビルドの観点だとクライアント側は静的型付けやコンパイルが必要な言語(iOSならSwift, AndroidならKotlin)が比較的使われ、サーバー側は型づけやコンパイルが必須ではない言語(Ruby, PHP, Python)が比較的使われる傾向にあります。
型付けでいうとアプリの生存期間が長いクライアントアプリほど依存が複雑になりやすく、(バッチ処理を除けば)HTTPレスポンスをUXのために数秒以内に返すという時間制限のあるプログラミングであるサーバーサイド開発は型付けがなくてもコードが脳に収まりやすい構造にはあります。ただ最近はPythonのType Hints、Rubyのrbsだったりスクリプト言語でも任意でつけられる型の機構が出てきてはいます。
ビルドの観点だと例えばC#でUnityのゲームの開発をするなどがわかりやすいですが、クライアント側の方がパフォーマンス要求が高くなることもあるため、ビルドがいるような言語が使われることが多いです。ビルドという工程があるとビルド待ちで開発が律速することもあるためマルチモジュール化のモチベーションが比較的早く出てきます。
サーバーサイドでもスクリプト言語でない言語を使うことはあるんですが、例えばGoはビルドがありますがマイクロサービスで使われることが多いので最初からビルド単位が分かれていたりします。
マルチモジュール化のモチベは他にもあって、チームが大きくなってチーム分割の観点でモジュールを分割をしたいユースケースもあります。チームAとチームBで同じ箇所を変更しているとコンフリクトが起きやすいからですね。 ビルド待ちによる開発時間の律速は比較的起きづらいため、 スクリプト言語でのバックエンド開発だとマルチモジュール化、モジュラーモノリスの需要は2チーム以上になってから起きやすい理解です。
一方で最近だとClaude CodeなどのLLMのCLIの勢いがありますが、AI系のツールはできることが多い分プログラムを壊してしまうこともあり、AI時代こそバックエンドでもモジュールを分割したい、変更の影響を局所化したい需要が出てきたようにも思います。
ドメイン分割の境界は本当に人間にしか決められないのか
モジュールを分割すると決めたとします。モジュールのディレクトリ構成の考え方には
- 技術軸ファースト
- app/models/feature_a
- app/models/feature_b
- app/models/feature_c
- 機能軸ファースト
- app/feature_a/controllers
- app/feature_a/modles
- app/feature_a/views
の2つの考え方があります。
技術軸ファーストはまずは技術で切るやり方です。Ruby on Railsのapp/controllers, app/models, app/viewsなどがわかりやすいですね。ここをさらに機能で分割するならapp/models/feature_aaa, app/models/feature_bbb に世界を作っていく感じでしょうか。
機能軸ファーストならapp/feature_aaa/、app/feature_bbb、app/feature_cccが第一の軸でその中にapp/feature_a/controllers、app/feature_a/modles、app/feature_a/viewsと世界を作っていきます。
技術軸ファーストの方は最初はfeature_aのみとし、feature_aディレクトリを作らないとすればRuby on Railsのデフォルトディレクトリのままです。Androidの推奨アーキテクチャでもDomain Layerはoptionalとあります。全体の機能像が見えてから境界を決めるのに比べて、これからアプリがどう育つか予想できないフェーズでは技術軸でまず切ったほうが迷いは少ないので0=>1に向いているという考え方はありそうです。
機能軸ファーストの方は例えばCIでテストを実行するときに特定のfeature配下でdiffがあるときのみ実行するとすれば全体のテスト実行時間は減らせそうです。そういう意味で比較的10=>100に向いているという考え方はありそうです。
技術軸の方が技術の概念さえ決まってしまえば迷いは生じずらく、機能軸の方がこの概念はどちらの機能に所属するか白黒つかない場面もあります。パレートの法則でフィーチャーフィーチャーしているのは2割のみで8割はくっきりと機能に分けられないということもあります。common(others), feature_a, feature_bのように分類している企業もありますね。
技術軸の決め方は例えばMVCなどの設計手法がありますが、機能軸の分け方はそれほど理論が確立していないです。機能(ドメイン)分割は人間がサービスに向き合うしかないと主張される方もいますね。一方でドメインに人間が向き合うことは依然大事なもののそこで思考停止しドメイン分割を人間の聖域としてしまうのはサイエンスがないようにも感じました。
機能(ドメイン)境界の決定は本当に人間だけしかできないものでしょうか? ORM(Active Record)の関係グラフをクラスタリングしてドメインを自動分割する というのは一つの手法ですが世にあまり例がないように感じたので今回チャレンジしてみます。
Active Recordのrelationはグラフ
ゲーム開発だとパフォーマンス上の理由からバックエンドのデータストアとしてNoSQLを使うこともありますが、WebサービスのデータストアとしてはMySQLやPostgreSQLといったRDBがメインDBとして使われることが多いです。最近ではNewSQLと呼ばれる従来だとNoSQLを使わないといけなかった部分でもRDB互換インターフェースを持つよりスケーラブルなDBも登場しており、アプリ側から見るとRDBとして扱えばいいケースが増えてきています。
RDBベースのデータ設計だとテーブルとテーブルがリレーションを持ちます。Active Recordのモデルでもhas_one, has_manyとrelationを貼りますね。こういったrelationは例えばデータベースのテーブルだけを見てもある程度把握することはできます。外部キー制約が貼ってあるカラムだとテーブルAとテーブルBが接続があることがわかりますし、Active Recordを使わなくても同様の慣例、aaa_idはaaaテーブルのidを指すなどとすればデータベースのみで関係を掴むことはできます。
一方でActive Recordは単一テーブル継承、ポリモーフィック関連付け、Delegated Typeといったより複雑なrelationをサポートしており、Rubyのエコシステムはそのメタプログラミング的性質からrelationを取得するAPIが用意されています。
バックエンド開発でORMを使うか、使わず生SQLを書くかが議論になることがあります。ORMを使う利点は例えばセキュアバイデザイン、そのラッパーを通すことでSQLインジェクションなどのセキュリティーリスクを減らせるなどがあるわけですが、ORM上に日々relationを定義する徳を積んでおくことでからアーキテクチャーを検討する際のグラフ構造がより取得しやすいという利点もあるわけです。
Active Recordからグラフ上の集積点(神クラス)を取得する
ある程度モデル数があるサービスで取得した方が分析しがいがあるのでRedmine(commit 068a2868ae8d5316a7c4cf9a3d1452dfab8e43a5)のコードベースに試していきます。
試しにActive Recordからグラフ上の集積点(神クラス)を取得してみましょう。
Active RecordのメタAPIからRelationを取得するスクリプトを作成します。
# lib/tasks/relation_report.rake
# Rake task to list all ActiveRecord associations
# and output all models sorted by association count (descending).
# Excludes internal HABTM join classes from output.
namespace :relations do
desc "List model relations sorted by association count"
task report: :environment do
# Eager load application models
Rails.application.eager_load!
# Collect all AR models, excluding HABTM join classes
models = ActiveRecord::Base.descendants
.select { |m| m.name.present? && !m.name.start_with?("HABTM_") }
# Map each model class to its association count
association_counts = models.each_with_object({}) do |model, hash|
count = model.reflect_on_all_associations.size
hash[model] = count if count.positive?
end
# Sort models by association count descending
sorted = association_counts.sort_by { |model, count| -count }
puts "Model associations sorted by count (descending):\n"
sorted.each do |model, count|
puts "Model: #{model.name} (#{count} associations)"
model.reflect_on_all_associations.each do |assoc|
puts " #{assoc.macro} :#{assoc.name} -> #{assoc.class_name}"
end
puts
end
end
end
bundle exec rake relations:report
で実行すると結果は以下のようになります。
Model associations sorted by count (descending):
Model: Project (26 associations)
belongs_to :parent -> Project
has_many :memberships -> Member
has_many :members -> Member
has_many :users -> User
has_many :enabled_modules -> EnabledModule
has_and_belongs_to_many :trackers -> Tracker
has_many :issues -> Issue
has_many :issue_changes -> Journal
has_many :versions -> Version
belongs_to :default_version -> Version
belongs_to :default_assigned_to -> Principal
has_many :time_entries -> TimeEntry
has_many :time_entry_activities -> TimeEntryActivity
has_many :queries -> Query
has_many :documents -> Document
has_many :news -> News
has_many :issue_categories -> IssueCategory
has_many :boards -> Board
has_one :repository -> Repository
has_many :repositories -> Repository
has_many :changesets -> Changeset
has_one :wiki -> Wiki
has_and_belongs_to_many :issue_custom_fields -> IssueCustomField
belongs_to :default_issue_query -> IssueQuery
has_many :attachments -> Attachment
has_many :custom_values -> CustomValue
Model: Issue (19 associations)
belongs_to :parent -> Issue
has_many :reactions -> Reaction
belongs_to :project -> Project
belongs_to :tracker -> Tracker
belongs_to :status -> IssueStatus
belongs_to :author -> User
belongs_to :assigned_to -> Principal
belongs_to :fixed_version -> Version
belongs_to :priority -> IssuePriority
belongs_to :category -> IssueCategory
has_many :journals -> Journal
has_many :time_entries -> TimeEntry
has_and_belongs_to_many :changesets -> Changeset
has_many :relations_from -> IssueRelation
has_many :relations_to -> IssueRelation
has_many :attachments -> Attachment
has_many :custom_values -> CustomValue
has_many :watchers -> Watcher
has_many :watcher_users -> Principal
Model: User (14 associations)
has_many :members -> Member
has_many :memberships -> Member
has_many :projects -> Project
has_many :issue_categories -> IssueCategory
has_one :email_address -> EmailAddress
has_and_belongs_to_many :groups -> Group
has_many :changesets -> Changeset
has_one :preference -> UserPreference
has_one :atom_token -> Token
has_one :api_token -> Token
has_many :email_addresses -> EmailAddress
has_many :reactions -> Reaction
belongs_to :auth_source -> AuthSource
has_many :custom_values -> CustomValue
Model: AnonymousUser (14 associations)
has_many :members -> Member
has_many :memberships -> Member
has_many :projects -> Project
has_many :issue_categories -> IssueCategory
has_one :email_address -> EmailAddress
has_and_belongs_to_many :groups -> Group
has_many :changesets -> Changeset
has_one :preference -> UserPreference
has_one :atom_token -> Token
has_one :api_token -> Token
has_many :email_addresses -> EmailAddress
has_many :reactions -> Reaction
belongs_to :auth_source -> AuthSource
has_many :custom_values -> CustomValue
(後略)
Project, Issue, User, AnonymousUser...が集積点となるクラスですね。
User, Comapnyはグラフ上の集積点(神クラス)になりやすい
Kaggleのようなデータ分析コンペでは機械学習モデルを構築する前にExploratory Data Analysis、実際にデータがどういった状態か分析することが重要とされます。アーキテクチャを考えることはデータ分析コンペと似ている部分があり少しみてみます。
ビジネスモデルを類型する方法の一つとしてB向けかC向けかというものがあります。BtoB, BtoC, CtoC、Bto(Bto)B、Bto(Bto)C、Cto(Bto)Cというやつですね。個人事業主自体がするサービスを除き、会社が間で手数料を取るビジネスでの内部むけは管理者用ページで棲み分けるとすればBtoB、BtoC、CtoCの3つだけ考えれば良さそうです。
自分の経験上、何も考えずに作るとBtoB向けサービスだとCompany、CompanyUserがCtoCサービスだとUserが神クラスになりやすいです。BtoCサービスだとCompany、Userに分かれていた部分が全てCompany同士、User同士のrelationに置き換わるからですね。
実際Redmineの集積クラスはProject、Issue、User、AnonymousUserでUser、AnonymouseUserでユーザーモデルは上位にいます。BtoCサービスでもBtoB、CtoCの半分のrelationになりますがそれでも集積点になる傾向はあります。
マルチモジュール化という文脈だと確かにノウハウはネイティブアプリのほうが多いのですが、近代的な思想のネイティブアプリだと1ユーザー1端末想定であることが多いです。スマートTV向けアプリで家族内のユーザーが切り替えられたり、図書館の共用端末で動くアプリだったりもなくもないですが、User, Company軸はそれほど考えなくてもよいアプリのほうが多いです。
一方で、バックエンドは全ユーザーの情報が基本のります。機能境界の分割を考える上でユーザーモデルはグラフ上のノイズになるが自分の意見です。これからグラフをクラスタリングすることで機能境界を決めますがここがノイズになるので除きます。
relationの一覧を取得する
Active Recordのrelationグラフはhas_one、has_manyといったように方向がある有向グラフであり、なおかつ1:1、1:多といったようなメタ情報ものっているのですが、この有向性は無視してもメインとなるクラスはグラフの辺が十分に集積していると一旦仮定します。
EDAでUserモデルがノイズになると仮定したのでUser、AnonymousUserへのrelationは削っています。正確にいうとUserProfileのようなUserXxxモデルがあるとユーザー設定のようなものがという一つの機能、塊として抽出できる可能性はあるのですが、処理の簡便のためにさぼっています。
以下のようなスクリプトを作り
# lib/tasks/relation_report_csv.rake
# Rake task to export ActiveRecord model relations as CSV,
# excluding User, AnonymousUser, and auto-generated HABTM join classes.
# Only one direction per model pair is output to avoid duplicates.
require 'csv'
require 'set'
namespace :relations do
desc "Export model relations to CSV (excluding User, AnonymousUser, and HABTM join classes)"
task export_csv: :environment do
Rails.application.eager_load!
models = ActiveRecord::Base.descendants
.select { |m| m.name.present? }
.reject { |m| ['User', 'AnonymousUser'].include?(m.name) }
.reject { |m| m.name.start_with?('HABTM_') }
output_path = Rails.root.join('tmp', 'model_relations.csv')
seen = Set.new
CSV.open(output_path, 'wb') do |csv|
csv << ['from_model', 'to_model', 'association_type']
models.each do |model|
model.reflect_on_all_associations.each do |assoc|
src = model.name
dst = assoc.class_name
# Skip excluded models
next if ['User', 'AnonymousUser'].include?(dst)
next if dst.start_with?('HABTM_')
# Use sorted pair to detect duplicates (undirected)
pair_key = [src, dst].sort.join(':')
next if seen.include?(pair_key)
seen.add(pair_key)
csv << [src, dst, assoc.macro]
end
end
end
puts "CSV exported to #{output_path}"
end
end
bundle exec rake relations:export_csv
と実行すると
from_model,to_model,association_type
Doorkeeper::AccessToken,Doorkeeper::Application,belongs_to
Doorkeeper::AccessGrant,Doorkeeper::Application,belongs_to
WorkflowRule,Role,belongs_to
WorkflowRule,Tracker,belongs_to
WorkflowRule,IssueStatus,belongs_to
WikiRedirect,Wiki,belongs_to
WikiPage,Wiki,belongs_to
WikiPage,WikiContent,has_one
WikiPage,Attachment,has_many
WikiPage,WikiPage,belongs_to
WikiPage,Watcher,has_many
WikiPage,Principal,has_many
WikiContentVersion,WikiPage,belongs_to
WikiContent,WikiContentVersion,has_many
(後略)
とあるモデルからとあるモデルへのrelationの一覧を出力することができます。
無向グラフだとモデルAからモデルB、モデルBからモデルAのrelationは重複となるので片方除外しています。
NetworkXでコミュニティ検出する
グラフは抽出できたのでグラフのクラスタリングをします。
このままRubyで.. といきたいところですがデータ分析周りはPythonの方がエコシステムが強いのでNetworkXでコミュニティ検出します。forkに近いNetworkX.rbというのもあるのですが現状コミュニティ検出はサポートされていないようです。
コミュニティ検出というのはグラフ上で接続が強いところでグルーピングしてグラフをクラスタリングする手法です。平均的なWebサービスだとモデル数は1000いくかどうかといったところでコミュニティ検出がサポートしているグラフの大きさと比べるとサイズが小さくアルゴリズムの差が出にくいのですが、近年の論文で良い手法とされるLouvain法を使用します。
以下のようなスクリプトを作り実行します。
# community_detection.py
# Python script using NetworkX to perform community detection on the
# exported model relations CSV (tmp/model_relations.csv).
# Using Louvain method for higher-quality community detection.
import csv
import networkx as nx
from networkx.algorithms.community import louvain_communities
# Path to CSV generated by the rake task
csv_path = 'tmp/model_relations.csv'
# Initialize undirected graph
G = nx.Graph()
# Read CSV and build graph, skipping exclusions
with open(csv_path, newline='') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
src = row['from_model']
dst = row['to_model']
# Skip any excluded models
if src in ('User', 'AnonymousUser') or dst in ('User', 'AnonymousUser'):
continue
if src.startswith('HABTM_') or dst.startswith('HABTM_'):
continue
G.add_edge(src, dst)
# Perform community detection using Louvain method
# Optionally specify resolution parameter or random seed
communities = louvain_communities(G)
# Output detected communities
print("Detected communities (Louvain):")
for idx, comm in enumerate(communities, start=1):
print(f"Community {idx}: {', '.join(sorted(comm))}")
結果は以下です。
Detected communities (Louvain):
Community 1: Doorkeeper::AccessGrant, Doorkeeper::AccessToken, Doorkeeper::Application
Community 2: IssueStatus, Tracker, WorkflowPermission, WorkflowRule, WorkflowTransition
Community 3: Document, DocumentCategory, Enumeration, IssuePriority, IssueQuery, Project, ProjectAdminQuery, ProjectQuery, Query, TimeEntry, TimeEntryActivity, TimeEntryQuery, UserQuery
Community 4: Attachment, Board, Comment, Commented, Container, EnabledModule, Issue, IssueRelation, Journal, JournalDetail, Journalized, Message, News, Principal, Reactable, Reaction, Version, Watchable, Watcher, Wiki, WikiContent, WikiContentVersion, WikiPage, WikiRedirect
Community 5: Import, ImportItem, IssueImport, TimeEntryImport, UserImport
Community 6: CustomField, CustomFieldEnumeration, CustomValue, Customized, DocumentCategoryCustomField, DocumentCustomField, GroupCustomField, IssueCustomField, IssuePriorityCustomField, ProjectCustomField, Role, TimeEntryActivityCustomField, TimeEntryCustomField, UserCustomField, VersionCustomField
Community 7: Change, Changeset, Repository, Repository::Bazaar, Repository::Cvs, Repository::Filesystem, Repository::Git, Repository::Mercurial, Repository::Subversion
Community 8: EmailAddress, Group, GroupAnonymous, GroupBuiltin, GroupNonMember, IssueCategory, Member, MemberRole
広義のAIでアーキテクトの仕事をディスラプトできるとは言えないまでもドメイン境界を考える際に補助する情報としては使えそうですね。
まとめ
以上です。
パラメーターのチューニングや可視化まではいけなかったのでまた次回。