iTranslated by AI
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.
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:
idemail-
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.
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