Help us understand the problem. What is going on with this article?

RailsでDDD

More than 3 years have passed since last update.

スクリーンショット 2015-10-16 23.52.59.png

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

kanban_core_extension

現時点では、最小限の機能しかありませんが
ドメイン駆動設計を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ディレクトリ

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

デフォルトのcontrollersmodelsviewsなどのディレクトリの他に

  • 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

ドメインオブジェクトに何らかの変更をしたいときのみリポジトリを使います。
したがって、メソッドはfindstoreだけです。

集約のルートエンティティは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マッパーは工夫が必要
  • 最も重要なのはdomainにいいモデルを作ること
haazime
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした