ソフトウェア設計ガイド

CRUD用語の代わりにユビキタス言語を使用

コードは、製品やユーザーのドキュメントで使われているのと同じユビキタス言語を使うべきです。ユビキタス言語を正しく使用しないと、翻訳や複数の用語の使用が絶えず発生し、貢献者や顧客を混乱させる大きな原因となります。また、これはコミュニケーション戦略にも反します。

下の例では、CRUDという用語が曖昧さをもたらしています。名前ではepic_issues 関連レコードを epic_issues作成してepic_issues いますが、既存のイシュー epic_issuesをエピックに追加しています。epic_issues この名前は epic_issuesRailsの慣例から使われていますが、サービスオブジェクトのような抽象度の高いオブジェクトに漏れています。コードは、一般的な言語ではなくフレームワークの専門用語を話しています。

# Bad
EpicIssues::CreateService

ユビキタス言語を使用することで、コードが明確になり、フレームワークの専門用語を翻訳しようとする読者に認知的負荷を与えません。

# Good
Epic::AddExistingIssueService

プロジェクトを作成するような、あいまいでない単純な概念を表現するときや、既存のユビキタス言語に合わせるときには、CRUDを使うことができます。

# OK: Matches the product language.
Projects::CreateService

新しいクラスとデータベーステーブルはユビキタス言語を使うべきです。この場合、モデル名とテーブル名はRailsの規約に従います。

ユビキタス言語に従わない既存のクラスは、可能であれば名前を変更すべきです。データベーステーブルのような低レベルの抽象化は、名前を変更する必要がないものもあります。たとえば、モデル名とテーブル名が異なる場合はself.table_name=

リネームが困難な場合に限り、例外を認めることができます。例えば、命名がSTIに使用される場合、ユーザーに公開される場合、破壊的な変更になる場合などです。

名前空間を使用して境界コンテキストを定義

健全なアプリケーションはマクロコンポーネントとサブコンポーネントに分割され、ビジネスドメインやインフラストラクチャコードに関係なく、コンテキストを表現します。

GitLabのコードには非常に多くの機能やコンポーネントがあるため、どのようなコンテキストが関係しているのかを確認するのは難しいです。どのクラスも、それがオペレーションするコンテキストを表すモジュール/名前空間の中で定義されることを期待すべきです。

クラスがそのドメインの内部で名前空間化されている場合:

  • ドメインが意味を明確にするため、似たような用語は曖昧さを失います:例えば、MergeRequests::DiffNotes::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::CreateServiceGroups::TransferServiceなどです。

コントローラについては、app/controllers/projectsapp/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

同様のリファクタリングの実例