Ruby on Rails Zero to Advanced #3: Ruby OOP, Classes & Modules
Every Rails app is built from classes: class Article < ApplicationRecord, class ArticlesController < ApplicationController, class SendWelcomeEmailJob < ApplicationJob. Before we install Rails in part 4, let's make sure Ruby's object-oriented features feel natural: classes, instance variables, accessors, inheritance, private methods, and modules used as namespaces and mixins.
Classes, objects and instance variables
A class is a blueprint; an object is one instance of it. initialize runs when you call .new, and variables starting with @ are instance variables, the data each object carries.
class User
attr_reader :name, :email
attr_accessor :role
def initialize(name, email, role: "member")
@name = name
@email = email
@role = role
end
def admin?
role == "admin"
end
def to_s
"#{name} <#{email}>"
end
end
budi = User.new("Budi", "budi@example.com")
budi.name
budi.admin?
budi.role = "admin"
budi.admin?
puts budi
Output:
=> :to_s
=> #<User:0x000000011f2b6e40 @name="Budi", @email="budi@example.com", @role="member">
=> "Budi"
=> false
=> "admin"
=> true
Budi <budi@example.com>
=> nil
attr_reader, attr_writer and attr_accessor generate getter and/or setter methods for you. Because we only declared a reader for email, assigning it fails:
budi.email = "new@example.com"
Output:
(irb):1:in '<main>': undefined method 'email=' for an instance of User (NoMethodError)
Defining to_s controls how puts and interpolation display the object. Active Record models generate readers and writers for every database column in exactly the same spirit.
Class methods and self
Methods defined with self. belong to the class itself, not to its instances. Rails' Article.find(1) and User.where(...) are class methods.
class User
def self.from_hash(attrs)
new(attrs[:name], attrs[:email])
end
end
andi = User.from_hash(name: "Andi", email: "andi@example.com")
andi.to_s
Output:
=> :from_hash
=> #<User:0x000000011f3c1a28 @name="Andi", @email="andi@example.com", @role="member">
=> "Andi <andi@example.com>"
Notice we wrote class User a second time. Ruby classes are open: reopening a class adds methods to it instead of replacing it. Rails itself uses this to add methods such as "".blank? to core classes.
Inheritance and super
A subclass inherits everything from its parent and can override methods. super calls the parent's version of the current method.
class Admin < User
def initialize(name, email)
super(name, email, role: "admin")
end
def to_s
"[ADMIN] #{super}"
end
end
siti = Admin.new("Siti", "siti@example.com")
puts siti
siti.admin?
siti.is_a?(User)
Admin.superclass
Admin.ancestors.take(3)
Output:
[ADMIN] Siti <siti@example.com>
=> nil
=> true
=> true
=> User
=> [Admin, User, Object]
This is exactly what happens with class Article < ApplicationRecord: your model inherits hundreds of methods (save, update, destroy...) from Active Record.
Private methods
Methods below the private keyword can only be called from inside the object. Use them for internal helpers. In controllers, you will put things like article_params under private.
class BankAccount
attr_reader :balance
def initialize(balance = 0)
@balance = balance
end
def withdraw(amount)
raise ArgumentError, "Insufficient balance" unless enough?(amount)
@balance -= amount
end
private
def enough?(amount)
amount <= balance
end
end
account = BankAccount.new(100_000)
account.withdraw(30_000)
account.enough?(5)
account.withdraw(500_000)
Output:
=> 70000
(irb):1:in '<main>': private method 'enough?' called for an instance of BankAccount (NoMethodError)
(irb):1:in 'BankAccount#withdraw': Insufficient balance (ArgumentError)
Modules: namespaces and mixins
Modules cannot be instantiated. They have two jobs. First, as a namespace that groups related classes and constants:
module Billing
TAX_RATE = 0.11
class Invoice
def initialize(amount)
@amount = amount
end
def total
(@amount * (1 + TAX_RATE)).round
end
end
end
Billing::Invoice.new(100_000).total
Billing::TAX_RATE
Output:
=> 111000
=> 0.11
Second, as a mixin: include copies a module's methods into a class as instance methods, while extend adds them as class methods. Ruby's built-in Comparable is a great example: define <=> and you get >, <, between?, min, sort and more for free.
module Searchable
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def search_label
"Searching #{name.downcase}s"
end
end
def matches?(keyword)
to_s.downcase.include?(keyword.downcase)
end
end
class Product
include Comparable
include Searchable
attr_reader :name, :price
def initialize(name, price)
@name = name
@price = price
end
def <=>(other)
price <=> other.price
end
def to_s
"#{name} (Rp#{price})"
end
end
coffee = Product.new("Iced Coffee", 25_000)
tea = Product.new("Tea", 15_000)
coffee > tea
[coffee, tea].sort.map(&:name)
coffee.matches?("coffee")
Product.search_label
Product.ancestors.take(4)
Output:
=> true
=> ["Tea", "Iced Coffee"]
=> true
=> "Searching products"
=> [Product, Searchable, Comparable, Object]
The self.included hook plus extend(ClassMethods) pattern is so common that Rails wraps it in ActiveSupport::Concern. In a Rails app the same module would live in app/models/concerns/searchable.rb:
module Searchable
extend ActiveSupport::Concern
class_methods do
def search_label
"Searching #{name.downcase}s"
end
end
def matches?(keyword)
to_s.downcase.include?(keyword.downcase)
end
end
# app/models/product.rb
class Product < ApplicationRecord
include Searchable
end
Output (in rails console):
store(dev)> Product.search_label
=> "Searching products"
Common mistakes
- Forgetting the @. Inside
initialize,name = namejust creates a local variable. You need@name = name. - Calling a setter without self. Inside a method,
role = "admin"creates a local variable. Writeself.role = "admin"to call the setter. This bites many Rails beginners in callbacks. - Deep inheritance trees. Prefer small modules (concerns) for shared behaviour over long chains of subclasses.
- Making everything public. Keep helpers private so the public interface of your class stays small and clear.
- Naming. Class and module names are
CamelCase, and in Rails the file name must match:BankAccountlives inbank_account.rb, otherwise autoloading fails.
Exercise
Create a Course class with title and price, and an enroll(student_name) method that stores students in an array. Add a full? method that returns true at 3 students. Then make Course include Comparable by price.
class Course
include Comparable
attr_reader :title, :price, :students
def initialize(title, price)
@title = title
@price = price
@students = []
end
def enroll(student_name)
return "Course is full" if full?
students << student_name
"#{student_name} enrolled in #{title}"
end
def full?
students.size >= 3
end
def <=>(other)
price <=> other.price
end
end
rails = Course.new("Rails Basics", 300_000)
%w[Budi Siti Andi Dewi].each { |name| puts rails.enroll(name) }
p rails > Course.new("Ruby Intro", 150_000)
Output:
Budi enrolled in Rails Basics
Siti enrolled in Rails Basics
Andi enrolled in Rails Basics
Course is full
true
Summary
Classes bundle data (instance variables) and behaviour (methods), accessors generate getters and setters, inheritance and super let you reuse and extend behaviour, private hides helpers, and modules work as namespaces and mixins. You now have all the Ruby you need to read Rails code comfortably. In part 4 we finally install Rails, create our first app and explore its folder structure and the MVC pattern.
Related articles
PostgreSQL Zero to Advanced #1: Introduction & Setup
Start the series by understanding what a relational database is, installing PostgreSQL locally, and getting comfortable with psql commands like \l, \c and \dt.
PHP Zero to Advanced #1: Introduction & Setup
Start your PHP journey: what PHP is, how to install PHP 8.2+, run the built-in development server, and write your very first script with echo.
Python Zero to Advanced #1: Introduction & Setup
Start your Python journey: what Python is, how to install it, how to use the REPL, print your first output, and run .py files from the terminal.