Rubyで class を使わずにドメインモデルを書いてみよう
はじめに
Rubyでビジネスロジックを書くとき、自然と class
を使うことが多いと思います。
initialize
で属性を設定し、インスタンスメソッドで状態を変更します。特にRailsを書いていればそれが自然です。
しかし、状態を持ったクラスでロジックを管理するやり方が、すべてのケースに適しているかは分かりません。
最近では、関数型プログラミングの考え方を取り入れた設計が注目されていて、X(旧Twitter)でも盛り上がっていたように、TypeScriptではclassを使わないのが主流になりつつあります。
Ruby 3.2 から導入された Data
クラスを使うことで、構造的なデータとロジックを分離しながらドメインを表現できるようになりました。今回の記事ではその方法を紹介してみます。
Dataクラスとは
Ruby 3.2 で導入された Data
クラスは、イミュータブルなデータ構造を簡単に定義できるクラスです。それ以前にもRubyにあった Struct に似ていますが、すべてのフィールドはイミュータブルであり、値を変更するには新しいインスタンスを生成する必要があります。
雑な喩えになりますが、Structはハッシュ、Dataは変更不可のハッシュみたいな感じです。
実際に Data
クラスを使ってドメインモデルを設計してみましょう。
ドメインモデルの例
まず、ドメインモデルの例を考えてみましょう。
以下のような属性を持つ Customer
モデルを想定します。
id
email
-
first_name
/last_name
is_active
-
created_at
/updated_at
このモデルに対して、以下のように操作を加えられるようにしたいです。
- 氏名の変更
- メールアドレスの変更
- アクティブ/非アクティブ状態の切り替え
- フルネームの取得
特に最初の3つの操作は、状態を変更するためのメソッドになりそうです。
class ベースの実装
まずは、普通にclassを使って設計してみます。インスタンス変数多すぎ……など細かいツッコミどころはあると思いますが、ひとまずサンプルとして書いてみます。
class Customer
attr_reader :id, :email, :first_name, :last_name, :is_active, :created_at, :updated_at
def initialize(id:, email:, first_name:, last_name:, is_active: true)
validate_name!(first_name, last_name)
validate_email!(email)
@id = id
@email = email
@first_name = first_name
@last_name = last_name
@is_active = is_active
@created_at = Time.now
@updated_at = Time.now
end
def change_name(first_name, last_name)
validate_name!(first_name, last_name)
@first_name = first_name
@last_name = last_name
touch
end
def change_email(email)
validate_email!(email)
@email = email
touch
end
def deactivate
return unless @is_active
@is_active = false
touch
end
def activate
return if is_active
@is_active = true
touch
end
def full_name
"#{first_name} #{last_name}"
end
private
def touch
@updated_at = Time.now
end
def validate_name!(first_name, last_name)
if first_name.strip.empty? || last_name.strip.empty?
raise ArgumentError, "Invalid name"
end
end
def validate_email!(email)
unless email.match?(/\A[^@\s]+@[^@\s]+\z/)
raise ArgumentError, "Invalid email format"
end
end
end
もっとうまく書けそうな気もしますが、ひとまずこれで。
このコードを呼び出すときは次のようになります。
customer = Customer.new(
id: "c1",
email: "alice@example.com",
first_name: "Alice",
last_name: "Anderson"
)
customer.change_name("Bob", "Brown")
customer.deactivate
puts customer.full_name # => "Bob Brown"
newでインスタンスを生成し、メソッドを呼び出すことで状態を変更しています。これはよくある作りだと思います。
Data
クラス
Ruby 3.2 から使える Ruby 3.2 では Data
クラスが導入されました。
Struct
に近い書き心地ですが、すべてのフィールドはイミュータブルになっています。
Customer = Data.define(
:id, :email, :first_name, :last_name,
:is_active, :created_at, :updated_at
)
値を変更するには .with
を使って新しいインスタンスを生成します。
たとえば、次のようなコードを考えます。customer1
の first_name
は Bob
だとします。
customer1 = Customer.new(
id: "c1",
email: "bob@example.com",
first_name: "Bob",
last_name: "Johnson",
is_active: true,
created_at: Time.now,
updated_at: Time.now
)
ここで customer1
の first_name
を Carol
に変更したい場合、次のように書きます。
customer2 = customer1.with(first_name: "Carol")
このとき customer1
は一切変わっていません。customer2
は first_name
だけが異なる新しいオブジェクトです。
puts customer1.first_name # => "Bob"
puts customer2.first_name # => "Carol"
Data.withではこのように、値の変更ではなく「差し替え」によって状態を扱うことで、予期せぬ副作用を避けることができます。
Data クラスと関数ベースで書くドメインロジック
先程のclassのコードをDataクラスを使って表現してみましょう。
Customerとは別に、CustomerServiceモジュールを定義します。そこにドメイン操作を関数として定義していきます。
Customer = Data.define(
:id, :email, :first_name, :last_name,
:is_active, :created_at, :updated_at
)
module CustomerService
module_function
def create_customer(id:, email:, first_name:, last_name:)
validate_name!(first_name, last_name)
validate_email!(email)
now = Time.now
Customer.new(
id,
email.strip,
first_name.strip,
last_name.strip,
true,
now,
now
)
end
def change_name(customer, first_name:, last_name:)
validate_name!(first_name, last_name)
customer.with(
first_name: first_name.strip,
last_name: last_name.strip,
updated_at: Time.now
)
end
def change_email(customer, email:)
validate_email!(email)
customer.with(email: email.strip, updated_at: Time.now)
end
def deactivate(customer)
return customer unless customer.is_active
customer.with(is_active: false, updated_at: Time.now)
end
def activate(customer)
return customer if customer.is_active
customer.with(is_active: true, updated_at: Time.now)
end
def full_name(customer)
"#{customer.first_name} #{customer.last_name}"
end
def validate_name!(first_name, last_name)
if first_name.strip.empty? || last_name.strip.empty?
raise ArgumentError, "Invalid name"
end
end
def validate_email!(email)
unless email.match?(/\A[^@\s]+@[^@\s]+\z/)
raise ArgumentError, "Invalid email format"
end
end
end
CustomerService モジュールで create_customer
をファクトリとして使っています。また、ドメイン操作を関数として定義することで、状態の変更を副作用の少ない形で扱えるようになっています。
各関数は Customer データ構造を直接変更するのではなく、with
メソッドを使って新しいインスタンスを生成するため、データの不変性が保たれ、関数型スタイルに近い設計になっています。
データ(Customer)と振る舞い(関数)を明確に分離して記述できました。
呼び出し例も見てみましょう。
customer = CustomerService.create_customer(
id: "c1",
email: "alice@example.com",
first_name: "Alice",
last_name: "Anderson"
)
customer = CustomerService.change_name(customer, first_name: "Bob", last_name: "Brown")
customer = CustomerService.deactivate(customer)
# CustomerService モジュールの関数を使ってfull_nameを取得
puts CustomerService.full_name(customer) # => "Bob Brown"
再代入するときに、元のオブジェクトを変更するのではなく、新しいオブジェクトを返すことに注意してください。customer_updated
, customer_updated2
とかにしようかも迷いましたがひとまずこれでいきます。
インスタンス変数を使わず、すべての状態を引数として渡すことで、副作用を避けることができます。あとはこの最終的な状態をデータベースに保存するなど、必要な処理を行うだけです。
例えばSQLiteを使って保存する場合はこんな感じでしょうか。
db.execute(
"INSERT OR REPLACE INTO customers (id, email, first_name, last_name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
customer.id,
customer.email,
customer.first_name,
customer.last_name,
customer.is_active ? 1 : 0,
customer.created_at.iso8601,
customer.updated_at.iso8601
)
customer.xxx
のようにアクセスできるので、classのような感覚で使えます。
注意点
もちろん class
を使うのが悪いわけではありません。UIロジックやコントローラー、ActiveRecordのようなDBモデルなど、クラスベースがしっくりくる場面も多くあります。
Railsのプロジェクトの場合は、構造化データの処理を切り出す際に、PORO(Plain Old Ruby Object)を classで書くことが一般的です。
モデルに書いていたメソッドをPOROに切り出してみた! - bagelee(ベーグリー)
ただし、ドメインロジックが複雑になるほど「状態の変更」をコードで追うのは難しくなっていきます。そういった場面では、今回のように「値は不変、ロジックは関数」という構成を使ってみると、メンテナンスしやすくなるかもしれません。
まとめ
Rubyは柔軟な言語で、クラスやインスタンス変数を使わない方法でもドメインロジックを表現できます。
Ruby の Data
クラスは、ドメイン層のロジックを、構造化されたデータと明示的な関数で設計するための一つの手段になると思います。Railsの場合だと今回の方法をあまり使う機会はないかもしれませんが、選択肢として知っているだけでも設計の幅が広がるかもです。
Discussion