🧩

Rubyで class を使わずにドメインモデルを書いてみよう

に公開

はじめに

Rubyでビジネスロジックを書くとき、自然と class を使うことが多いと思います。

initialize で属性を設定し、インスタンスメソッドで状態を変更します。特にRailsを書いていればそれが自然です。

しかし、状態を持ったクラスでロジックを管理するやり方が、すべてのケースに適しているかは分かりません。

最近では、関数型プログラミングの考え方を取り入れた設計が注目されていて、X(旧Twitter)でも盛り上がっていたように、TypeScriptではclassを使わないのが主流になりつつあります。

Ruby 3.2 から導入された Data クラスを使うことで、構造的なデータとロジックを分離しながらドメインを表現できるようになりました。今回の記事ではその方法を紹介してみます。

Dataクラスとは

Ruby 3.2 で導入された Data クラスは、イミュータブルなデータ構造を簡単に定義できるクラスです。それ以前にもRubyにあった Struct に似ていますが、すべてのフィールドはイミュータブルであり、値を変更するには新しいインスタンスを生成する必要があります。

https://docs.ruby-lang.org/ja/latest/class/Data.html

class Data (Ruby リファレンスマニュアル)

雑な喩えになりますが、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でインスタンスを生成し、メソッドを呼び出すことで状態を変更しています。これはよくある作りだと思います。

Ruby 3.2 から使える Data クラス

Ruby 3.2 では Data クラスが導入されました。

Struct に近い書き心地ですが、すべてのフィールドはイミュータブルになっています。

Customer = Data.define(
  :id, :email, :first_name, :last_name,
  :is_active, :created_at, :updated_at
)

値を変更するには .with を使って新しいインスタンスを生成します。

たとえば、次のようなコードを考えます。customer1first_nameBob だとします。

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
)

ここで customer1first_nameCarol に変更したい場合、次のように書きます。

customer2 = customer1.with(first_name: "Carol")

このとき customer1 は一切変わっていません。customer2first_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で書くことが一般的です。

https://bagelee.com/programming/poro/

モデルに書いていたメソッドをPOROに切り出してみた! - bagelee(ベーグリー)

ただし、ドメインロジックが複雑になるほど「状態の変更」をコードで追うのは難しくなっていきます。そういった場面では、今回のように「値は不変、ロジックは関数」という構成を使ってみると、メンテナンスしやすくなるかもしれません。

まとめ

Rubyは柔軟な言語で、クラスやインスタンス変数を使わない方法でもドメインロジックを表現できます。

Ruby の Data クラスは、ドメイン層のロジックを、構造化されたデータと明示的な関数で設計するための一つの手段になると思います。Railsの場合だと今回の方法をあまり使う機会はないかもしれませんが、選択肢として知っているだけでも設計の幅が広がるかもです。

Discussion