0
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

テスト駆動開発からはじめるドメイン駆動設計入門 ~ドメインサービス~

Posted at

はじめに

この記事は書籍『ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本』で解説されているドメイン駆動設計パターンをテスト駆動開発で実装したものです。

言語はRubyです。Rubyでのテスト駆動開発の詳細に関してはこちらの記事をご参照ください。下のボタンをクリックするとブラウザ開発環境が起動するのでお手軽に開発を始めることが出来ます。

Open in Gitpod

ユーザーストーリー

前回の エンティティ に続いて今回は ドメインサービス を作成します。
まず ユーザーストーリー をもとに追加作業を TODOリスト に追加します。

利用者として
ユーザーを管理できるようにしたい
なぜならユーザーはシステムを利用するために必要だから

TODOリスト

  • ❏ ユーザーを管理できるようにする

    • ✓ ユーザーを登録する

      • ✓ IDと名前を持ったユーザーを作成する

      • ✓ ユーザー名が3文字未満の場合はエラー

      • ✓ ユーザー名を指定しない場合はエラー

      • ✓ ユーザー名が4文字の場合は登録される

      • ✓ IDを指定しない場合はエラー

    • ✓ ユーザー名を変更できるようにする

    • ✓ ユーザーの同一性を判断できるようにする

      • ✓ 識別子を追加する

      • ✓ エンティティの比較のを行う

    • ❏ ユーザーを重複して登録できないようにする

仮実装を経て本実装へ

セットアップ

ユーザーデータを永続化するため今回はSQLiteを使用します。Rubyでのセットアップ方法はまず Gemfile
にsqlite3ライブラリを追加します。

Gemfile

# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

...
gem 'sqlite3'

続いてライブラリを読み込んで利用できるようにします。

lib/sns.rb

# frozen_string_literal: true

require './lib/user_id.rb'
require './lib/user_name.rb'
require './lib/user.rb'
require 'sqlite3'

追加する機能のテストコードの準備をします。テスト実行の最初にユーザーテーブルを作成してテスト終了時にテーブルを削除するようにします。

test/user_test.rb

...
  describe 'ユーザーの重複を判定する' do
    def setup
      @db = SQLite3::Database.new('sns.db')
      sql = 'CREATE TABLE USERS(id string, name string)'
      @db.execute(sql)
    end

    def teardown
      sql = 'DROP TABLE USERS'
      @db.execute(sql)
      @db.close
    end
  end
end

準備が出来たら追加ライブラリをインストールします。

$ bundle install

仮実装

ユーザーの重複を判定する機能を実装したいのですがまだ具体的なコードの実装イメージが湧きません。こんな時は 仮実装 でまず失敗するテストから始めるとしましょう。

test_user_test.rb

...
    def test_登録するユーザーがすでに存在している
      id = UserId.new('1')
      name = UserName.new('Bob')
      user = User.new(user_id: id, user_name: name)

      sql = 'INSERT INTO USERS(id, name) VALUES(:id, :name)'
      @db.execute(sql, id: user.id.value, name: user.name.value)

      assert user.exist?(user)
    end
...

User#exist? メソッドが存在しないためテストは失敗しました。

$ ruby test/user_test.rb
Started with run options --seed 19263

ユーザーの重複を判定する
  test_登録するユーザーがすでに存在している                                        ERROR (0.04s)
Minitest::UnexpectedError:         NoMethodError: undefined method exist? for #<User:0x000055f6172730e0>
            test/user_test.rb:103:in test_登録するユーザーがすでに存在している
...

テストを通すために User#exist? メソッドを追加して最小限の実装をします。

lib/user.rb

class User
...
  def change_name(name)
    raise if name.nil?

    @name = name
  end

  def exist?(_user)
    true
  end

  def eql?(other)
    @id == other.id
  end
...
end

テストが成功してグリーンの状態になりました。

$ ruby test/user_test.rb
Started with run options --seed 21516

ユーザーを更新する
  test_ユーザー名を更新する                                                 PASS (0.00s)

...

ユーザーの重複を判定する
  test_登録するユーザーがすでに存在している                                         PASS (0.07s)

Finished in 0.07418s
10 tests, 12 assertions, 0 failures, 0 errors, 0 skips

本実装

仮実装 でテストは通るようになりましたがこのままではユーザーが存在しない場合もTrueを返すのでデータベースから該当するユーザーが存在するかを確認するコードを実装します。

...
  def exist?(user)
    db = SQLite3::Database.new('sns.db')
    sql = 'SELECT * FROM USERS WHERE name = :name'
    result = db.execute(sql, name: user.name.value)
    !result.empty?
  end
...

テストが通ることを確認します。

$ ruby test/user_test.rb
Started with run options --seed 47320

ユーザーを更新する
  test_ユーザー名を更新する                                                 PASS (0.00s)

...

ユーザーの重複を判定する
  test_登録するユーザーがすでに存在している                                         PASS (0.05s)

Finished in 0.05795s
10 tests, 12 assertions, 0 failures, 0 errors, 0 skips

テスト

ユーザーが存在しない場合のテストも追加しておきます。

...
    def test_登録するユーザーが存在していない
      id = UserId.new('2')
      name = UserName.new('Alice')
      user = User.new(user_id: id, user_name: name)

      refute user.exist?(user)
    end
...

テストが通ることを確認します。

$ ruby test/user_test.rb
Started with run options --seed 2872

ユーザーの同一性を判断する
  test_同じ名前の同じユーザー                                                PASS (0.00s)
  test_同じ名前の異なるユーザー                                               PASS (0.00s)
  test_名前を変更した同じユーザー                                              PASS (0.00s)

ユーザーの重複を判定する
  test_登録するユーザーがすでに存在している                                         PASS (0.19s)
  test_登録するユーザーが存在していない                                           PASS (0.12s)
...

Finished in 0.32139s
11 tests, 13 assertions, 0 failures, 0 errors, 0 skips

リファクタリング

レッド、グリーン、となったので次はリファクタです。

クラスの抽出

ユーザー エンティティ にユーザーが存在するかを確認するメソッドが存在するのは不自然なので クラスの抽出 を適用して ドメインサービス クラスを抽出するとしましょう。まず、 ドメインサービス クラスとテストクラスを追加します。

test/user_service_test.rb

require './test/test_helper'
require './lib/sns.rb'

class UserServiceTest < Minitest::Test
end

続いて ドメインサービス クラスとなる UserService クラスを追加して読み込むようにします。

lib/user_service.rb

class UserService
end

lib/sns.rb

require './lib/user_id.rb'
require './lib/user_name.rb'
require './lib/user.rb'
require './lib/user_service.rb'
require 'sqlite3'

テストが壊れていないことを確認します。

$ rake test
...
ユーザーの重複を判定する
  test_登録するユーザーがすでに存在している                                         PASS (0.16s)
  test_登録するユーザーが存在していない                                           PASS (0.12s)

Finished in 0.60710s
13 tests, 15 assertions, 0 failures, 0 errors, 0 skips

メソッドの移動

ドメインサービスクラスの抽出 したので続いて エンティティ からユーザーの重複を確認する メソッドの移動 を実施します。

test/user_service_test.rb

class UserServiceTest < Minitest::Test
  describe 'ユーザーの重複を判定する' do
    def setup
      @db = SQLite3::Database.new('sns.db')
      sql = 'CREATE TABLE USERS(id string, name string)'
      @db.execute(sql)
    end

    def test_登録するユーザーがすでに存在している
      id = UserId.new('1')
      name = UserName.new('Bob')
      user = User.new(user_id: id, user_name: name)

      sql = 'INSERT INTO USERS(id, name) VALUES(:id, :name)'
      @db.execute(sql, id: user.id.value, name: user.name.value)

      assert user.exist?(user)
    end

    def test_登録するユーザーが存在していない
      id = UserId.new('2')
      name = UserName.new('Alice')
      user = User.new(user_id: id, user_name: name)

      refute user.exist?(user)
    end

    def teardown
      sql = 'DROP TABLE USERS'
      @db.execute(sql)
      @db.close
    end
  end
end

test/user_service_test.rb

rlass UserService
  def exist?(user)
    db = SQLite3::Database.new('sns.db')
    sql = 'SELECT * FROM USERS WHERE name = :name'
    result = db.execute(sql, name: user.name.value)
    !result.empty?
  end
end

テストを ドメインサービス 経由から実行するように変更します。

test/user_service_test.rb

require './test/test_helper'
require './lib/sns'

class UserServiceT.rbest < Minitest::Test
  describe 'ユーザーの重複を判定する' do
    def setup
      @db = SQLite3::Database.new('sns.db')
      sql = 'CREATE TABLE USERS(id string, name string)'
      @db.execute(sql)

      @service = UserService.new
    end

    def test_登録するユーザーがすでに存在している
      id = UserId.new('1')
      name = UserName.new('Bob')
      user = User.new(user_id: id, user_name: name)

      sql = 'INSERT INTO USERS(id, name) VALUES(:id, :name)'
      @db.execute(sql, id: user.id.value, name: user.name.value)

      assert @service.exist?(user)
    end

    def test_登録するユーザーが存在していない
      id = UserId.new('2')
      name = UserName.new('Alice')
      user = User.new(user_id: id, user_name: name)

      refute @service.exist?(user)
    end

    def teardown
      sql = 'DROP TABLE USERS'
      @db.execute(sql)
      @db.close
    end
  end
end

テストが壊れていないことを確認したら ドメインサービスクラスの抽出メソッドの移動 のリファクタリングは完了です。

$ rake test
...
ユーザーの重複を判定する
  test_登録するユーザーがすでに存在している                                         PASS (0.12s)
  test_登録するユーザーが存在していない                                           PASS (0.06s)
...
ユーザーを更新する
  test_ユーザー名を更新する                                                 PASS (0.00s)

Finished in 0.18120s
11 tests, 13 assertions, 0 failures, 0 errors, 0 skips

TODOリスト

続いてユーザーIDを エンティティ の生成時に引数として受け取っていますが重複したIDで エンティティ
を生成してしまう可能性があるので自動生成するようにリファクタリングしたいと思います。

  • ❏ ユーザーを管理できるようにする

    • ✓ ユーザーを登録する

      • ✓ IDと名前を持ったユーザーを作成する

      • ✓ ユーザー名が3文字未満の場合はエラー

      • ✓ ユーザー名を指定しない場合はエラー

      • ✓ ユーザー名が4文字の場合は登録される

      • ✓ IDを指定しない場合はエラー

    • ✓ ユーザー名を変更できるようにする

    • ✓ ユーザーの同一性を判断できるようにする

      • ✓ 識別子を追加する

      • ✓ エンティティの比較のを行う

    • ✓ ユーザーを重複して登録できないようにする

    • ❏ IDを自動生成する

リファクタリング

パラメータの削除

UUID による識別子を導入するため securerandom ライブラリを追加します。なお securerandom は標準添付ライブラリなので gem によるインストールは必要ありません。

lib/sns.rb

require './lib/user_id.rb'
require './lib/user_name.rb'
require './lib/user.rb'
require './lib/user_service.rb'
require 'sqlite3'
require 'securerandom'

エンティティ のコンストラクタの引数からidを削除して、生成時にUUIDを自動生成するように変更します。

lib/user.rb

class User
  attr_reader :id, :name

  def initialize(user_name:)
    @id = UserId.new(SecureRandom.uuid.to_str)
    @name = user_name
  end
...

プロダクトコードの変更に合わせてテストコードも修正します。

test/user_service_test.rb

class UserServiceTest < Minitest::Test
  describe 'ユーザーの重複を判定する' do
    def setup
      @db = SQLite3::Database.new('sns.db')
      sql = 'CREATE TABLE USERS(id string, name string)'
      @db.execute(sql)

      @service = UserService.new
    end

    def test_登録するユーザーがすでに存在している
      name = UserName.new('Bob')
      user = User.new(user_name: name)

      sql = 'INSERT INTO USERS(id, name) VALUES(:id, :name)'
      @db.execute(sql, id: user.id.value, name: user.name.value)

      assert @service.exist?(user)
    end

    def test_登録するユーザーが存在していない
      name = UserName.new('Alice')
      user = User.new(user_name: name)

      refute @service.exist?(user)
    end

    def teardown
      sql = 'DROP TABLE USERS'
      @db.execute(sql)
      @db.close
    end
  end
end

test/user_test.rb

class UserTest < Minitest::Test
  describe 'ユーザーを登録する' do
    def setup
      name = UserName.new('Bob')
      @user = User.new(user_name: name)
    end

    def test_IDと名前を持ったユーザーを作成する
      assert_equal 'Bob', @user.name.value
    end

    def test_ユーザー名が3文字未満の場合はエラー
      e = assert_raises RuntimeError do
        UserName.new('a')
      end

      assert_equal 'ユーザー名は3文字以上です。', e.message
    end

    def test_ユーザー名が4文字の場合は登録される
      user = User.new(
        user_name: UserName.new('abcd')
      )
      assert_equal 'abcd', user.name.value
    end

    def test_ユーザー名を指定しない場合はエラー
      assert_raises RuntimeError do
        UserName.new(nil)
      end
    end

    def test_IDを指定しない場合はエラー
      assert_raises RuntimeError do
        UserId.new(nil)
      end
    end
  end

  describe 'ユーザーを更新する' do
    def setup
      name = UserName.new('Bob')
      @user = User.new(user_name: name)
    end

    def test_ユーザー名を更新する
      @user.change_name('Alice')
      assert_equal 'Alice', @user.name
    end
  end

  describe 'ユーザーの同一性を判断する' do
    def setup
      name = UserName.new('Bob')
      @user = User.new(user_name: name)
    end

    def test_同じ名前の異なるユーザー
      name = UserName.new('Bob')
      @user2 = User.new(user_name: name)

      refute @user.eql?(@user2)
    end

    def test_同じ名前の同じユーザー
      assert @user.eql?(@user)
    end

    def test_名前を変更した同じユーザー
      @user.change_name('Alice')

      assert @user.eql?(@user)
    end
  end
end

テストが正しく動作することが確認出来たらリファクタリング完了です。

$ rake test
...
ユーザーの重複を判定する
  test_登録するユーザーがすでに存在している                                         PASS (0.12s)
  test_登録するユーザーが存在していない                                           PASS (0.06s)
...
ユーザーを更新する
  test_ユーザー名を更新する                                                 PASS (0.00s)

Finished in 0.18120s
11 tests, 13 assertions, 0 failures, 0 errors, 0 skips

ドメインモデル貧血症

続いて エンティティ にある change_name メソッドに メソッドの移動 を適用して ドメインサービス
に移動するリファクタリング適用してみましょう。

lib/user.rb

class User
  attr_reader :id, :name
  attr_writer :name

  def initialize(user_name:)
    @id = UserId.new(SecureRandom.uuid.to_str)
    @name = user_name
  end

  def eql?(other)
    @id == other.id
  end

  def ==(other)
    other.equal?(self) || other.instance_of?(self.class) && other.id == id
  end

  def hash
    id.hash
  end
end

lib/user_service.rb

class UserService
  def exist?(user)
    db = SQLite3::Database.new('sns.db')
    sql = 'SELECT * FROM USERS WHERE name = :name'
    result = db.execute(sql, name: user.name.value)
    !result.empty?
  end

  def change_name(user, name)
    raise if name.nil?

    user.name = name
  end
end

test/user_test.rb

...
    def test_ユーザー名を更新する
      service = UserService.new
      service.change_name(@user, UserName.new('Alice'))
      assert_equal 'Alice', @user.name.value
    end
...
    def test_名前を変更した同じユーザー
      service = UserService.new
      service.change_name(@user, UserName.new('Alice'))

      assert @user.eql?(@user)
    end
...

メソッドの移動 の結果 エンティティ がスカスカになった上に 値オブジェクト を外部から更新するためのセッターを追加する必要が発生してしまいカプセル化が破壊されてしまう結果となりました。このような エンティティ の実装は ドメインモデル貧血症 と呼ばれています。このリファクタリングはやりすぎだったようなので変更前に戻しておきましょう。

$ git checkout .

リリース

静的コード解析

$ rubocop
The following cops were added to RuboCop, but are not configured. Please set Enabled to either `true` or `false` in your `.rubocop.yml` file:
 - Layout/EmptyLinesAroundAttributeAccessor (0.83)
 - Layout/SpaceAroundMethodCallOperator (0.82)
 - Lint/RaiseException (0.81)
 - Lint/StructNewOverride (0.81)
 - Style/ExponentialNotation (0.82)
 - Style/HashEachMethods (0.80)
 - Style/HashTransformKeys (0.80)
 - Style/HashTransformValues (0.80)
 - Style/SlicingWithRange (0.83)
For more information: https://docs.rubocop.org/en/latest/versioning/
Inspecting 7 files
.......

7 files inspected, no offenses detected

コードカバレッジ

2020060501

TODOリスト

  • ❏ ユーザーを管理できるようにする

    • ✓ ユーザーを登録する

      • ✓ IDと名前を持ったユーザーを作成する

      • ✓ ユーザー名が3文字未満の場合はエラー

      • ✓ ユーザー名を指定しない場合はエラー

      • ✓ ユーザー名が4文字の場合は登録される

      • ✓ IDを指定しない場合はエラー

    • ✓ ユーザー名を変更できるようにする

    • ✓ ユーザーの同一性を判断できるようにする

      • ✓ 識別子を追加する

      • ✓ エンティティの比較のを行う

    • ✓ ユーザーを重複して登録できないようにする

    • ✓ IDを自動生成する

ファイル構成

/main.rb
  |--lib/
      |
       -- sns.rb
       -- user.rb
       -- user_id.rb
       -- user_name.rb
       -- user_service.rb
  |--test/
      |
       -- test_helper.rb
       -- user_service_test.rb
       -- user_test.rb

/main.rb.

require './test/user_test.rb'

/lib/sns.rb.

# frozen_string_literal: true

require './lib/user_id.rb'
require './lib/user_name.rb'
require './lib/user.rb'
require './lib/user_service.rb'
require 'sqlite3'
require 'securerandom'

/lib/user.rb.

# frozen_string_literal: true

# User
class User
  attr_reader :id, :name

  def initialize(user_name:)
    @id = UserId.new(SecureRandom.uuid.to_str)
    @name = user_name
  end

  def change_name(name)
    raise if name.nil?

    @name = name
  end

  def eql?(other)
    @id == other.id
  end

  def ==(other)
    other.equal?(self) || other.instance_of?(self.class) && other.id == id
  end

  def hash
    id.hash
  end
end

/lib/user_id.rb.

# frozen_string_literal: true

# User ID value object
class UserId
  attr_reader :value

  def initialize(value)
    raise if value.nil?

    @value = value
  end
end

/lib/user_name.rb.

# frozen_string_literal: true

# User name value object
class UserName
  attr_reader :value

  def initialize(value)
    raise if value.nil?
    raise 'ユーザー名は3文字以上です。' if value.length < 3

    @value = value
  end
end

/lib/user_service.rb.

# frozen_string_literal: true

# UserService
class UserService
  def exist?(user)
    db = SQLite3::Database.new('sns.db')
    sql = 'SELECT * FROM USERS WHERE name = :name'
    result = db.execute(sql, name: user.name.value)
    !result.empty?
  end
end

/test/test_helper.rb.

# frozen_string_literal: true

require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new(color: true)]
require 'minitest/autorun'

/test/user_service_test.rb.

# frozen_string_literal: true

require './test/test_helper'
require './lib/sns.rb'

class UserServiceTest < Minitest::Test
  describe 'ユーザーの重複を判定する' do
    def setup
      @db = SQLite3::Database.new('sns.db')
      sql = 'CREATE TABLE USERS(id string, name string)'
      @db.execute(sql)

      @service = UserService.new
    end

    def test_登録するユーザーがすでに存在している
      name = UserName.new('Bob')
      user = User.new(user_name: name)

      sql = 'INSERT INTO USERS(id, name) VALUES(:id, :name)'
      @db.execute(sql, id: user.id.value, name: user.name.value)

      assert @service.exist?(user)
    end

    def test_登録するユーザーが存在していない
      name = UserName.new('Alice')
      user = User.new(user_name: name)

      refute @service.exist?(user)
    end

    def teardown
      sql = 'DROP TABLE USERS'
      @db.execute(sql)
      @db.close
    end
  end
end

/test/user_test.rb.

# frozen_string_literal: true

require './test/test_helper'
require './lib/sns.rb'


class UserTest < Minitest::Test
  describe 'ユーザーを登録する' do
    def setup
      name = UserName.new('Bob')
      @user = User.new(user_name: name)
    end

    def test_IDと名前を持ったユーザーを作成する
      assert_equal 'Bob', @user.name.value
    end

    def test_ユーザー名が3文字未満の場合はエラー
      e = assert_raises RuntimeError do
        UserName.new('a')
      end

      assert_equal 'ユーザー名は3文字以上です。', e.message
    end

    def test_ユーザー名が4文字の場合は登録される
      user = User.new(user_name: UserName.new('abcd'))
      assert_equal 'abcd', user.name.value
    end

    def test_ユーザー名を指定しない場合はエラー
      assert_raises RuntimeError do
        UserName.new(nil)
      end
    end

    def test_IDを指定しない場合はエラー
      assert_raises RuntimeError do
        UserId.new(nil)
      end
    end
  end

  describe 'ユーザーを更新する' do
    def setup
      name = UserName.new('Bob')
      @user = User.new(user_name: name)
    end

    def test_ユーザー名を更新する
      @user.change_name('Alice')
      assert_equal 'Alice', @user.name
    end
  end

  describe 'ユーザーの同一性を判断する' do
    def setup
      name = UserName.new('Bob')
      @user = User.new(user_name: name)
    end

    def test_同じ名前の異なるユーザー
      name = UserName.new('Bob')
      @user2 = User.new(user_name: name)

      refute @user.eql?(@user2)
    end

    def test_同じ名前の同じユーザー
      assert @user.eql?(@user)
    end

    def test_名前を変更した同じユーザー
      @user.change_name('Alice')

      assert @user.eql?(@user)
    end
  end
end

ふりかえり

まず、ユーザーストーリー から追加の TODOリスト を作成しました。
TODOリスト の内容を実装するにあたって今回は 仮実装を経て本実装へ のアプローチで作業を進めていきました。

続いて、 クラスの抽出ドメインサービス を抽出して エンティティ から対象メソッドを メソッドの移動ドメインサービス に移しました。

UUIDによる識別子を導入した後 エンティティ から メソッドの移動 をさらに実施した結果 ドメインモデル貧血症 を起こしてしまったので変更を取り消しました。

次回は リポジトリ の実装に取り組んでみたいと思います。

0
5
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
0
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?