iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🧩

Writing Domain Models in Ruby Without Using Classes

に公開

Introduction

When writing business logic in Ruby, I think it's often natural to use class.

You set attributes in initialize and change the state using instance methods. This feels especially natural if you are writing Rails.

However, it's not always clear if managing logic through classes with state is suitable for every case.

Recently, designs incorporating functional programming concepts have been gaining attention. As seen in the discussions on X (formerly Twitter), avoiding the use of class is becoming mainstream in TypeScript.

The Data class introduced in Ruby 3.2 allows for domain representation while separating structural data and logic. In this article, I will try to introduce how to do that.

What is the Data Class?

The Data class, introduced in Ruby 3.2, is a class that allows you to easily define immutable data structures. It is similar to Struct, which has existed in Ruby before, but all fields are immutable, and you must create a new instance to change values.

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

class Data (Ruby Reference Manual)

To use a rough analogy, if Struct is like a Hash, Data is like an immutable Hash.

Let's actually try designing a domain model using the Data class.

Example Domain Model

First, let's consider an example of a domain model.

Suppose a Customer model with the following attributes:

  • id
  • email
  • first_name / last_name
  • is_active
  • created_at / updated_at

We want to be able to perform the following operations on this model:

  • Changing the name
  • Changing the email address
  • Toggling active/inactive state
  • Getting the full name

Specifically, the first three operations seem likely to become methods for changing state.

Implementation Based on a Class

First, let's design it using a standard class. There might be some minor points to nitpick, such as having too many instance variables, but let's write it as a sample for now.

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

I feel like it could be written better, but let's go with this for now.

When calling this code, it would look like this:

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"

We create an instance with new and change the state by calling methods. I think this is a common structure.

The Data Class Available from Ruby 3.2

The Data class was introduced in Ruby 3.2.

It feels similar to Struct to write, but all fields are immutable.

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

To change values, you create a new instance using .with.

For example, consider the following code. Let's say customer1 has first_name as 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
)

If you want to change customer1's first_name to Carol here, you would write it like this:

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

At this point, customer1 has not changed at all. customer2 is a new object with only the first_name being different.

puts customer1.first_name  # => "Bob"
puts customer2.first_name  # => "Carol"

By handling state through "replacement" rather than value modification using Data.with, you can avoid unexpected side effects.

Domain Logic with Data Class and Function-Based Design

Let's express the previous class-based code using the Data class.

We will define a CustomerService module separately from Customer. There, we will define domain operations as functions.

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

In the CustomerService module, create_customer is used as a factory. Additionally, by defining domain operations as functions, state changes can be handled with fewer side effects.

Each function does not directly modify the Customer data structure but instead creates a new instance using the with method. This maintains data immutability and leads to a design closer to a functional style.

Data (Customer) and behavior (functions) are now clearly separated.

Let's look at an example of how to call it.

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)

# Get full_name using the function in CustomerService module
puts CustomerService.full_name(customer) # => "Bob Brown"

Note that when reassigning, it returns a new object instead of modifying the original one. I considered using names like customer_updated or customer_updated2, but let's go with this for now.

By passing all state as arguments without using instance variables, side effects can be avoided. All that's left is to perform necessary processing, such as saving this final state to a database.

For example, it might look like this when saving using 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
)

Since you can access attributes like customer.xxx, it feels similar to using a class.

Considerations

Of course, using class is not a bad thing. There are many scenarios where a class-based approach feels natural, such as UI logic, controllers, and database models like ActiveRecord.

In Rails projects, it is common to write POROs (Plain Old Ruby Objects) as classes when extracting structural data processing.

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

I tried extracting methods from models into POROs! - bagelee

However, the more complex the domain logic becomes, the harder it is to track "state changes" in the code. In such scenarios, using a configuration like the one introduced here—where "values are immutable and logic consists of functions"—might lead to better maintainability.

Summary

Ruby is a flexible language, and domain logic can be expressed even in ways that do not use classes or instance variables.

I believe Ruby's Data class can be one means of designing domain-layer logic with structured data and explicit functions. In the case of Rails, there might not be many opportunities to use this method, but just knowing it as an option could broaden the scope of your design.

Discussion