別のアプローチによる実装の記事を書きました。
よろしければこちらもご覧ください。
「かんばん」を、DDDで設計しRailsで実装してみました。
現時点では、最小限の機能しかありませんが
ドメイン駆動設計をRailsで実装する際の一例として参考になれば幸いです。
アプリケーションの機能
- 開発するフィーチャ(タスク)をカードで表現し、進捗状況をかんばんボードで可視化する
- カードを次のフェーズ(進捗の区切り)に進める時にWIP制限をチェックする
アーキテクチャスタイル
Event SourcingなしのCQRSです。
変更(Command)の時だけドメインモデルを使います。
問い合わせ(Query)では、必要なデータをデータベースから直接取得します。
リポジトリからドメインモデルを取得することはしません。
取得したデータは構造化されたオブジェクトですが、ドメインロジックは一切持たないようにしています。
ドメインモデルに、情報の取得やUIのためのメソッドを持たせず、ドメインに関する振る舞いに集中させるためです。
構成
├── Gemfile
├── Gemfile.lock
├── README.rdoc
├── Rakefile
├── app
├── bin
├── config
├── config.ru
├── db
├── domain (*)
├── lib
├── log
├── public
├── spec
├── tmp
└── vendor
Rails.root
直下にdomain
ディレクトリがある以外は通常のRailsと同じです。
domainをappに含めない理由
app
は解決領域、domain
は問題領域だからです。
domain
をアプリケーションと疎結合にすることで
Rails以外のWebアプリケーションフレームワークを
使うこともできます。
後述のO/RマッピングでActiveRecordを使っているので
実際に違うWAFを使おうとすると、そう簡単には切り替えられないのですが
最も大事なドメインモデルはそのまま利用することができます。
domainディレクトリ
domain
├── activity
│ ├── complete_state.rb
│ ├── end_phase_spec.rb
│ ├── no_state.rb
│ ├── no_transition.rb
│ ├── no_wip_limit.rb
│ ├── phase.rb
│ ├── phase_spec.rb
│ ├── phase_spec_builder.rb
│ ├── state.rb
│ ├── step.rb
│ ├── transition.rb
│ ├── transition_setted.rb
│ ├── wip_limit.rb
│ ├── workflow.rb
│ └── workflow_builder.rb
├── activity.rb
├── feature
│ ├── backlog_service.rb
│ ├── description.rb
│ ├── development_tracker.rb
│ ├── feature.rb
│ ├── feature_id.rb
│ └── state.rb
├── feature.rb
├── kanban
│ ├── actions.rb
│ ├── add_card.rb
│ ├── board.rb
│ ├── board_builder.rb
│ ├── board_maintainer.rb
│ ├── card.rb
│ ├── card_added.rb
│ ├── card_map.rb
│ ├── card_removed.rb
│ ├── pull_card.rb
│ ├── push_card.rb
│ └── remove_card.rb
├── kanban.rb
├── project
│ ├── description.rb
│ ├── operations.rb
│ ├── project.rb
│ ├── project_factory.rb
│ ├── project_id.rb
│ └── project_launched.rb
└── project.rb
名前の通り、ユビキタス言語を使ってRubyで実装されたドメインモデルが入っています。
現時点で想定している、境界づけられたコンテキスト内にあるモデルを
- Activity
- Feature
- Kanban
- Project
の4つのモジュールにまとめています。
- エンティティ(Entities)
- バリューオブジェクト(Value Objects)
- サービス(Domain Services)
- ドメインイベント(Domain Events)
といった戦術的パターンによる分類はしていません。
appディレクトリ
app
├── assets
├── commands
├── controllers
├── forms
├── helpers
├── infrastructures
├── mailers
├── models
│ └── view
├── repositories
├── services
├── validators
└── views
デフォルトのcontrollers
、models
、views
などのディレクトリの他に
- commands
- infrastructures
- repositories
- models/view
- services
これらのディレクトリを追加しています。
app/commands
レイヤードアーキテクチャのアプリケーション層の一部です。
UI層のRailsコントローラと、アプリケーション層のアプリケーションサービスを繋ぎます。
class FeaturesController < ApplicationController
def new
@command = AddFeatureCommand.new.tap do |c|
c.project_id_str = params[:project_id_str]
end
end
def create
@command = AddFeatureCommand.new(params[:add_feature_command])
if @command.execute(feature_service)
redirect_to backlog_url(@command.project_id_str), notice: 'Feature added to backlog!'
else
flash.now[:alert] = 'Ooops!'
render :new
end
end
end
コントローラは、HTTPリクエストを元にコマンドオブジェクトを生成した後
アプリケーションサービスをコマンドオブジェクトに渡して実行します。
class AddFeatureCommand
include ActiveModel::Model
include DomainObjectConversion
attr_accessor :project_id_str, :summary, :detail
validates :summary, presence: true
validates :detail, presence: true
def description
Feature::Description.new(summary, detail)
end
def execute(service)
return false unless valid?
service.add(project_id, description)
end
end
コマンドは、
- コマンドを実行するときに必要なパラメータのバリデーション
- パラメータのドメインオブジェクトへの変換(主にバリューオブジェクトへ変換する)
- 自身に対応するアプリケーションサービスの呼び出し
- エラーハンドリング
を担当します。
app/infrastructures
Arize
集約(Aggregates)を構成するオブジェクトとリレーション(DBレコード)の変換を担当します。
Javaなどでは、DataMapperパターンのライブラリを使ってリポジトリに実装することが多いと思いますが、
今回はRails標準のActiveRecordを使っています。
その場合、ドメインモデルのエンティティクラスを、通常のRailsモデルと同じように実装すると
ドメインの振る舞いと、データに関する振る舞いが混在します。
今回は結局、エンティティクラスはActiveRecord::Base
を継承していて
実際には通常のRailsモデルと変わらないのですが
コード(ファイル)だけでも、ドメインの振る舞いとデータに関する振る舞いを分離させたかったので
このapp/infrastructures/arize
ディレクトリ以下にモジュールとして切り出しました。
module Arize
module Card
extend ActiveSupport::Concern
included do
self.table_name = 'card_records'
include Writers
include Readers
end
module Writers
def feature_id=(a_feature_id)
self.feature_id_str = a_feature_id.to_s
end
def step=(a_step)
self.step_phase_name = a_step.phase.to_s
self.step_state_name = serialize_state(a_step.state)
end
def serialize_state(state)
state.to_s
end
end
module Readers
def feature_id
::Feature::FeatureId.new(feature_id_str)
end
def step
::Project::Step.new(
build_phase(step_phase_name),
build_state(step_state_name)
)
end
def build_phase(phase)
::Project::Phase.new(phase)
end
def build_state(state)
::Project::State.from_string(state)
end
end
end
end
通常のRailsモデルと違い、テーブル名はクラス名に_records
接尾辞を付けています。
Writers
でドメインオブジェクトからカラム型へ
Readers
でカラム型からドメインオブジェクトへ
変換を行います。
module Kanban
class Card < ActiveRecord::Base
include Arize::Card
def self.write(feature_id)
new.tap do |card|
card.feature_id = feature_id
end
end
def relocate(to)
self.step = to
end
def ==(other)
if other.instance_of?(self.class)
self.feature_id == other.feature_id
else
self.feature_id == other
end
end
end
end
ドメインモデルのKanban::Card
クラスはこのようになっています。
ドメインに関する振る舞いのみです。
event_publisher.rb
Wisperのラッパーです。
ドメインイベントのpublish/subscribeを扱います。
app/repositories
リポジトリの実装です。
1つの集約に1つのリポジトリが対応します。
class BoardRepository
def find(project_id)
Kanban::Board
.includes(:cards)
.find_by(project_id_str: project_id.to_s)
end
def store(board)
board.save!
end
end
ドメインオブジェクトに何らかの変更をしたいときのみリポジトリを使います。
したがって、メソッドはfind
とstore
だけです。
集約のルートエンティティはActiveRecord::Base
を継承していますので
通常のRailsモデルと同じように扱えます。
app/models
通常のRailsモデルですが、用途はView専用です。
バリデーションやコールバックなどは一切行いません。
class ProjectRecord < ActiveRecord::Base
has_many :phase_spec_records, -> { order(:order) }
has_many :state_records, -> { order(:order) }
class << self
def with_workflow(project_id_str)
eager_load(:phase_spec_records, :state_records)
.find_by(project_id_str: project_id_str)
end
end
def name
description_name
end
def goal
description_goal
end
end
app/models/view
ドメインの状態を表示するときは、このapp/models/view
以下のモデルを直接取得します。
class BoardsController < ApplicationController
layout 'board'
def show
@stats = View::Stats.build(params[:project_id_str], [:backlogs, :ships])
@board = View::Board.build(params[:project_id_str])
if @board.header.phases.any?
render :show
else
render :bootstrap
end
end
end
module View
Board = Struct.new(:project_id_str, :project_name, :header, :body) do
class << self
def build(project_id_str)
project_with_workflow = ProjectRecord.with_workflow(project_id_str)
new(
project_id_str,
project_with_workflow.description_name,
header(project_with_workflow),
body(project_id_str)
)
end
private
def header(project_with_workflow)
View::BoardHeader.build(
project_with_workflow.project_id_str,
project_with_workflow.phase_spec_records.to_a,
project_with_workflow.state_records.to_a
)
end
def body(project_id_str)
cards = CardRecord.with_feature(project_id_str)
View::BoardBody.build(project_id_str, cards)
end
end
def step_size
header.phase_states.size
end
end
end
ドメインモデル(Commandモデル)とは違い、
データの問い合わせのためのメソッドを多数持っていたり
複数の集約にまたがったデータを、1箇所で保持しているのが特徴です。
app/services
レイヤードアーキテクチャのアプリケーション層のサービスです。
※ドメインサービスではありません。
ドメインの知識は持っていませんが、ユースケースを実現するための処理を行います。
class BoardService
def initialize(project_repository, board_repository, development_tracker)
@project_repository = project_repository
@board_repository = board_repository
@development_tracker = development_tracker
end
def add_card(project_id, feature_id)
EventPublisher.subscribe(@development_tracker)
project = @project_repository.find(project_id)
board = @board_repository.find(project_id)
action = Kanban::AddCard.new(feature_id, project.workflow)
board.update_by(action)
@board_repository.store(board)
end
def forward_card(project_id, feature_id, current_step)
EventPublisher.subscribe(@development_tracker)
board = @board_repository.find(project_id)
project = @project_repository.find(project_id)
action = Kanban::Actions.detect(feature_id, current_step, project.workflow)
board.update_by(action)
@board_repository.store(board)
end
end
コンストラクタにリポジトリやドメインサービスを渡します。
必要であればデータベーストランザクションを管理します。
まとめ
- RailsでもDDDはできる
- ただしO/Rマッパーは工夫が必要
- romやLotus::Modelも使えそう
- 最も重要なのはdomainにいいモデルを作ること