RailsでDDD 2020-2021

[5年前に書いたこの記事](https://qiita.com/haazime/items/5776e4e25b6527b682e7)をいまだにストックしてもらうことがあるのですが、別のアプローチでRailsによる戦術的DDDを実装しているので軽く紹介します。

※*太字は戦術的DDDパターン名*

# Githubリポジトリ

[haazime/fridge](https://github.com/haazime/fridge)

# 変わってないところ

- アーキテクチャスタイル
- `app`ディレクトリと同じ階層に`domain`ディレクトリがある
- `domain`内は**MODULES**単位になっている
- 永続化はActiveRecordで行われる

# 変わったところ

## ドメインモデルとコードの整合性を保つ

ドメインモデルを表す図などとコードを一致させ、その状態を保ちます。
コードを書いてみて違和感があればそれをドメインモデルに反映します。
[MODEL EXPLORATION Whirlpool - CODE PROVE](https://www.domainlanguage.com/ddd/whirlpool/)

## `domain`はRailsに依存していない

次の永続化にも関わりますが、`domain`ディレクトリにあるすべてのオブジェクトはRailsに一切依存しておらず、`domain`内で完結しています。

Railsに依存していないので、ドメインロジックのテスト（RSpec）は`require rails_helper`することなく実行できます。

## ドメインオブジェクトのDBへの永続化

永続化の責務がドメインレイヤーから分離され、**REPOSITORIES（リポジトリ）**のインターフェースは`domain`に、実装は`app/repositories`にあります。

今回はDBテーブル名の規則として
- ドメインオブジェクトは`dao_`で始まる
- ドメインモデル外のオブジェクト（ログインアカウントなど）は`app_`で始まる
としました。

```
domain/issue
├── acceptance_criteria.rb
├── acceptance_criterion.rb
├── description.rb
├── id.rb
├── issue.rb <<集約のルートエンティティ>>
├── issue_repository.rb <<リポジトリのインターフェース>>
├── status.rb
├── statuses
│   ├── preparation.rb
│   ├── ready.rb
│   └── wip.rb
├── statuses.rb
├── story_point.rb
├── type.rb
├── types
│   ├── feature.rb
│   └── task.rb
└── types.rb
```

```domain/issue/issue.rb
module Issue
  class Issue
    extend T::Sig

    class << self
      extend T::Sig

      sig {params(product_id: Product::Id, type: Type, description: Description).returns(T.attached_class)}
      def create(product_id, type, description)
        new(
          Id.create,
          product_id,
          type,
          type.initial_status,
          description,
          StoryPoint.unknown,
          AcceptanceCriteria.new([]),
        )
      end

      sig {params(id: Id, product_id: Product::Id, type: Type, status: Status, description: Description, size: StoryPoint, acceptance_criteria: AcceptanceCriteria).returns(T.attached_class)}
      def from_repository(id, product_id, type, status, description, size, acceptance_criteria)
        new(id, product_id, type, status, description, size, acceptance_criteria)
      end
    end

    sig {params(
      id: Id,
      product_id: Product::Id,
      type: Type,
      status: Status,
      description: Description,
      size: StoryPoint,
      acceptance_criteria: AcceptanceCriteria
    ).void}
    def initialize(id, product_id, type, status, description, size, acceptance_criteria)
      @id = id
      @product_id = product_id
      @type = type
      @status = status
      @description = description
      @size = size
      @acceptance_criteria = acceptance_criteria
    end
    private_class_method :new

    # 省略...
  end
end
```

- `from_repository`メソッドでオブジェクトを再構成する
- `new`はプライベートクラスメソッドで、`Issue.create`以外からは呼び出せない

```app/repositories/issue_repository.rb
module IssueRepository
  class AR
    class << self
      extend T::Sig
      include Issue::IssueRepository

      sig {override.params(id: Issue::Id).returns(Issue::Issue)}
      def find_by_id(id)
        Dao::Issue.eager_load(:criteria).find(id).read
      end

      sig {override.params(issue: Issue::Issue).void}
      def store(issue)
        Dao::Issue.find_or_initialize_by(id: issue.id.to_s).tap do |dao|
          dao.write(issue)
          dao.save!
        end
      end

      sig {override.params(id: Issue::Id).void}
      def remove(id)
        Dao::Issue.destroy(id.to_s)
      end
    end
  end
end
```

- `domain/issue/issue_repository.rb`で定義されているメソッドを実装する
- 集約に含まれるドメインオブジェクトとDBレコードとのマッピングは、`app/models/dao`にある`Dao::***`*ActiveRecordモデル*で行う

## ドメインオブジェクトのバリデーション

```app/forms/issue_form.rb
class IssueForm
  include ActiveModel::Model
  extend I18nHelper

  attr_accessor :type, :description
  attr_accessor :domain_objects

  validates :type,
    presence: true,
    domain_object: { object_class: Issue::Types, method: :from_string, message: t_domain_error(Issue::InvalidType), allow_blank: true }

  validates :description,
    presence: true,
    domain_object: { object_class: Issue::Description, message: t_domain_error(Issue::InvalidDescription), allow_blank: true }
end
```

```domain/issue/types.rb
module Issue
  module Types
    class << self
      extend T::Sig

      TYPES = T.let({
        'feature' => Feature,
        'task' => Task,
      }, T::Hash[String, Type])

      sig {params(str: String).returns(Type)}
      def from_string(str)
        raise InvalidType unless TYPES.key?(str)
        T.must(TYPES[str])
      end

      # 省略...
    end
  end
end
```

- ドメインオブジェクトを生成できなければエラーとする
  - `domain_object`はカスタムバリデーター(ActiveModel::EachValidator)

## [Sorbet](https://sorbet.org/)によるドメインレイヤー境界での型チェック

実験的に導入してみました。
- ドメインオブジェクト間
- アプリケーションレイヤーとドメインレイヤー
- ドメインレイヤーとインフラストラクチャレイヤー
ではやりとりするオブジェクトの型が厳密に決まっているので、これらのレイヤーに属するクラス/モジュールでは全て型が宣言されています。