はじめに
この記事は書籍『ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本』で解説されているドメイン駆動設計パターンをテスト駆動開発で実装したものです。
言語はRubyです。Rubyでのテスト駆動開発の詳細に関してはこちらの記事をご参照ください。下のボタンをクリックするとブラウザ開発環境が起動するのでお手軽に開発を始めることが出来ます。
ユーザーストーリー
前回の エンティティ に続いて今回は ドメインサービス を作成します。
まず ユーザーストーリー をもとに追加作業を 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
コードカバレッジ
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による識別子を導入した後 エンティティ から メソッドの移動 をさらに実施した結果 ドメインモデル貧血症 を起こしてしまったので変更を取り消しました。
次回は リポジトリ の実装に取り組んでみたいと思います。