DDDによる関心の分離

Web Engineer の @wozaki です。 スタディサプリの合格特訓コースの機能開発・保守が主な業務です。

今年3月に開催された Rails Developer Meetup 2018 で、弊社の @kyannyQuipper における「関心の分離」の歴史 をプレゼンしました。 プレゼンでは、モノリシックなコードベースを「関心の分離」により保守性を高めた事例をご紹介しました。「分断されたモノリス」に共感された方も多かったのではないでしょうか。

本記事では「関心の分離」事例を、ドメイン駆動設計(以下、DDD)の観点からご紹介します。

対象読者

  • モノリシックで大規模なアプリケーションを保守している方
  • DDD の具体例を探している方

※ 注意点

  • DDDの詳しい説明はしません。補足は入れますが、予備知識があるとより分かりやすいです。
  • DDDをガッツリ適用したコードは出てきません。
    • 基本的な設計はRailsの慣習に沿っています。
    • 登場するパターンは、「境界づけられたコンテキスト」「レイヤードアーキテクチャ」です。

本記事における用語の定義

関心の分離

@kyannyのプレゼン(Wikipedia)から引用します。

https://ja.wikipedia.org/wiki/%E9%96%A2%E5%BF%83%E3%81%AE%E5%88%86%E9%9B%A2 image

DDD

ドメイン駆動設計の道標 - sandbox から引用します。

この「DDD とは何なのか?」という説明から明確に認識すべきは、DDD = 設計手法ではないという事です。 勿論、設計手法の話も含まれますが、DDD の根幹は、「現実の複雑な問題解決領域をどの様にソフトウェアに落としこむか」というテーマの、組織、開発プロセス、設計論に及ぶソフトウェア開発哲学であり、その探求の過程で生まれたモデリングの戦略、戦術のパターン、思想の集まりです。

本記事では、設計以外についても言及します。

モデル

ドメインオブジェクト。ドメインにおける解決方法を表現したもの。 RailsActiveRecordを継承したクラスそのものではないが、合致することもある。

事例紹介

「スタディサプリ合格特訓コースの資料請求ドメイン」を元に、事例を2点ご紹介します。 ユースケースは以下の通りです。

  • ユーザとして
    • 資料請求を申込む
  • スタディサプリとして
    • 資料を配送する
    • 資料を配送後、フォローの電話を行う
    • フォローの電話を行った後n日以内に合格特訓コースに入会した場合は、契約書を配送する

1.名前空間の分離 => 境界づけられたコンテキスト

@kyanny のプレゼンでは、スタディサプリ専用のモデルを Aya:: ネームスペースで分離する事例 をご紹介しました。資料請求ドメインでは、更に Aya::Coaching::Brochure:: で分離しています。

背景・解決したかった課題

開発当初、合格特訓コースドメインを表現するAya::Coaching:: ネームスペースが既に存在しており、その中に DocumentRequest モデルが一つある状態でした。 しかし、資料請求ドメインの理解が進むにつれて、一つのモデルだけでは開発が難しいことが分かりました。そのためモデルを追加していきましたが、以下の課題がありました。

  • 名前が冗長になる
    • 他のモデル名と被らないように冗長になる (DocumentRequestShippedResult等)
  • シンプルな名前にすると責務が曖昧になる
    • モデルの責務に適さない改修が入る可能性がある
  • 資料請求ドメインを構成する知識が散らばる
    • 資料請求ドメイン全体の理解が難しくなる。取っ掛かりが無いので、モデルを利用するアプリケーションロジックからモデルを辿る必要がある
    • 他のドメインの理解が難しくなる。この事例では、合格特訓コースドメインが煩雑になる

また、これらの課題は、モデル以外 (API エンドポイント、Jenkinsのview、Slack channel 等) でも発生しました。

やったこと

  • Aya::Coaching::Brochure:: を導入し他のモデルから隔離した

image

  • 資料請求ドメインに関わる全てに brochure の名前を付けた

image

※スタディサプリのJenkinsなので aya のprefixは省略している。

得られた結果

認知的エントリポイント

名前から資料請求ドメインを連想できるようになりました。 資料請求ドメインに関連するもの全てに一貫した名前を含めることが重要です。 名前変更時の追従コストは大きいですが、引き継ぐ際に効果を発揮すると思います。

コンテキストの確定

資料請求ドメイン何々を表現しやすくなりました。 ステークホルダーと会話する際、意識/無意識的にコンテキストを確定することで認識齟齬を防いでいます。 それはソフトウェアにも有効です。資料請求者を表すモデルをApplicantと表現していますが、Applicantモデルは資料請求ドメイン外にも存在しています。 そのため、Aya::Coaching::Brochure:: があることで「資料請求ドメインのApplicant」だと確定することができます。

また、資料請求ドメイン専用のSlack channelでの会話は、コンテキストを確立する手間が省けて楽になりました。

「境界づけられたコンテキスト」の観点から補足

定義を、実践ドメイン駆動設計 | ヴァーン・ヴァーノン, 高木正弘 から引用します

全部入りの巨大なモデルをひとつ作ってしまうという誘惑に駆られるプロジェクトもある。あらゆる名称が唯一の意味しかもたないように、組織全体で合意を形成しようというのが、その狙いだ。このようなモデリング手法では、落とし穴にはまってしまう。まず、あらゆる概念に対してステークホルダー全員が納得するような共通の意味付けをすることなど、事実上不可能だ。

さらに具体的な説明は、 境界づけられたコンテキスト 概念編 - ドメイン駆動設計用語解説 [DDD] に載っているので、参考になると思います。

2.アプリケーションの分離 => レイヤードアーキテクチャ

プレゼンでは、用途ごとにアプリケーションを分離する事例 をご紹介しました。資料請求ドメインでも同様にアプリケーションを分離・モデル層の共有ライブラリ化しています。

背景・解決したかった課題

モデルロジックの重複・流出

資料請求ドメインには様々なユースケースがあり、それぞれに適したアプリケーションが既にありました。 各アプリケーションにモデルロジックを書くとDRY原則に反します。

さらにドメインの知識が流出するためドメインの理解が難しくなります。 ref ドメインモデル貧血症 - Martin Fowler's Bliki

利用技術や運用方針の変更

資料の配送資料請求者へのフォローの電話業務は、外部企業へ委託しています。 委託先が変わると利用技術や運用方針が変わる可能性があり、その際の改修範囲を少なくしたい要望がありました。

やったこと

モデルの分離とライブラリ化

各アプリケーションから、ライブラリ化したモデルを利用しています。 image

利用技術(インフラレイヤ)の分離

ユースケース「スタディサプリとして、資料を配送後、フォローの電話を行う」のコードで詳細をご紹介します。

システムの概要は以下の通りです。 image

コードは、多少改変していますが雰囲気をお伝えできればと思います。

# インフラレイヤ
class S3Uploader
  def initialize(bucket_name:)
    @bucket_name = bucket_name
  end

  def upload(key: key, body: body)
    base_path.files.create(key: key, body: body)
  end

  private

  # https://github.com/fog/fog-aws に密結合しているが、fog-aws以外に変わる可能性は低いので許容
  def base_path
    @base_path ||= FogHelper.establish_storage_connection.directories.get(@bucket_name)
  end
end

# アプリケーションレイヤ
# フォローの電話業務を委託する企業へ連携するファイル。
module Aya
  module Coaching
    module Brochure
      module Telemarketing
        class UploadFile
          DIR_NAME         = 'xxx'
          FILE_NAME_FORMAT = 'xxx'
          CSV_HEADERS      = %w(xxx yyy zzz)

          # UploadFileは「何の技術」でアップロードするのか知らない
          # uploaderが upload(key:, name:) メソッドを持っていれば交換可能
          def initialize(uploader:)
            @rows     = []
            @uploader = uploader
          end

          def append(target_record)
            @rows << shape_row(target_record)
          end

          def upload
            @uploader.upload(key: name, body: generate_body)
          end

          private

          # 各行を整形する。この例ではCSVの各field値
          def shape_row(applicant)
            [
              applicant.xxx,
              applicant.xxx,
              applicant.xxx,
            ]
          end

          def generate_body
            CSV.generate(headers: CSV_HEADERS, write_headers: true, col_sep: CSV_COL_SEP) do |csv|
              # xxx
            end
          end

          def name
            # xxx
          end
        end
      end
    end
  end
end

# アプリケーションレイヤ
# 現在、Jenkins・rakeタスク経由で実行する運用だが、それが変わっても影響は受けない
# ドメインに関係ない依存関係は、このレイヤで解決する
module Aya
  module Coaching
    module Brochure
      module Telemarketing
        class RequestRunner
          def run
            s3_uploader    = S3Uploader.new(bucket_name: "aya-coaching-brochure-telemarketing")
            upload_content = Telemarketing::UploadFile.new(uploader: s3_uploader)
            ActiveRecord::Base.transaction do
              Applicant.bulk_request_to_telemarket!(upload_content: upload_content)
            end
          end
        end
      end
    end
  end
end

# ドメインレイヤ
# 資料請求者を表すクラス
module Aya
  module Coaching
    module Brochure
      class Applicant
        key :first_name
        key :last_name
        key :phone_number
        key :xxxxx

        # 他にも状態(資料請求済み、フォローの電話前、契約書配送 等)を持っている
        # 状態の遷移知識はApplicantにカプセル化する
        state_machine :state, initial: :need_to_ship_brochure do
          transition need_to_telemarket: :requested_to_telemarket, on: :request_to_telemarket
        end

        scope :need_to_telemarket, -> { where(state: :need_to_telemarket) }

        class << self
          # Applicantは「何の技術」で「何を」アップロードするのか知らない
          # upload_contentが、appendとuploadメソッドを持っていれば交換可能
          def bulk_request_to_telemarket!(upload_content:)
            need_to_telemarket.find_each do |applicant|
              upload_content.append(applicant)
              applicant.request_to_telemarket!
            end
            upload_content.upload
          end
        end
      end
    end
  end
end

Rubyのダックタイピングで、依存関係逆転の原則(DIP) を適用できます。 つまり、上位レイヤから渡ってきたオブジェクトは、同一レイヤのインタフェースを実装していることを期待しています。 おそらく他の静的型付け言語であれば、インタフェースのみ同一レイヤーで定義する実装になるかもしれません。

ダックタイピングは暗黙的なので、Unitテストで明示・ドキュメント化すると、より保守性が上がりそうです。 テストの例や、ダックタイピングを探す方法は以下の書籍が参考になります。

オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 | Sandi Metz, 髙山 泰基

得られた結果

  • ドメインロジックの隔離
  • 交換可能性
    • 各レイヤの具体ロジックを変更しても改修範囲が少ない
  • 安定した方向への依存
    • ドメインロジックはユーザインタフェースやアプリケーションロジックより変更頻度が低い可能性が高い(安定している)
    • 安定している方向に依存することで改修時の影響を受けづらくなる

「レイヤードアーキテクチャ」の観点から補足

定義を、実践ドメイン駆動設計 | ヴァーン・ヴァーノン, 高木正弘 から引用します

インフラストラクチャやUIへの依存も排除して、さらには業務ロジック以外のアプリケーションロジックも分離する。複雑なプログラムはレイヤに分割すること。各レイヤ内で設計を進め、凝集度を高めて下位層だけに依存するようにすること。

image

書籍には他にもアーキテクチャが紹介されていますが、一貫して大事なことは、「ドメインレイヤを隔離すること」と「安定したレイヤへ単一方向に依存する」ことだと思います。

まとめ

DDDの視点から「関心の分離」を再考しました。 DDDは抽象的な説明が多いですが、既知のソフトウェア開発プラクティスと照らし合わせると理解が進みやすいかもしれません。

また、「分断されたモノリス」にも課題はあるので、保守性を高めるプロジェクトが進行中です

Quipperでは、コードの設計・保守性・DDDなどについて議論するのが好きな Web Engineer を募集しています。