GuideRailというimmutable value objectをうまく使うためのgemを作ってみた

作ったもの

wtnabe/guide-rail: A opinionated factory library to create safe, immutable Data objects from various sources using dry-schema and Data ( Ruby 3.2+ ).

以下、ここのところ考えていたこととか、なんでこういうものを作りたかったか、動機の部分を掘り下げていこうと思う。

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 の機能をどうするか考えてないんだけど、まぁまぁイケてるんじゃないか?と思っている。

  1. ActiveRecordで例外が起きないように検索してnilが返ってきてるパターンとか 

More