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.