ソフトウェア設計ガイド
CRUD用語の代わりにユビキタス言語を使用
コードは、製品やユーザーのドキュメントで使われているのと同じユビキタス言語を使うべきです。ユビキタス言語を正しく使用しないと、翻訳や複数の用語の使用が絶えず発生し、貢献者や顧客を混乱させる大きな原因となります。また、これはコミュニケーション戦略にも反します。
下の例では、CRUDという用語が曖昧さをもたらしています。名前ではepic_issues
関連レコードを epic_issues
作成してepic_issues
いますが、既存のイシュー epic_issues
をエピックに追加しています。epic_issues
この名前は epic_issues
Railsの慣例から使われていますが、サービスオブジェクトのような抽象度の高いオブジェクトに漏れています。コードは、一般的な言語ではなくフレームワークの専門用語を話しています。
# Bad
EpicIssues::CreateService
ユビキタス言語を使用することで、コードが明確になり、フレームワークの専門用語を翻訳しようとする読者に認知的負荷を与えません。
# Good
Epic::AddExistingIssueService
プロジェクトを作成するような、あいまいでない単純な概念を表現するときや、既存のユビキタス言語に合わせるときには、CRUDを使うことができます。
# OK: Matches the product language.
Projects::CreateService
新しいクラスとデータベーステーブルはユビキタス言語を使うべきです。この場合、モデル名とテーブル名はRailsの規約に従います。
ユビキタス言語に従わない既存のクラスは、可能であれば名前を変更すべきです。データベーステーブルのような低レベルの抽象化は、名前を変更する必要がないものもあります。たとえば、モデル名とテーブル名が異なる場合はself.table_name=
。
リネームが困難な場合に限り、例外を認めることができます。例えば、命名がSTIに使用される場合、ユーザーに公開される場合、破壊的な変更になる場合などです。
名前空間を使用して境界コンテキストを定義
健全なアプリケーションはマクロコンポーネントとサブコンポーネントに分割され、ビジネスドメインやインフラストラクチャコードに関係なく、コンテキストを表現します。
GitLabのコードには非常に多くの機能やコンポーネントがあるため、どのようなコンテキストが関係しているのかを確認するのは難しいです。どのクラスも、それがオペレーションするコンテキストを表すモジュール/名前空間の中で定義されることを期待すべきです。
クラスがそのドメインの内部で名前空間化されている場合:
- ドメインが意味を明確にするため、似たような用語は曖昧さを失います:例えば、
MergeRequests::Diff
とNotes::Diff
。 - トップレベルの名前空間は、ドメインの専門家として識別される1つまたは複数のグループに関連付けることができます。
- コンポーネント間の相互作用と結合をよりよく識別できます。たとえば、
MergeRequests::
ドメイン内部のいくつかのクラスは、Ci::
ドメインとの相互作用が大きく、ImportExport::
ドメインとの相互作用は小さくなります。
最上位の名前空間(境界コンテキスト)に名前を付けるための良いガイドラインは、関連する機能カテゴリを使用するこ とです。たとえば、Continuous Integration
機能カテゴリは、Ci::
名前空間にマッピングされます。
# bad
class JobArtifact
end
# good
module Ci
class JobArtifact
end
end
プロジェクトとグループは、テナントを識別するため、一般にコンテナ概念です。プロジェクトやグループは、リポジトリやRunnerのように、プロジェクトやグループレベルで機能が存在することを許しますが、そのような機能をProjects::
やGroups::
の下に入れ子にすることはありません。
Projects::
およびGroups::
名前空間は、厳密に関連する概念にのみ使用する必要があります。たとえば、Project::CreateService
やGroups::TransferService
などです。
コントローラについては、app/controllers/projects
とapp/controllers/groups
を例外として認めています。この規約は、指定したウェブエンドポイントのスコープを示すために使用します。
ステージ名やグループ名は使用しないでください。将来、機能カテゴリが別のグループに割り当てられる可能性があるからです。
# bad
module Create
class Commit
end
end
# good
module Repositories
class Commit
end
end
一方、フィーチャーカテゴリーが細かすぎる場合もあります。フィーチャーは、ProductとMarketingによって異なる扱いを受ける傾向があります。この場合、境界のあるコンテキストが多すぎると、他のコンテキストとの結合が浅くなる可能性があります。
バウンデッドコンテキスト(またはトップレベルの名前空間)は、アプリ全体のマクロコンポーネントと見なすことができます。良いバウンデッド・コンテキストは深いはずなので、ドメインの複雑な部分をさらに分解するために、ネストされた名前空間を持つことを検討してください。たとえば、Ci::Config::
.
たとえば、:ContainerScanning::
ContainerHostSecurity::
,ContainerNetworkSecurity::
のような個別の詳細なバウンデッドコンテキストを持つ代わりに、次のようなバウンデッドコンテキストを持つことができます:
module ContainerSecurity
module HostSecurity
end
module NetworkSecurity
end
module Scanning
end
end
あるネームスペースに定義されているクラスが他のネームスペースのクラスと共通点が多い場合、これら 2 つのネームスペースは同じ境界コンテキストに属している可能性があります。
ドメイン・コードとジェネリック・コードの区別
上記のガイドラインは主にドメインコードについて言及しています。ドメインコードでは、Ruby クラスを、与えられた境界コンテキスト (機能と能力のまとまり) を表す名前空間の下に置くべきです。
ドメインコードはGitLab製品に固有のものです。ビジネスロジック、ポリシー、データを記述します。このコードはGitLabリポジトリにあるべきです。ドメインコードは主にapp/
とlib/
に分かれています。
アプリケーションのコードベースには、インフラレベルのアクションを実行するための汎用コードもあります。これは、ロガー、インスツルメンテーション、Redisのようなデータストアのクライアント、データベースユーティリティなどです。
アプリケーションの実行には不可欠ですが、ジェネリックコードにはGitLab製品に固有のビジネスロジックは記述されていません。ビジネスロジックに影響を与えることなく、書き直したり、既製のソリューションに置き換えたりすることができます。つまり、ジェネリックコードはドメインコードから分離されるべきです。
現在、ジェネリックコードの多くはlib/
にありますが、ドメインコードと混在しています。Gems開発ガイドラインに記載されているように、代わりにgems/
ディレクトリにgemsを抽出する必要があります。
全知全能クラスを飼いならす
全知全能のクラス(神オブジェクトとしても知られています)に新しいデータや動作を追加しないことを考慮しなければなりません。Project
,User
,MergeRequest
,Ci::Pipeline
および 1000 LOC 以上のクラスは全知全能であると考えます。
このようなクラスは責任が過負荷になっています。新しいデータや振る舞いは、たいていの場合、独立した専用のクラスとして追加することができます。
ガイドライン
- オブジェクトIDへの参照(例えば
Project#id
)が必要な場合、外部キーを使用する新しいモデルを追加するか、特別な振る舞いを追加するためにオブジェクトの周りの薄いラッパーを追加することができます。 - 全知全能クラスにメソッドを追加することで、他のいくつかのメソッド(非公開もしくは公開)も追加することになることがわかったら、これらのメソッドは専用のクラスにカプセル化されるべきです。
-
Project
にメソッドを追加するのは一時的なことで、それがデータと関連付けの出発点だからです。データ(またはその一部)がある場所ではなく、それが属する境界のあるコンテキストで振る舞いを定義するようにしてください。これは、より多くのカップリングと複雑さをもたらすジェネリックオブジェクトやオーバーロードオブジェクトを持つよりも、バウンデッドコンテキストにはるかに関連する全知全能オブジェクトのファセットを作成するのに役立ちます。
例ジェネリックモデルの周りに薄いドメインオブジェクトを定義します。
abuse_trust_scores
に関連するので、User
に複数のメソッドを追加する代わりに、依存関係を反転させてみてください。
##
# BAD: Behavior added to User object.
class User
def spam_score
abuse_trust_scores.spamcheck.average(:score) || 0.0
end
def spammer?
# Warning sign: we use a constant that belongs to a specific bounded context!
spam_score > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
end
def telesign_score
abuse_trust_scores.telesign.recent_first.first&.score || 0.0
end
def arkose_global_score
abuse_trust_scores.arkose_global_score.recent_first.first&.score || 0.0
end
def arkose_custom_score
abuse_trust_scores.arkose_custom_score.recent_first.first&.score || 0.0
end
end
# Usage:
user = User.find(1)
user.spam_score
user.telesign_score
user.arkose_global_score
##
# GOOD: Define a thin class that represents a user trust score
class Abuse::UserTrustScore
def initialize(user)
@user = user
end
def spam
scores.spamcheck.average(:score) || 0.0
end
def spammer?
spam > Abuse::TrustScore::SPAMCHECK_HAM_THRESHOLD
end
def telesign
scores.telesign.recent_first.first&.score || 0.0
end
def arkose_global
scores.arkose_global_score.recent_first.first&.score || 0.0
end
def arkose_custom
scores.arkose_custom_score.recent_first.first&.score || 0.0
end
private
def scores
Abuse::TrustScore.for_user(@user)
end
end
# Usage:
user = User.find(1)
user_score = Abuse::UserTrustScore.new(user)
user_score.spam
user_score.spammer?
user_score.telesign
user_score.arkose_global
実際のマージリクエストの例をご覧ください。
例依存関係の反転を使用してドメイン概念を抽出します。
##
# BAD: methods related to integrations defined in Project.
class Project
has_many :integrations
def find_or_initialize_integrations
# ...
end
def find_or_initialize_integration(name)
# ...
end
def disabled_integrations
# ...
end
def ci_integrations
# ...
end
# many more methods...
end
##
# GOOD: All logic related to Integrations is enclosed inside the `Integrations::`
# bounded context.
module Integrations
class ProjectIntegrations
def initialize(project)
@project = project
end
def all_integrations
@project.integrations # can still leverage caching of AR associations
end
def find_or_initialize(name)
# ...
end
def all_disabled
all_integrations.disabled
end
def all_ci
all_integrations.ci_integration
end
end
end