소프트웨어 디자인 가이드

CRUD 용어 대신 유비쿼터스 언어 사용

코드는 제품 및 사용자 문서에서 사용되는 것과 동일한 유비쿼터스 언어를 사용해야 합니다. 유비쿼터스 언어를 올바르게 사용하지 않으면 기여자와 고객에게 혼란을 줄 수 있는 주요 원인이 됩니다. 이는 우리의 커뮤니케이션 전략에도 반합니다.

아래 예에서 CRUD 용어는 모호성을 도입합니다. 이름은 ‘epic_issues’ 관계 레코드를 생성하고 있다고 말하지만, 실제로는 기존 문제를 에픽에 추가하고 있습니다. Rails 규칙에서 사용되는 이름 ‘epic_issues’는 서비스 객체와 같은 고차원 개념으로 흘러넘칩니다. 코드가 유비쿼터스 언어가 아닌 프레임워크 전문 용어를 사용합니다.

# 나쁨
EpicIssues::CreateService

유비쿼터스 언어를 사용하면 코드를 명확하게 하고 프레임워크 전문 용어를 번역하려는 독자에게 인지 부담을 주지 않습니다.

# 좋음
Epic::AddExistingIssueService

모호하지 않은 단순 개념을 나타낼 때는 CRUD를 사용할 수 있으며, 기존의 유비쿼터스 언어와 일치할 때 사용됩니다.

# 괜찮음: 제품 언어와 일치합니다.
Projects::CreateService

새로운 클래스와 데이터베이스 테이블은 유비쿼터스 언어를 사용해야 합니다. 이 경우 모델 이름과 테이블 이름은 Rails 규칙을 따릅니다.

유비쿼터스 언어를 따르지 않는 기존 클래스는 가능할 경우 이름을 변경해야 합니다. 데이터베이스 테이블과 같은 낮은 수준의 추상화는 이름을 변경할 필요가 없습니다. 예를 들어, 모델 이름이 테이블 이름과 다를 때는 self.table_name=을 사용해야 합니다.

이름 변경이 어려운 경우에만 예외를 허용할 수 있습니다. 예를 들어, 명칭이 STI에 사용되거나 사용자에게 노출되는 경우, 또는 그것이 파괴적인 변경이 될 경우입니다.

경계가 있는 맥락

경계가 있는 맥락에 대한 보다 자세한 내용은 Bounded Contexts working groupGitLab Modular Monolith design document을 참조하세요.

경계가 있는 맥락을 정의하기 위해 네임스페이스 사용

건강한 애플리케이션은 매크로 및 하위 구성 요소로 나누어져 있으며, 이는 작동하는 경계가 있는 맥락을 나타냅니다. GitLab 코드는 다양한 기능과 구성 요소가 많기 때문에 어떤 맥락이 관련되어 있는지 파악하기 어렵습니다. 이러한 구성 요소는 비즈니스 도메인 또는 인프라 코드와 관련이 있을 수 있습니다.

어떤 클래스라도 작동하는 맥락을 나타내는 모듈/네임스페이스 내에서 정의되는 것을 기대해야 합니다. 이러한 맥락을 정의하기 위해 허용된 네임스페이스 목록을 유지 관리합니다.

도메인 내에서 클래스를 네임스페이스로 지정할 때:

  • 유사한 용어는 도메인에 의해 의미가 명확해지므로 모호하지 않게 됩니다: 예를 들어, MergeRequests::DiffNotes::Diff.
  • 최상위 네임스페이스는 도메인 전문가로 식별된 하나 이상의 그룹과 연관될 수 있습니다.
  • 구성 요소 간의 상호 작용 및 결합을 더 잘 식별할 수 있습니다. 예를 들어, MergeRequests:: 도메인 내의 여러 클래스는 Ci:: 도메인과 더 많은 상호 작용을 하고 Import::와는 덜 상호 작용합니다.
# 나쁨
class JobArtifact ... end

# 좋음
module Ci
  class JobArtifact ... end
end

경계가 있는 맥락 정의 방법

허용된 경계가 있는 맥락은 config/bounded_contexts.yml에 정의되어 있으며, 도메인 레이어와 인프라 레이어에 대한 네임스페이스를 포함합니다.

도메인 레이어에 대해서는 다음을 참조합니다:

  1. 어플리케이션 어댑터(컨트롤러, API 엔드포인트 및 뷰)를 제외한 app의 코드.
  2. 도메인 논리와 구체적으로 관련된 lib의 코드.

여기에는 ActiveRecord 모델, 서비스 객체, 작업자 및 도메인 특정 일반 루비 객체가 포함됩니다.

현재 애플리케이션 어댑터는 노력을 줄이기 위해 모듈화에서 제외됩니다. 특정 엔드포인트가 단일 도메인과 항상 일치하지 않을 수 있기 때문입니다(예: 설정, 병합 요청 보기, 프로젝트 보기 등).

인프라 레이어에 대해서는 GitLab 비즈니스 개념을 포함하지 않고, Ruby gem으로 추출될 수 있는 일반 목적을 위한 lib의 코드를 참조합니다.

최상위 네임스페이스(경계가 있는 맥락)의 이름을 지정하기 위한 좋은 지침은 관련된 기능 범주를 사용하는 것입니다. 예를 들어, Continuous Integration 기능 범주는 Ci:: 네임스페이스에 매핑됩니다.

프로젝트와 그룹은 일반적으로 테넌트를 식별하는 컨테이너 개념입니다. 기능이 프로젝트 또는 그룹 수준에 존재하지만, 저장소나 러너와 같은 기능은 Projects:: 또는 Groups:: 아래에 중첩하지 않고 상대 경계가 있는 문맥 아래에 있어야 합니다.

Projects::Groups:: 네임스페이스는 그들과 엄격히 관련된 개념에만 사용해야 합니다: 예를 들어 Project::CreateService 또는 Groups::TransferService.

컨트롤러의 경우 app/controllers/projectsapp/controllers/groups는 예외로 허용되며, 경계가 있는 맥락이 애플리케이션 레이어에 적용되지 않기 때문입니다. 우리는 특정 웹 엔드포인트의 범위를 나타내기 위해 이 규칙을 사용합니다.

단계 또는 그룹 이름을 사용하지 마세요, 왜냐하면 기능 범주가 미래에 다른 그룹으로 재할당될 수 있기 때문입니다.

# 나쁨
module Create
  class Commit ... end
end

# 좋음
module Repositories
  class Commit ... end
end

반면에, 기능 범주가 너무 세분화되는 경우도 있습니다. 기능은 제품 및 마케팅에 따라 다르게 취급되는 경향이 있지만, 많은 도메인 모델과 동작을 공유할 수 있습니다. 이 경우 지나치게 많은 경계가 있는 맥락은 얕고 다른 맥락과 더 결합될 수 있습니다.

경계가 있는 맥락(또는 최상위 네임스페이스)은 전체 애플리케이션에서 매크로 구성 요소로 볼 수 있습니다. 좋은 경계가 있는 맥락은 깊어야 하므로 복잡한 도메인의 일부를 더 세분화하기 위해 중첩 네임스페이스를 고려하세요. 예를 들어, Ci::Config::와 같이 말입니다.

예를 들어, ContainerScanning::, ContainerHostSecurity::, ContainerNetworkSecurity::와 같은 별도이고 세분화된 경계가 있는 맥락을 대신하여 다음과 같은 구조로 만들 수 있습니다:

module Security::Container
  module Scanning ... end

  module NetworkSecurity ... end

  module HostSecurity ... end
end

네임스페이스 내에 정의된 클래스가 다른 네임스페이스의 클래스와 많은 공통점을 가지고 있다면, 이 두 네임스페이스가 동일한 경계가 있는 맥락의 일부일 가능성이 높습니다.

GitLab/BoundedContexts RuboCop 위반 사항 해결 방법

Gitlab/BoundedContexts RuboCop 규칙은 모든 Ruby 클래스나 모듈이 config/bounded_contexts.yml에서 존재하는 최상위 Ruby 네임스페이스 안에 중첩되어 있도록 보장합니다.

위반 사항은 상수를 기존의 경계 컨텍스트 네임스페이스 안에 중첩시켜 해결해야 합니다.

  • 기능과 더 밀접하게 관계된 네임스페이스를 찾기 위해 config/bounded_contexts.yml에서 검색하세요. 예를 들어 기능 카테고리와 일치하는 경우입니다.
  • 필요하다면, 네임스페이스 안에서 상수를 추가로 중첩시키기 위해 하위 네임스페이스를 사용할 수 있습니다. 예: Repositories::Mirrors::SyncService.
  • 기존의 관련 코드를 동일한 네임스페이스로 이동시키기 위한 후속 이슈를 생성하세요.

예외적인 경우에는 목록에 새로운 경계 컨텍스트를 추가해야 할 수도 있습니다. 이는 다음과 같은 경우에 가능합니다:

  • 기존의 경계 컨텍스트와 일치하지 않는 새로운 제품 카테고리를 도입하는 경우.
  • 기존 경계 컨텍스트에서 너무 커서 두 개를 분리하고자 하는 경우.

GitLab/BoundedContexts 및 config/bounded_contexts.yml FAQ

  1. 규칙을 비활성화해야 하는 상황이 있나요?

    • 규칙은 비활성화되어서는 안 되지만, 위반하는 클래스나 모듈이 모두 함께 이동해야 할 클래스 클러스터의 일부인 경우 일시적으로 비활성화할 수 있습니다. 이 경우, 규칙을 비활성화하고 모든 클래스를 한 번에 이동하기 위한 후속 이슈를 생성할 수 있습니다.
  2. 모든 기존 코드를 규칙 준수로 리팩토링하기 위한 제안된 일정이 있나요?

    • 정의된 일정은 없지만, 우리가 코드를 더 빨리 통합할수록 그 코드는 더 일관성이 있게 됩니다.
  3. 기존 Sidekiq 작업자에게 경계 컨텍스트가 적용되나요?

    • 기존 작업자는 이미 RuboCop TODO 파일에 포함되어 있어 위반 사항을 발생시키지 않습니다. 그러나 가능할 경우 경계 컨텍스트로 이동해야 합니다. Sidekiq 작업자 이름 변경 가이드를 따르세요.
  4. 기능 카테고리를 이름 변경하고 config/bounded_contexts.yml이 이를 참조합니다. 업데이트하는 것이 안전한가요?

    • 네, 이 파일은 경계 컨텍스트에 매핑된 기능 카테고리가 config/feature_categories.yml에 정의되어 있다고 기대할 뿐이며, 이러한 값에 특별히 의존하는 것은 없습니다. 이 매핑은 주로 기여자들이 코드베이스에서 기능이 어디에 있는지를 이해하는 데 도움을 주기 위한 것입니다.

도메인 코드를 일반 코드와 구분하기

위의 지침은 주로 도메인 코드를 지칭합니다.

도메인 코드에서는 Ruby 클래스를 주어진 경계 컨텍스트를 나타내는 네임스페이스 아래에 두어야 합니다.

도메인 코드는 GitLab 제품에 고유합니다. 비즈니스 로직, 정책 및 데이터를 설명합니다.

이 코드는 GitLab 저장소에 존재해야 합니다. 도메인 코드는 주로 app/lib/ 사이에 분할되어 있습니다.

응용 프로그램 코드베이스 내에는 인프라 수준의 작업을 수행하는 일반 코드가 있습니다. 로거, 계측, Redis와 같은 데이터 저장소 클라이언트, 데이터베이스 유틸리티 등이 있을 수 있습니다.

응용 프로그램이 작동하는 데 필수적이지만, 일반 코드는 GitLab 제품에 고유한 비즈니스 로직을 설명하지 않습니다. 비즈니스 로직에 영향을 주지 않고 기존 솔루션으로 다시 작성하거나 대체될 수 있습니다.

이것은 일반 코드가 도메인 코드와 분리되어야 한다는 것을 의미합니다.

오늘날 많은 일반 코드가 lib/에 존재하지만 도메인 코드와 혼합되어 있습니다.

우리는 Gems 개발 가이드라인에서 설명한 대로 gems/ 디렉터리로 젬을 추출해야 합니다.

Omniscient 클래스 다루기

우리는 Omniscient Classes (또는 God Objects)에 새로운 데이터와 동작을 추가하지 않는 것을 고려해야 합니다.

우리는 Project, User, MergeRequest, Ci::Pipeline 및 1000 LOC 이상인 모든 클래스를 omniscient으로 간주합니다.

이러한 클래스는 책임이 과중합니다. 새로운 데이터와 동작은 대부분 별도의 전용 클래스로 추가할 수 있습니다.

가이드라인:

  • 객체 ID에 대한 참조만 필요하다면 (예: Project#id) 외래 키를 사용하는 새로운 모델이나 객체를 감싸는 얇은 래퍼를 추가할 수 있습니다.

  • 만약 omniscient 클래스에 메서드를 추가함으로써 다른 몇 개의 메서드(프라이빗 또는 퍼블릭)도 추가하게 되는 경우, 이는 이러한 메서드가 전용 클래스에 캡슐화되어야 한다는 신호입니다.

  • Project에 메서드를 추가하고 싶은 유혹이 있지만, 해당 데이터 및 연관의 시작 지점이기 때문입니다. 데이터(또는 일부분)가 있는 곳이 아니라 소속되는 한정된 컨텍스트에서 동작을 정의하세요. 이는 더 많은 결합성과 복잡성을 가져오는 일반적이고 과중한 객체를 갖기보다는 한정된 컨텍스트에서 더 관련성이 높은 omniscient 객체의 측면을 생성하는 데 도움이 됩니다.

예제: 일반 모델 주위에 얇은 도메인 객체 정의하기

abuse_trust_scores에 대한 연관이 있기 때문에 User에 여러 메서드를 추가하기보다는 의존성을 반전시키는 것을 시도하세요.

##
# 나쁜 예: User 객체에 추가된 동작.
class User
  def spam_score
    abuse_trust_scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    # 경고 신호: 특정 한정된 컨텍스트에 속하는 상수를 사용하고 있습니다!
    spam_score > AntiAbuse::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

# 사용 예제:
user = User.find(1)
user.spam_score
user.telesign_score
user.arkose_global_score
##
# 좋은 예: 사용자 신뢰 점수를 나타내는 얇은 클래스 정의
class AntiAbuse::UserTrustScore
  def initialize(user)
    @user = user
  end

  def spam
    scores.spamcheck.average(:score) || 0.0
  end

  def spammer?
    spam > AntiAbuse::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
    AntiAbuse::TrustScore.for_user(@user)
  end
end

# 사용 예제:
user = User.find(1)
user_score = AntiAbuse::UserTrustScore.new(user)
user_score.spam
user_score.spammer?
user_score.telesign
user_score.arkose_global

실제 예제는 병합 요청을 참조하세요.

예제: 의존성 역전을 사용하여 도메인 개념 추출하기

##
# 나쁜 사례: 프로젝트에 통합 관련 메서드가 정의되어 있습니다.
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

  # 이 외에도 여러 메서드가 있습니다...
end
##
# 좋은 사례: 통합 관련 모든 로직은 `Integrations::` 바운디드 컨텍스트 내에 위치합니다.
module Integrations
  class ProjectIntegrations
    def initialize(project)
      @project = project
    end

    def all_integrations
      @project.integrations # 여전히 AR 연관의 캐싱을 활용할 수 있습니다.
    end

    def find_or_initialize(name)
      # ...
    end

    def all_disabled
      all_integrations.disabled
    end

    def all_ci
      all_integrations.ci_integration
    end
  end
end

유사 리팩토링의 실제 예시: 링크.

사용 사례 중심으로 소프트웨어 디자인하기, 엔티티가 아닌

Rails는 Active Record의 힘을 통해 개발자들이 엔티티 중심의 소프트웨어를 설계하도록 권장합니다.

컨트롤러와 API 엔드포인트는 엔티티와 서비스 객체 모두에 대한 CRUD 작업을 나타내는 경향이 있습니다.

새로운 데이터베이스 열은 서로 다른 사용 사례를 언급함에도 불구하고 기존 엔티티 테이블에 추가되는 경향이 있습니다.

이러한 안티 패턴은 다음과 같은 하나 이상의 형태로 나타날 수 있습니다:

안티 패턴 예시

우리는 Groups::UpdateService가 있으며 이는 엔티티 중심이며 상이한 사용 사례에 대해 재사용됩니다:

  • 그룹 설명 업데이트, 이는 그룹 관리자가 접근해야 합니다.
  • 계산 한도와 같은 네임스페이스 수준 제한 설정, 이는 인스턴스 관리자가 접근해야 합니다.

이 두 가지 다른 사용 사례는 서로 다른 매개변수 집합을 지원합니다. 인스턴스 관리자가 shared_runners_minutes_limit를 업데이트하고 그룹 설명을 동시에 업데이트할 것으로 예상되지는 않습니다. 마찬가지로, 사용자가 브랜치 보호 규칙과 공유 러너 설정을 동시에 변경할 것으로 예상되지 않습니다.

이들은 서로 다른 도메인에서 오는 서로 다른 사용 사례를 나타냅니다.

해결책

엔티티 대신 사용 사례를 중심으로 설계합니다. 페르소나, 사용 사례 및 의도가 다르다면 별도의 추상화를 생성합니다:

  • 특정 사용 사례의 도메인에 중첩된 다른 엔드포인트(컨트롤러, GraphQL 또는 REST).
  • 특정 권한과 일관된 매개변수 집합을 포함하는 별도의 서비스 객체.

예를 들어, 그룹 관리자가 일반 그룹 설정을 업데이트하기 위한 Groups::UpdateService.

인스턴스 관리자를 위한 Ci::Minutes::UpdateLimitService는 권한, 기대치, 매개변수 및 사이드 이펙트가 완전히 다릅니다.

궁극적으로 이는 모든 것을 알고 있는 클래스 관리의 원칙을 활용해야 합니다.

우리는 독립적인 사용 사례 로직의 결합을 피하여 느슨한 결합과 높은 응집을 아는 것을 원합니다.

그 결과, 클레임이 일관되게 적용되어 전체 동작이 보다 안전한 시스템을 얻습니다.

마찬가지로, 별도의 모델이나 테이블에서 정의된 경우 관리 레벨 데이터를 우발적으로 노출하지 않습니다.

우리는 동일한 사용 사례에 일관되게 속하는 데이터를 읽거나 쓰기 전에 단일 권한 확인을 가질 수 있습니다.