Classes #

Classes are the foundation of object-oriented programming in Ruby — and Ruby takes OOP more seriously than most languages. In Ruby, everything is an object: integers, strings, nil, and even classes themselves are objects of the Class class. This isn’t just philosophy — it has deep practical implications. You can add methods to existing classes (including built-ins like String and Integer), classes are open to be reopened at any time, and every object can introspect itself. This article covers all aspects of classes in Ruby, from the most basic to the design patterns used in professional codebases.

Defining Classes #

Classes are defined with the class keyword followed by a name in PascalCase. Every class implicitly descends from Object if no inheritance is declared:

class Product
  # class body here
end

# Classes are objects of Class
puts Product.class          # => Class
puts Product.superclass     # => Object
puts Object.superclass      # => BasicObject
puts BasicObject.superclass # => nil (top of the hierarchy)

initialize — The Constructor #

initialize is a special method called automatically when a new object is created with new. This is where instance variables are initialized:

class Product
  def initialize(name, price, stock = 0)
    @name  = name
    @price = price
    @stock = stock
  end

  def info
    "#{@name} — Rp #{@price} (stock: #{@stock})"
  end
end

laptop  = Product.new("Laptop", 15_000_000, 10)
mouse   = Product.new("Mouse", 350_000)          # stock defaults to 0

puts laptop.info   # => Laptop — Rp 15000000 (stock: 10)
puts mouse.info    # => Mouse — Rp 350000 (stock: 0)

Attributes — attr_reader, attr_writer, attr_accessor #

Instance variables can’t be accessed from outside the class directly. Ruby provides three shortcuts for creating getters and setters:

# ANTI-PATTERN: manual getters and setters — verbose and repetitive
class Product
  def name
    @name
  end

  def name=(value)
    @name = value
  end

  def price
    @price
  end
end

# CORRECT: use attr_* to reduce boilerplate
class Product
  attr_reader   :id            # read-only from outside
  attr_writer   :stock         # write-only from outside
  attr_accessor :name, :price  # readable and writable

  def initialize(id, name, price)
    @id    = id
    @name  = name
    @price = price
    @stock = 0
  end

  def info
    "#{@name} — Rp #{@price}"
  end
end

p = Product.new(1, "Keyboard", 450_000)

puts p.id       # => 1          (reader)
puts p.name     # => Keyboard   (reader from accessor)
p.name  = "Mechanical Keyboard" # (writer from accessor)
p.price = 750_000               # (writer from accessor)
p.stock  = 25                   # (writer)

puts p.info     # => Mechanical Keyboard — Rp 750000
puts p.id       # => 1  (can't be changed — reader only)
# p.id = 99    # => NoMethodError: undefined method 'id='

Validation Inside Setters #

One reason not to always use attr_writer directly is that you lose the ability to validate. When you need to validate the value being set, write your own setter:

class Product
  attr_reader :name, :price, :stock

  def initialize(name, price)
    self.name  = name   # use your own setter so validation runs
    self.price = price
    @stock = 0
  end

  def name=(value)
    raise ArgumentError, "Name cannot be empty" if value.to_s.strip.empty?
    @name = value.strip
  end

  def price=(value)
    raise ArgumentError, "Price must be positive" unless value.is_a?(Numeric) && value > 0
    @price = value
  end

  def stock=(value)
    raise ArgumentError, "Stock cannot be negative" if value < 0
    @stock = value
  end
end

p = Product.new("Laptop", 15_000_000)
p.stock = 10
# p.price = -500   # => ArgumentError: Price must be positive
# p.name  = ""     # => ArgumentError: Name cannot be empty

Instance Methods vs Class Methods #

Instance methods are called on objects; class methods are called on the class itself:

class User
  @@count = 0

  attr_reader :name, :email

  def initialize(name, email)
    @name  = name
    @email = email
    @@count += 1
  end

  # Instance method — called on an object
  def introduction
    "Hello, I'm #{@name} (#{@email})"
  end

  def active?
    true   # simplified
  end

  # Class method — called on the User class
  def self.registered_count
    @@count
  end

  # Alternative way to define class methods — class << self
  class << self
    def create_admin(name)
      new(name, "#{name.downcase}@admin.com")
    end

    def create_guest
      new("Guest", "[email protected]")
    end
  end
end

u1 = User.new("Rina", "[email protected]")
u2 = User.new("Budi", "[email protected]")
admin = User.create_admin("SuperAdmin")

puts u1.introduction              # => Hello, I'm Rina ([email protected])
puts User.registered_count        # => 3
puts admin.email                  # => [email protected]
flowchart TD
    A[User class] --> B[Instance Methods\ncalled on objects]
    A --> C[Class Methods\ncalled on the class]
    B --> B1["introduction\nactive?\nto_s"]
    C --> C1["registered_count\ncreate_admin\ncreate_guest"]
    D[Object u1] --> B
    E[User] --> C

Inheritance #

Inheritance lets a child class inherit all methods and attributes of its parent class. Use the < operator:

class Animal
  attr_reader :name, :age

  def initialize(name, age)
    @name = name
    @age  = age
  end

  def breathe
    "#{@name} is breathing..."
  end

  def description
    "#{@name} (#{self.class.name}), #{@age} years old"
  end

  def sound
    raise NotImplementedError, "#{self.class} must implement #sound"
  end
end

class Cat < Animal
  attr_reader :breed

  def initialize(name, age, breed)
    super(name, age)   # call the parent class's initialize
    @breed = breed
  end

  def sound
    "Meow!"
  end

  def description
    "#{super}, breed: #{@breed}"   # call the parent's description then extend
  end
end

class Dog < Animal
  def sound
    "Woof!"
  end

  def fetch(thing)
    "#{@name} fetches the #{thing}!"
  end
end

cat = Cat.new("Mochi", 3, "Persian")
dog = Dog.new("Rex", 5)

puts cat.breathe    # => Mochi is breathing... (inherited)
puts cat.sound      # => Meow!
puts cat.description   # => Mochi (Cat), 3 years old, breed: Persian
puts dog.sound      # => Woof!
puts dog.fetch("ball")  # => Rex fetches the ball!

# Checking inheritance relationships
puts cat.is_a?(Cat)  # => true
puts cat.is_a?(Animal)   # => true
puts cat.is_a?(Dog)  # => false
puts Cat.ancestors.inspect
# => [Cat, Animal, Object, Kernel, BasicObject]

super — Calling the Parent Class’s Method #

super calls the method with the same name from the parent class. There are three forms of usage:

class Vehicle
  def initialize(brand, year)
    @brand = brand
    @year  = year
  end

  def info
    "#{@brand} (#{@year})"
  end
end

class Car < Vehicle
  def initialize(brand, year, doors)
    super(brand, year)   # pass specific arguments to the parent
    @doors = doors
  end

  def info
    super + ", #{@doors} doors"   # call the parent's info, then extend
  end
end

class Motorcycle < Vehicle
  def initialize(brand, year, cc)
    super           # pass ALL arguments to the parent (brand and year)
    @cc = cc
  end

  def info
    "#{super}#{@cc}cc"
  end
end

puts Car.new("Toyota", 2022, 4).info    # => Toyota (2022), 4 doors
puts Motorcycle.new("Honda", 2023, 150).info   # => Honda (2023) — 150cc

Modules and Mixins #

Ruby only allows single inheritance — one class can have only one parent class. But Ruby overcomes this limitation with mixins: the ability to inject methods from a module into a class using include, extend, or prepend.

module Printable
  def print
    puts "--- #{self.class.name} ---"
    instance_variables.each do |var|
      puts "  #{var}: #{instance_variable_get(var)}"
    end
    puts "---"
  end
end

module Serializable
  def to_simple_json
    pairs = instance_variables.map do |var|
      key = var.to_s.delete("@")
      val = instance_variable_get(var)
      "\"#{key}\": #{val.inspect}"
    end
    "{ #{pairs.join(', ')} }"
  end
end

module ComparableByValue
  def same_value_as?(other)
    self.class == other.class &&
      instance_variables.all? do |var|
        instance_variable_get(var) == other.instance_variable_get(var)
      end
  end
end

class Product
  include Printable
  include Serializable
  include ComparableByValue

  attr_accessor :name, :price

  def initialize(name, price)
    @name  = name
    @price = price
  end
end

p1 = Product.new("Mouse", 350_000)
p2 = Product.new("Mouse", 350_000)
p3 = Product.new("Keyboard", 450_000)

p1.print
# --- Product ---
#   @name: "Mouse"
#   @price: 350000
# ---

puts p1.to_simple_json
# { "name": "Mouse", "price": 350000 }

puts p1.same_value_as?(p2)   # => true
puts p1.same_value_as?(p3)   # => false

include vs extend vs prepend #

module Greeting
  def hello
    "Hello from #{self}!"
  end
end

# include — adds methods as INSTANCE methods
class Class1
  include Greeting
end
puts Class1.new.hello   # => Hello from #<Class1:...>

# extend — adds methods as CLASS methods
class Class2
  extend Greeting
end
puts Class2.hello   # => Hello from Class2

# prepend — inserts methods IN FRONT of the class in the method lookup
# module methods are called BEFORE class methods (useful for wrapping/decoration)
module Logger
  def save
    puts "[LOG] Before save"
    result = super
    puts "[LOG] After save"
    result
  end
end

class DataModel
  prepend Logger

  def save
    puts "Saving to database..."
    true
  end
end

DataModel.new.save
# => [LOG] Before save
# => Saving to database...
# => [LOG] After save
includeextendprepend
Method typeInstanceClassInstance
Position in lookupAfter the classBefore the class
Main useShared behaviorClass helpersWrapping/decoration

Open Classes — Reopening Existing Classes #

One of Ruby’s most distinctive — and most controversial — features is the ability to open and add methods to existing classes, including Ruby’s built-ins:

# Add methods to Ruby's built-in Integer class
class Integer
  def seconds
    self
  end

  def minutes
    self * 60
  end

  def hours
    self * 3600
  end

  def days
    self * 86_400
  end

  def in_rupiah
    "Rp #{to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse}"
  end
end

puts 5.minutes     # => 300
puts 2.hours       # => 7200
puts 3.days        # => 259200
puts 1_500_000.in_rupiah  # => Rp 1.500.000

# Add methods to String
class String
  def snake_case
    gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
      .gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
      .downcase
  end

  def pascal_case
    split('_').map(&:capitalize).join
  end

  def blank?
    strip.empty?
  end
end

puts "NamaLengkapPengguna".snake_case   # => nama_lengkap_pengguna
puts "nama_lengkap".pascal_case         # => NamaLengkap
puts "  ".blank?                        # => true
Open classes (also called monkey patching) is a double-edged sword. It makes code more expressive, but adding methods to Ruby’s built-in classes can cause name conflicts with other gems or future Ruby versions. As a safer alternative, use Refinements — a Ruby feature that limits class changes to a specific file or module scope rather than globally.

Comparable — Free Comparison Operators #

By including the Comparable module and defining a single <=> method, your class automatically gets all comparison operators (<, >, <=, >=, between?, clamp):

class Temperature
  include Comparable

  attr_reader :value, :unit

  def initialize(value, unit = :celsius)
    @value = value
    @unit  = unit
  end

  def in_celsius
    case @unit
    when :celsius    then @value
    when :fahrenheit then (@value - 32) * 5.0 / 9
    when :kelvin     then @value - 273.15
    end
  end

  def <=>(other)
    in_celsius <=> other.in_celsius
  end

  def to_s
    "#{@value}°#{@unit.to_s[0].upcase}"
  end
end

boiling   = Temperature.new(100, :celsius)
freezing  = Temperature.new(32, :fahrenheit)   # = 0°C
room      = Temperature.new(300, :kelvin)      # = 26.85°C

puts boiling > room     # => true
puts freezing < room    # => true
puts room.between?(freezing, boiling)  # => true

temps = [boiling, room, freezing]
puts temps.sort.map(&:to_s).inspect
# => ["32°F", "300°K", "100°C"]
puts temps.min    # => 32°F
puts temps.max    # => 100°C

Struct — Simple Data Classes #

Struct is a quick way to create simple data classes without having to write initialize, getters, and setters manually:

# Creating a Struct
Point  = Struct.new(:x, :y)
Color  = Struct.new(:r, :g, :b)
Address = Struct.new(:street, :city, :postal_code, keyword_init: true)

p1 = Point.new(3, 4)
puts p1.x        # => 3
puts p1.y        # => 4
puts p1.to_a     # => [3, 4]
puts p1 == Point.new(3, 4)   # => true (comparison by value!)

# Struct with additional methods
Point = Struct.new(:x, :y) do
  def distance_to(other)
    Math.sqrt((x - other.x)**2 + (y - other.y)**2)
  end

  def to_s
    "(#{x}, #{y})"
  end
end

a = Point.new(0, 0)
b = Point.new(3, 4)
puts a.distance_to(b)   # => 5.0

# keyword_init: true — more expressive when creating instances
address = Address.new(street: "Jl. Sudirman No. 1", city: "Jakarta", postal_code: "10220")
puts address.city   # => Jakarta
When to use Struct vs a regular class:
  Struct:
  ✓ Simple data containers without complex logic
  ✓ Need automatic value comparison (== by value)
  ✓ Want to reduce initialize + attr_accessor boilerplate
  ✓ Immutable value objects

  Regular class:
  ✓ There's validation in setters/initialize
  ✓ There's complex business logic
  ✓ Need detailed visibility control
  ✓ Inheritance from another class

to_s and inspect — Text Representations of Objects #

The two most useful methods for custom classes are to_s (for user-friendly output) and inspect (for debugging output):

class Order
  attr_reader :id, :total, :status

  def initialize(id, total, status = :pending)
    @id     = id
    @total  = total
    @status = status
  end

  # For puts, string interpolation, user-facing output
  def to_s
    "Order ##{@id} — Rp #{@total} [#{@status}]"
  end

  # For debugging, p(), irb
  def inspect
    "#<Order id=#{@id}, total=#{@total}, status=:#{@status}>"
  end
end

order = Order.new(42, 150_000, :processing)

puts order           # => Order #42 — Rp 150000 [processing]
puts "#{order}"      # => Order #42 — Rp 150000 [processing]
p order              # => #<Order id=42, total=150000, status=:processing>
puts order.inspect   # => #<Order id=42, total=150000, status=:processing>

Good Class Design Principles #

flowchart TD
    A[Good class] --> B["Single Responsibility\nOne reason to change"]
    A --> C["Encapsulation\nHide internal details"]
    A --> D["Minimal interface\nOnly expose what's needed"]
    A --> E["Clear initialize\nInitialize all state"]
    B --> B1["Separate Product and ProductRepository"]
    C --> C1["Validate in setters\nnot in callers"]
    D --> D1["Everything internal is private\nPublic is only the required API"]
    E --> E1["Don't leave @var nil\nwithout a clear reason"]
# ANTI-PATTERN: a class that does too much
class User
  attr_accessor :name, :email

  def initialize(name, email)
    @name  = name
    @email = email
  end

  def save_to_database    # ← persistence responsibility
    DB.execute("INSERT INTO users ...")
  end

  def send_welcome_email  # ← email responsibility
    Mailer.send(...)
  end

  def generate_report     # ← reporting responsibility
    # ...
  end
end

# CORRECT: separate responsibilities
class User
  attr_reader :name, :email

  def initialize(name, email)
    self.name  = name
    self.email = email
  end

  private

  def name=(value)
    raise ArgumentError, "Name is required" if value.to_s.strip.empty?
    @name = value.strip
  end

  def email=(value)
    raise ArgumentError, "Invalid email format" unless value.match?(/\A\S+@\S+\z/)
    @email = value.downcase
  end
end

class UserRepository
  def save(user)
    DB.execute("INSERT INTO users (name, email) VALUES (?, ?)", user.name, user.email)
  end
end

class UserWelcome
  def send(user)
    Mailer.send(to: user.email, subject: "Welcome, #{user.name}!")
  end
end

Summary #

  • initialize is the constructor — initialize all instance variables here, using internal setters so validation runs from the start.
  • attr_reader/writer/accessor reduce boilerplate — but write your own setters if you need validation; don’t expose attr_writer outward if not needed.
  • Class methods with self. or class << self — for factory methods, class-level utilities, and shared counters.
  • Single inheritance, many mixins — Ruby allows only one superclass, but modules can be included as many times as needed.
  • include for instance methods, extend for class methods, prepend for wrapping — choose based on need, not habit.
  • Open classes are powerful but dangerous — consider Refinements as a safer alternative for modifying built-in classes.
  • Comparable gives you all comparison operators — just define <=> and include Comparable, the rest is free.
  • Struct for simple value objects — far more concise than writing a manual class, and you get == by value automatically.
  • Define to_s and inspectto_s for user-facing output, inspect for debugging. Without them, the default output isn’t informative.
  • Single Responsibility Principle — separate persistence, email, and business logic into different classes. A good class has one reason to change.

← Previous: Functions   Next: Interfaces →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact