LoginSignup
6
2

More than 3 years have passed since last update.

RailsでDDD 2020-2021

Posted at

5年前に書いたこの記事をいまだにストックしてもらうことがあるのですが、別のアプローチでRailsによる戦術的DDDを実装しているので軽く紹介します。

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

Githubリポジトリ

haazime/fridge

変わってないところ

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

変わったところ

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

ドメインモデルを表す図などとコードを一致させ、その状態を保ちます。
コードを書いてみて違和感があればそれをドメインモデルに反映します。
MODEL EXPLORATION Whirlpool - CODE PROVE

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によるドメインレイヤー境界での型チェック

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

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2