RailsでDDD

[別のアプローチによる実装の記事を書きました。](https://qiita.com/haazime/items/48e5562c6a849b4fe06a)
よろしければこちらもご覧ください。

---

![スクリーンショット 2015-10-16 23.52.59.png](https://qiita-image-store.s3.amazonaws.com/0/38472/eb9a4aeb-14f9-2749-b43e-86f7761b2055.png)

「かんばん」を、DDDで設計しRailsで実装してみました。

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

現時点では、最小限の機能しかありませんが
ドメイン駆動設計をRailsで実装する際の一例として参考になれば幸いです。

## アプリケーションの機能

- 開発するフィーチャ（タスク）をカードで表現し、進捗状況をかんばんボードで可視化する
- カードを次のフェーズ（進捗の区切り）に進める時にWIP制限をチェックする

## アーキテクチャスタイル

Event SourcingなしのCQRSです。

変更（Command）の時だけドメインモデルを使います。
問い合わせ（Query）では、必要なデータをデータベースから直接取得します。
**リポジトリ**からドメインモデルを取得することはしません。

取得したデータは構造化されたオブジェクトですが、ドメインロジックは一切持たないようにしています。
ドメインモデルに、情報の取得やUIのためのメソッドを持たせず、ドメインに関する振る舞いに集中させるためです。

## 構成

```text
├── 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ディレクトリ

```text
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ディレクトリ

```text
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コントローラと、アプリケーション層のアプリケーションサービスを繋ぎます。

```rb
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リクエストを元にコマンドオブジェクトを生成した後
アプリケーションサービスをコマンドオブジェクトに渡して実行します。

```rb
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`ディレクトリ以下にモジュールとして切り出しました。

```rb
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`で`カラム型からドメインオブジェクトへ`
変換を行います。

```rb
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](https://github.com/krisleech/wisper)のラッパーです。
ドメインイベントのpublish/subscribeを扱います。

## app/repositories

**リポジトリ**の実装です。
1つの**集約**に1つの**リポジトリ**が対応します。

```rb
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専用です。
バリデーションやコールバックなどは一切行いません。

```rb
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`以下のモデルを直接取得します。

```rb
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
```

```rb
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

**レイヤードアーキテクチャ**のアプリケーション層のサービスです。
※ドメインサービスではありません。

ドメインの知識は持っていませんが、ユースケースを実現するための処理を行います。

```rb
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](http://rom-rb.org/)や[Lotus::Model](https://github.com/lotus/model)も使えそう
- 最も重要なのはdomainにいいモデルを作ること