5年前に書いたこの記事をいまだにストックしてもらうことがあるのですが、別のアプローチでRailsによる戦術的DDDを実装しているので軽く紹介します。
※太字は戦術的DDDパターン名
Githubリポジトリ
変わってないところ
- アーキテクチャスタイル
-
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によるドメインレイヤー境界での型チェック
実験的に導入してみました。
- ドメインオブジェクト間
- アプリケーションレイヤーとドメインレイヤー
- ドメインレイヤーとインフラストラクチャレイヤー
ではやりとりするオブジェクトの型が厳密に決まっているので、これらのレイヤーに属するクラス/モジュールでは全て型が宣言されています。