GuideRailというimmutable value objectをうまく使うためのgemを作ってみた
作ったもの
以下、ここのところ考えていたこととか、なんでこういうものを作りたかったか、動機の部分を掘り下げていこうと思う。
Dataをもっとうまく使いたい
2週間ほど前(2025-06-22)に Perplexity + Gemini 2.5 Pro 06-05 と議論してたんだけど、
- Data で ViewModel 作ったら面白いんじゃない?
- ViewComponent はいいんだけど Rails 専用だし、render の部分を取り除いた純粋な ViewModel ならイケないか?
ということを考えていた。
View の問題としては以下のようなものを想定している。
- nullable なデータおよび attribute で例外が発生する可能性
- そもそも目的のデータが nil である場合1
- 期待している attribute が nil でかつ何かフィルタ的なものを適用する場合
- 個々の attribute の値の状態に応じた場合分けで荒れがち
また、View に例えば ActiveRecord が直接露出していると
- 副作用が View から生まれる
のも一人でカウボーイスタイルでやるならともかく、引き継ぎ前提で寿命の長いコードにしたいなら避ける方が懸命と言える。
標準の Data の機能だけではこれらの問題は解消できないが、次に挙げる Schema Validation をうまく組み合わせればイケるか?と考えていた。
また以前 dry-operation の結果を表現する際に
dry-operation + dry-monads + ActiveModelほかいくつかの処理を交えていい具合にViewに返す試み
View 側で errors の正体が ActiveModel でも Dry::Schema でも違いを吸収できるようにしているんだけど、これを View の中じゃなくて View に入れる前にいい具合にできたらなおよいよね?と考えている。
Schema Validationの考え方でデータの「意味」を重視したい
これはイメージとしては以下のようなものを考えていた。例えば以下のような Model があったとして、
class BlogEntry
include ActiveModel::API
include ActiveModel::Attributes
attribute :id, :big_integer
attribute :title, :string
attribute :body, :string
attribute :tags
attribute :not_permitted, :boolean
end
- この BlogEntry を公開(publish)してよいか?
というメソッドは Rubyist 的には考えやすいと思う。 #publishable?
というメソッドを用意すればよい。こんな感じだ。
class BlogEntry
def publishable?
@title.size > 0 && @body.size > 0 && @tags.size > 0 && !@not_permitted
end
end
ただこれは、公開してよいか否かの判定しかしてくれない。「公開可能なデータ構造がどんなものかを静的に解決してくれない」ので、どの attribute については安心してアクセスしてよいかが分からない。こうなるとロジックの構築や View で表示処理を書く際に生々しくエラーチェックする必要があり、特に View は見通しが悪くなるし、繊細なコードになりやすい。
こういう時に便利なのが Schema Validation であり、静的型付けなんじゃないかと考えた。要は以前触れた Valibot や、これの元ネタになった Zod とか、そういうやつだ。
面倒くさがり屋のためのTypeScript環境 (2024-11-30) | あーありがち
Ruby である程度手軽に使えてメジャーな schema validator と言うと dry-schema だろう。
dry-rb - dry-schema - Introduction
上の BlogEntry の例だと以下のように書ける。
BlogEntryPublishableSchema = Dry::Schema.Params do
required(:id).filled(:integer)
required(:title).filled(:string)
required(:body).filled(:string)
required(:not_permitted).maybe(:false)
end
result = BlogEntryPublishableSchema.call(blog_entry.attributes)
これで result.success?
なら blog_entry は確実に publishable なデータ、ということになり、ここで required で filled になっている attribute は nil の心配がないことが保証される。逆に result.failure?
の場合は何が条件を満たしていないかを errors のメッセージで理解できる。
つまり
- ActiveRecord の validation を状況に応じて使い分けられるようなもの
- ActiveRecord に依存せずに利用できる
- 名前を付けて意味を明示できる
というスグレモノなのだ。
そしてこの schema をパスしたデータが
BlogEntryPublishable = Data.define(:id, :title, :body, :tags)
として利用できれば、非常に意味が明確になるし、Data のサブクラスの方にちゃんと RBS を書いてあげれば静的解析も可能になるわけだ。悪くないんじゃない?
どう組み合わせるのか?
Schema Validation や静的型付けと immutable な Value Object は TypeScript などの世界観で言うと最近は割とポピュラーだとは思うんだけど、我らが Ruby では
- 3.0 で RBS
- 3.2 でようやく標準の immutable object
が登場したくらいで、基本的には分が悪い状況にあると思う。
Data も、標準の使い方は Struct みたいな感じで。
Klass = Data.define(:key1, :key2)
Klass.new(key1: "a", key2: 1)
みたいな感じになっていて、これだけではあまり複雑なルールを設定できないうえ、ちゃんとサブクラスを定義したうえで使わないと全部
#<data key1="a" key2=1>
みたいになってしまって嬉しくない。
dry-schema と組み合わせれば複雑なものにも耐えられるようになるだろうけど、この書き方が人によって違うとすると、読み手の負荷になるし、バグの温床になりそうな気配がぷんぷんする。
Creator Objectを作ってみた
というわけでこの取り回しにルールを設けてみた。基本的な使い勝手は以下のような感じになる。
require "guide-rail"
class BlogEntry
include ActiveModel::API
include ActiveModel::Attributes
attribute :id, :big_integer
attribute :title, :string
attribute :body, :string
attribute :tags
attribute :not_permitted, :boolean
end
BlogEntryPublishableSchema = Dry::Schema.Params do
required(:id).filled(:integer)
required(:title).filled(:string)
required(:body).filled(:string)
required(:not_permitted).maybe(:false)
end
class BlogEntryPublishableCreator
extend GuideRail
schema BlogEntryPublishableSchema
class_name :BlogEntryPublishable
end
BlogEntryPublishableCreatore.from(blog_entry) # => #<data BlogEntryPublishable>
GuideRail という module で class method を定義してるだけ。なんとなく DSL っぽく書ける「あの感じ」を意識している。
で、
Rubyなのでclassであり単なるschemaやtypeではないので、もっと便利にならないか?
と思うわけです。思うわけですよ。というわけで以下のような機能を持たせてみた。
- Hash に変換可能なオブジェクトを受け付けることができる(attributes や to_h, to_hash メソッドに期待している)
- nullable な値も renderable にしたいので empty string で埋める機能
- Data.define にそのまま渡せる block を書けるので decorator みたいなものも持たせられる(何かの単位の変換とか prefix, suffix を付加するとか)
- Dry::Monads::Result を返す機能。処理の流れを関数型で書ける。
まだ errors の統合や i18n の機能をどうするか考えてないんだけど、まぁまぁイケてるんじゃないか?と思っている。
ActiveRecordで例外が起きないように検索してnilが返ってきてるパターンとか ↩