
Writing a Domain Model in Ruby Without Using class
From Ruby 3.2, the Data
class has been introduced, allowing us to explore ways of representing domain models without using class
. In this article, I’ll examine how we can do that.
When writing business logic in Ruby, we naturally tend to use class
.
We set attributes in initialize
, and change state via instance methods. Especially if you’ve been writing Rails code, this feels natural.
However, whether managing logic with a stateful class is suitable for every case is debatable. For domain logic where we want to avoid side effects, class-based design might not always be optimal. In recent years, designs influenced by functional programming have gained attention, and as has been discussed on X (formerly Twitter), the mainstream approach in TypeScript is moving toward avoiding classes entirely.
With the Data
class introduced in Ruby 3.2, we can represent domains while keeping structural data and logic separate. In this article, I’ll show you how.
Note: This article focuses on representing domain logic, separate from Rails’ ActiveRecord models.
I’m specifically looking at a style that works purely with plain Ruby syntax, without being tied to the Rails environment. This is driven more by curiosity than by real-world constraints, so please keep that in mind.
What Is the Data
Class?
The Data
class introduced in Ruby 3.2 lets you define immutable data structures easily. It’s similar to the Struct that Ruby already had, but all fields are immutable — to change a value, you must create a new instance.
class Data - Documentation for Ruby
To use a loose analogy: Struct is like a hash, while Data is like a read-only hash.
Let’s actually design a domain model using the Data
class.
Example Domain Model
First, let’s think of an example.
We’ll imagine 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 operations such as:
- Changing the name
- Changing the email address
- Activating/deactivating the customer
- Getting the full name
Especially the first three operations would naturally become methods that change state.
A Class-Based Implementation
First, let’s design it in the usual class-based way. You might find there are too many instance variables, among other nitpicks, but let’s just write it out as a sample.
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
This can be used as follows:
customer = Customer.new(
id: "c1",
email: "[email protected]",
first_name: "Alice",
last_name: "Anderson"
)
customer.change_name("Bob", "Brown")
customer.deactivate
puts customer.full_name # => "Bob Brown"
Here, we create an instance with new
and call methods to change its state — a very common approach.
The Data
Class Available from Ruby 3.2
In Ruby 3.2, the Data
class was introduced.
It feels similar to Struct
, but all fields are immutable.
Customer = Data.define(
:id, :email, :first_name, :last_name,
:is_active, :created_at, :updated_at
)
To change a value, you use .with
to create a new instance.
Suppose customer1
has first_name
set to Bob
:
customer1 = Customer.new(
id: "c1",
email: "[email protected]",
first_name: "Bob",
last_name: "Johnson",
is_active: true,
created_at: Time.now,
updated_at: Time.now
)
To change first_name
to Carol
:
customer2 = customer1.with(first_name: "Carol")
customer1
remains unchanged; customer2
is a new object with only first_name
different.
puts customer1.first_name # => "Bob"
puts customer2.first_name # => "Carol"
With Data.with
, we handle state not by mutation but by replacement, avoiding unintended side effects.
Writing Domain Logic with Data
and Functions
Let’s rewrite the earlier class-based code using Data
.
We’ll define a CustomerService
module, separate from Customer
, to hold our 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
Here, create_customer
is used as a factory in CustomerService
. By defining domain operations as functions, we can handle changes to state in a way that minimizes side effects.
Each function doesn’t change the Customer
data structure directly — it uses .with
to create a new instance. This preserves data immutability and brings the design closer to a functional style.
Also, by using module_function
, we can call these functions without creating an instance of the module. Validation is performed inside the functions where needed.
This cleanly separates data (Customer
) from behavior (functions).
Example Usage
customer = CustomerService.create_customer(
id: "c1",
email: "[email protected]",
first_name: "Alice",
last_name: "Anderson"
)
customer = CustomerService.change_name(customer, first_name: "Bob", last_name: "Brown")
customer = CustomerService.deactivate(customer)
puts CustomerService.full_name(customer) # => "Bob Brown"
Note that when reassigning, we return a new object instead of modifying the original. I considered naming them customer_updated
, customer_updated2
, etc., but left it as-is for now.
By not using instance variables and passing all state through arguments, we avoid side effects. At that point, all that’s left is to store the final state in the database or handle it however needed.
For example, saving with SQLite might look like this:
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
)
You can access attributes with customer.xxx
, so it feels similar to using a class.
Points to Note
Of course, there’s nothing wrong with using class
. For UI logic, controllers, database models like ActiveRecord, and so on, class-based design often makes sense.
In Rails projects, it’s common to extract structured data handling into POROs (Plain Old Ruby Objects) written as classes.
Plain Old Ruby Objects (POROs) in Rails Fat Models - DEV Community
However, as domain logic grows more complex, tracking “state changes” through the code becomes harder. In such cases, a design where “values are immutable and logic is functional” — like the one shown here — can make maintenance easier.
Summary
Ruby is a flexible language, and you can represent domain logic without relying on classes or instance variables.
Ruby 3.2’s Data
class can be a useful tool for designing the domain layer as structured data plus explicit functions. You don’t have to use it for every case, but simply knowing it’s an option can broaden your design possibilities.