Rails x dry-monads を考える、と境界の話

最近、関数型プログラミングを詰めていった中で、dry-monads に出会った。
(実は、かなり昔にブクマしていたので再会であった。)

Railsに組み込んで使う例をLLMをいくつか出してみたうえで、有望そうであったので、改めてサンプルをつくったので記しておく。

また、システム境界についても考慮する。

実装だけ見たい場合はこちら。

github.com - Octo8080X/think-rails-x-dry-monads

参考

dry-monads

dry-monads は、dry-rb に属するライブラリ。
dry-rbは、次世代Rubyライブラリのコレクションを標榜するライブラリ群。
この中に、dry-monads がある。

dry-monads は、名の通りモナドを取り扱い、例外・関数の連鎖・エラー処理を容易にしてくれる。

今回はこの中にある dry-validation も使っていく。

その他、dry-struct などもあるが、今回は使わない。
(OpenStruct が、消えてしまったので、dry-struct を使うのも選択肢として考えてみたい。)

今回作るもの

サンプルとして、在庫があって注文リストを作成するアプリを書いてみる。

以下のテーブルがある。

  • 商品のリスト: Products
  • 注文のリスト: OrderHistories

処理条件として以下を設定する。

  • 商品は、在庫があるものだけを注文できる。
  • 注文は、n個できる
  • 注文するとき、在庫が減る

以上を条件として、実装する。

実装

標準にないディレクトリと実装方針

今回導入してみるにあたり、以下の方針を置く。

  • CRUD の内、R: Read は、Rails の標準のActiveRecordを直接扱う。
  • CRUD の内、C: Create, U: Update, D: Delete(今回削除はやらない) は、dry-monads を使って実装する。
  • CRUD の内、C: Create, U: Update は、service層を導入する。
  • service層は、ActiveRecordを直接扱わず、repository層を置き、ActiveRecordを扱う。
  • repository層は、dry-monads を使う。

境界

WEBのシステムにはいくつかの境界がある。
リクエストから見ると、以下のようになる。
リクエストは、サーバーの内部で、コントローラー、サービス、モデルなどを通過している。

graph TD;
    A[クライアント]
    A --> |リクエスト| C[コントローラー層]
    C --> |ビジネスロジック実行| D[サービス層]
    C --> |ActiveRecord操作| F[モデル層]
    D --> |データ操作| E[リポジトリ層]
    E --> |ActiveRecord操作| F[モデル層]
    F --> |SQL実行| G[データベース]
    
    subgraph "サーバー"
        C
        D
        E
        F
    end
    
    G --> |データ取得| F
    F --> |結果返却| E
    E --> |結果返却| D
    D --> |結果返却| C
    C --> |レスポンス| A
    F --> |結果返却| C

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

この中で、クライアントからサーバー、サーバーからデータベースの間には、システムの境界がある。

Railsにおけるバリデーションは、(ストロングパラメータよる確認こそすれども)基本的にモデルに記述し、データベース書き込み段階で検証される。
システムの境界としてはサーバ<->データべ――スへのシステム境界は気にされている。
クライアント<->サーバーの境界の検証がより奥へ入り込みすぎていると感じられる。
これへの対策は、Raills標準機能では、手続き的な検証であったり、formオブジェクトであったり、モデルに一度データを入れてのvalid? メソッドの実行で担保が可能である。

これへの対策として今回は、dry-validation を使う。

### Rails 標準外Gem

Rails標準ではないGemとして以下を導入する。


```gemfile
gem "dry-validation"
gem "dry-monads"
gem "rails-i18n"</pre>

### モデル層

2つのモデルを定義する。

```ruby app/models/product.rb
class Product < ApplicationRecord
has_many :order_histories, dependent: :destroy

validates :name, presence: true, length: { maximum: 255 }
validates :price, presence: true, numericality: { greater_than: 0 }
validates :stock, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :description, length: { maximum: 1000 }
end
app/models/order_history.rb
1
2
3
4
5
6
7
8
class OrderHistory < ApplicationRecord
belongs_to :product

validates :quantity, presence: true, numericality: { greater_than: 0 }
validates :ordered_at, presence: true

scope :recent, -> { order(ordered_at: :desc) }
end
### リポジトリ層 リポジトリ層は、dry-monads を使う。
app/repositories/base_repository.rb
1
2
3
class BaseRepository
include Dry::Monads[:result, :try]
end
CRUDのうちRは、ActiveRecordを直接扱うと前述したが、サービス層で検索をするため定義しておくメソッドがあります。
app/repositories/product_repository.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class ProductRepository < BaseRepository
def create(attributes)
product = Product.new(attributes)
if product.save
Success(product)
else
Failure(product.errors)
end
end

def update(id, attributes)
product = Product.find(id)
if product.update(attributes)
Success(product)
else
Failure(product.errors)
end
end

def update_stock(id, new_stock)
product = Product.find(id)
if product.update(stock: new_stock)
Success(product)
else
Failure(product.errors)
end
rescue ActiveRecord::RecordNotFound
Failure("Product not found with id: #{id}")
end

def find_by_id(id)
product = Product.find(id)
Success(product)
rescue ActiveRecord::RecordNotFound
Failure("Product not found with id: #{id}")
end
end
app/repositories/order_history_repository.rb
1
2
3
4
5
6
7
8
9
10
class OrderHistoryRepository < BaseRepository
def create(attributes)
order_history = OrderHistory.new(attributes)
if order_history.save
Success(order_history)
else
Failure(order_history.errors)
end
end
end
### サービス層 サービス層もdry-monads を使う。 1つの身定義であるが、リポジトリ層同様の共通部分は、BaseService として定義する。
app/services/base_service.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
class BaseService
include Dry::Monads[:result, :do]

protected

def success_with(value = nil)
Success(value)
end

def failure_with(error)
Failure(error)
end
end
ドメインロジックとなる部分は、新しく注文を作る処理は、サービス層で定義する。 後々テストなど考えて、DIとしてリポジトリは、引数で渡す。 dry-validation を使うことにより、宣言的なバリデーションを行う。 SuccessとFailureをが使われており、Failure を返す場合、yield を使うと処理が短絡する。 トランザクションもcommitしない。 バリデーションの見通し、ドメインロジックの見通しともに良くなっていると感じられる。
app/services/base_service.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
class NewOrderService < BaseService

ERROR_CODES = {
validation_error: 'NEW_ORDER_SERVICE_VALIDATION_ERROR',
insufficient_stock: 'NEW_ORDER_SERVICE_RUNTIME_INSUFFICIENT_STOCK',
product_not_found: 'NEW_ORDER_SERVICE_RUNTIME_PRODUCT_NOT_FOUND',
transaction_failed: 'NEW_ORDER_SERVICE_RUNTIME_TRANSACTION_FAILED'
}.freeze

# バリデーションコントラクトを定義
class Contract < Dry::Validation::Contract
params do
required(:product_id).filled(:integer)
required(:quantity).filled(:integer)
end

rule(:product_id) do
key.failure('must be greater than 0') if value <= 0
end

rule(:quantity) do
key.failure('must be greater than 0') if value <= 0
end
end

def initialize(product_repository: ProductRepository.new, order_history_repository: OrderHistoryRepository.new)
@product_repository = product_repository
@order_history_repository = order_history_repository
@contract = Contract.new
end

def call(product_id:, quantity:)
# バリデーション実行
validation_result = @contract.call(product_id: product_id, quantity: quantity)

return create_error(:validation_error, validation_result.errors) if validation_result.failure?

# バリデーション済みの値を使用
validated_params = validation_result.values

# ビジネスロジック実行
execute_order(validated_params)
end

private

def execute_order(params)
# 商品の在庫確認
product = yield product_find_by_id(params[:product_id])

# 在庫チェック
quantity_valid = valid_stock_and_quantity(product, params[:quantity])

# 後続の処理のyieldではなく明示的にチェック
return quantity_valid if !quantity_valid.success?

new_stock = product.stock - params[:quantity]

# 注文履歴を作成
order_attributes = {
product_id: params[:product_id],
quantity: params[:quantity],
ordered_at: Time.current
}

ActiveRecord::Base.transaction do
yield @product_repository.update_stock(params[:product_id], new_stock)
yield @order_history_repository.create(order_attributes)
Success()
end
rescue StandardError => e
create_error(
:transaction_failed,
{
product_id: params[:product_id],
quantity: params[:quantity]
}
)
end

# 在庫と割り当て数が適切かを返す
def valid_stock_and_quantity(product, quantity)
if product.stock < quantity
create_error(
:insufficient_stock,
{
current_stock: product.stock,
requested_quantity: :quantity
}
)
else
Success()
end
end

def product_find_by_id(product_id)
product_result = @product_repository.find_by_id(product_id)

case product_result
when Success
product_result
when Failure
create_error(
:product_not_found,
{ product_id: }
)
end
end

def create_error(error_key, additional_data = {})
Failure({
code: ERROR_CODES[error_key],
**additional_data
})
end
end
### コントローラー層 前述の通り、CRUDのうち、C: Create, U: Update は、サービス層を使う。 R: Read は、ActiveRecordを直接扱う。 サービス層で発生したエラーの内容は、最終的にコントローラー層で処理させる。
app\controllers\orders_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class OrdersController < ApplicationController
include Dry::Monads[:result]

def index
@order_histories = OrderHistory.all

end

def new
@products = Product.all
@order_history = OrderHistory.new
end

def create
result = NewOrderService.new.call(product_id: order_params[:product_id], quantity: order_params[:quantity].to_i)

case result
when Success
redirect_to orders_path
when Failure
flash[:alert] = format_error_message(result.failure)
redirect_to new_order_path
end
end

private

def order_params
params.require(:order_history).permit(:product_id, :quantity)
end

def format_error_message(error_data)
return error_data.to_s unless error_data.has_key?(:code)

t("errors.#{error_data.dig(:code)}")
end
end
### そのほか 先のサービス層で定義したエラーコードを日本語化するために、i18nを使う。
config/locales/ja.yml
1
2
3
4
5
6
ja:
errors:
NEW_ORDER_SERVICE_VALIDATION_ERROR: パラメータが無効です
NEW_ORDER_SERVICE_RUNTIME_INSUFFICIENT_STOCK: 在庫が不足しています
NEW_ORDER_SERVICE_RUNTIME_PRODUCT_NOT_FOUND: 商品が見つかりません
NEW_ORDER_SERVICE_RUNTIME_TRANSACTION_FAILED: 処理に失敗しました
と、このような形で一通り記述できる。 ドメインロジックはその処理に注力し、プレゼンテーション層では表示に関わるものに注力することができる。 ここまで書いて「ドメインモデル貧血症」という言葉が頭をよぎります。 今回のところ、モデル自体の条件における返させるメソッドを生やすような処理が無いのでそう感じるところがあります。 在庫数の検証処理については、モデルにメソッドが生えているのが適切という意見はあるでしょう。 続けてこのサービスをのテストを紹介して終わります。 ### サービス層のテスト テストの方針として、以下設定します - バリデーションのテスト - 対象のモデルが無いケース - 書き込みが失敗するケース サービスクラスのテストは以下の通り実施できる。
spec/services/new_order_service_spec.rb(抜粋)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
require 'rails_helper'

RSpec.describe NewOrderService, type: :service do
let(:product_repository) { instance_double(ProductRepository) }
let(:order_history_repository) { instance_double(OrderHistoryRepository) }
let(:service) { described_class.new(product_repository: product_repository, order_history_repository: order_history_repository) }

describe '#call' do
let(:product_id) { 1 }
let(:quantity) { 5 }
let(:product) { instance_double(Product, id: product_id, stock: 10) }

context '正常なケース' do
before do
allow(product_repository).to receive(:find_by_id).with(product_id).and_return(Dry::Monads::Success(product))
allow(product_repository).to receive(:update_stock).with(product_id, 5).and_return(Dry::Monads::Success(product))
allow(order_history_repository).to receive(:create).and_return(Dry::Monads::Success(double))
end

it '注文が正常に処理される' do
result = service.call(product_id: product_id, quantity: quantity)

expect(result).to be_success
expect(product_repository).to have_received(:find_by_id).with(product_id)
expect(product_repository).to have_received(:update_stock).with(product_id, 5)
expect(order_history_repository).to have_received(:create).with(
hash_including(
product_id: product_id,
quantity: quantity
)
)
end
end

context 'バリデーションエラーのケース' do
context 'product_idが0以下の場合' do
let(:product_id) { 0 }

it 'バリデーションエラーを返す' do
result = service.call(product_id: product_id, quantity: quantity)

expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_VALIDATION_ERROR')
expect(result.failure[:product_id]).to include('must be greater than 0')
end
end
# 省略
end

context 'ビジネスロジックエラーのケース' do
context '商品が見つからない場合' do
before do
allow(product_repository).to receive(:find_by_id).with(product_id).and_return(Dry::Monads::Failure('Not found'))
end

it 'transaction_failedエラーを返す(do記法により自動的に処理中断)' do
result = service.call(product_id: product_id, quantity: quantity)

expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_RUNTIME_TRANSACTION_FAILED')
expect(result.failure[:product_id]).to eq(product_id)
expect(result.failure[:quantity]).to eq(quantity)
end
end

context '在庫が不足している場合' do
let(:product) { instance_double(Product, id: product_id, stock: 3) }

before do
allow(product_repository).to receive(:find_by_id).with(product_id).and_return(Dry::Monads::Success(product))
end

it 'insufficient_stockエラーを返す' do
result = service.call(product_id: product_id, quantity: quantity)

expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_RUNTIME_INSUFFICIENT_STOCK')
expect(result.failure[:current_stock]).to eq(3)
expect(result.failure[:requested_quantity]).to eq(quantity)
end
end

context '在庫アップデートが失敗した場合' do
before do
allow(product_repository).to receive(:find_by_id).with(product_id).and_return(Dry::Monads::Success(product))
allow(product_repository).to receive(:update_stock).with(product_id, 5).and_return(Dry::Monads::Failure('Update failed'))
end

it 'transaction_failedエラーを返す' do
result = service.call(product_id: product_id, quantity: quantity)

expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_RUNTIME_TRANSACTION_FAILED')
expect(result.failure[:product_id]).to eq(product_id)
expect(result.failure[:quantity]).to eq(quantity)
end
end

context '注文履歴の作成が失敗した場合' do
before do
allow(product_repository).to receive(:find_by_id).with(product_id).and_return(Dry::Monads::Success(product))
allow(product_repository).to receive(:update_stock).with(product_id, 5).and_return(Dry::Monads::Success(product))
allow(order_history_repository).to receive(:create).and_return(Dry::Monads::Failure('Create failed'))
end

it 'transaction_failedエラーを返す' do
result = service.call(product_id: product_id, quantity: quantity)

expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_RUNTIME_TRANSACTION_FAILED')
expect(result.failure[:product_id]).to eq(product_id)
expect(result.failure[:quantity]).to eq(quantity)
end
end

context 'トランザクション中に例外が発生した場合' do
before do
allow(product_repository).to receive(:find_by_id).with(product_id).and_return(Dry::Monads::Success(product))
allow(product_repository).to receive(:update_stock).and_raise(StandardError, 'Database error')
end

it 'transaction_failedエラーを返す' do
result = service.call(product_id: product_id, quantity: quantity)

expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_RUNTIME_TRANSACTION_FAILED')
expect(result.failure[:product_id]).to eq(product_id)
expect(result.failure[:quantity]).to eq(quantity)
end
end
end
end

describe 'プライベートメソッドのテスト' do
describe '#valid_stock_and_quantity' do
let(:product) { instance_double(Product, stock: 10) }

context '在庫が十分な場合' do
it 'Successを返す' do
result = service.send(:valid_stock_and_quantity, product, 5)
expect(result).to be_success
end
end

context '在庫が不足している場合' do
it 'Failureを返す' do
result = service.send(:valid_stock_and_quantity, product, 15)
expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_RUNTIME_INSUFFICIENT_STOCK')
expect(result.failure[:current_stock]).to eq(10)
expect(result.failure[:requested_quantity]).to eq(15)
end
end
end

describe '#product_find_by_id' do
 # 省略
end

describe '#create_error' do
it '適切なエラー構造を作成する' do
result = service.send(:create_error, :validation_error, { test: 'data' })

expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_VALIDATION_ERROR')
expect(result.failure[:test]).to eq('data')
end
end
end

describe 'Contract バリデーション' do
let(:contract) { described_class::Contract.new }

describe 'product_id のバリデーション' do
it '正の整数で成功する' do
result = contract.call(product_id: 1, quantity: 1)
expect(result).to be_success
end

it '0で失敗する' do
result = contract.call(product_id: 0, quantity: 1)
expect(result).to be_failure
expect(result.errors[:product_id]).to include('must be greater than 0')
end

it '負の数で失敗する' do
result = contract.call(product_id: -1, quantity: 1)
expect(result).to be_failure
expect(result.errors[:product_id]).to include('must be greater than 0')
end

it '空で失敗する' do
result = contract.call(quantity: 1)
expect(result).to be_failure
expect(result.errors[:product_id]).to include('is missing')
end
end

describe 'quantity のバリデーション' do
# 省略
end
end
end
このテストは、実際にモデルは作成しないでテストが行われている。 インテグレーションテストは、以下のように記述できる。 テストトロフィー的にインテグレエーションテストになると、項目数は減ってくる。
spec\services\new_order_service_integration_spec.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
require 'rails_helper'

RSpec.describe NewOrderService, type: :service do
describe '統合テスト(実際のリポジトリを使用)' do
let!(:product) { Product.create!(name: 'Test Product', price: 1000, stock: 10) }
let(:service) { described_class.new }

context '正常な注文処理' do
it '商品の在庫が減り、注文履歴が作成される' do
expect {
result = service.call(product_id: product.id, quantity: 3)
expect(result).to be_success
}.to change { product.reload.stock }.from(10).to(7)
.and change { OrderHistory.count }.by(1)

order_history = OrderHistory.last
expect(order_history.product_id).to eq(product.id)
expect(order_history.quantity).to eq(3)
expect(order_history.ordered_at).to be_present
end
end

context '在庫不足のケース' do
it '在庫が変更されず、注文履歴も作成されない' do
initial_stock = product.stock
initial_order_count = OrderHistory.count

result = service.call(product_id: product.id, quantity: 15)

expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_RUNTIME_INSUFFICIENT_STOCK')
expect(product.reload.stock).to eq(initial_stock)
expect(OrderHistory.count).to eq(initial_order_count)
end
end

context '存在しない商品IDのケース' do
it 'transaction_failedエラーが返される' do
expect {
result = service.call(product_id: 99999, quantity: 1)
expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_RUNTIME_TRANSACTION_FAILED')
}.not_to change { OrderHistory.count }
end
end

context 'バリデーションエラーのケース' do
it '在庫も注文履歴も変更されない' do
initial_stock = product.stock
initial_order_count = OrderHistory.count

result = service.call(product_id: -1, quantity: 1)

expect(result).to be_failure
expect(result.failure[:code]).to eq('NEW_ORDER_SERVICE_VALIDATION_ERROR')
expect(product.reload.stock).to eq(initial_stock)
expect(OrderHistory.count).to eq(initial_order_count)
end
end
end
end
FactoryBotを積極的に使うケースだと処理時間が問題になるケースを耳にする。 DIによる処理経路をモックすることによるテストで、サービスのロジック検証に集中できる。 --- というわけで、dry-monads を使ったサービス層の実装を検討した。 改めて綺麗に書いてみたから、綺麗に書けている部分というのがあり、dry-monads の恩恵とどこまで解釈できるのかは悩ましい部分を感じる。 dry-validation によるスキーマベースのバリデーションは、見通しの良さを感じる。 むしろこちらの利点を感じる部分が大きい。 dry-~~ で統一しないならば、json-schema だけ使っても十分ではという結論もあるという感触だった。 この記事をここまで書いて dry-monads と Rails の組み合わせ例を検索してみた。 日本語例も少なく、ここまでに得た所感は一定程度共感が得られるものなのだろう。 では。