When you start reading Rails code, plain Ruby scripts, or Ruby AI libraries, you hit object-oriented code almost immediately.
You see classes. Modules. Inheritance. include. extend. Maybe a service object or two.
If you come from Python or JavaScript, Ruby’s object model feels familiar in places and weird in others. The good news: you do not need every edge case to be productive. You need enough to read code, structure code, and avoid writing a mess.
Let’s build exactly that.
Start with a class
A class is a blueprint for objects. Objects hold state and behavior.
class PromptTemplate
def initialize(name, template)
@name = name
@template = template
end
def render(input)
@template.gsub("{{input}}", input)
end
end
summarizer = PromptTemplate.new(
"summary",
"Summarize this text in 3 bullets: {{input}}"
)
puts summarizer.render("Ruby is elegant and productive.")
Output:
Summarize this text in 3 bullets: Ruby is elegant and productive.
A few Ruby basics are doing work here:
-
initializeruns when you call.new - instance variables start with
@ - methods are defined with
def - the last expression is returned automatically
That implicit return is a very Ruby thing. Don’t fight it.
Add readers and writers with attr_*
You often want controlled access to object state.
class ModelConfig
attr_reader :provider, :model
attr_accessor :temperature
def initialize(provider, model, temperature = 0.2)
@provider = provider
@model = model
@temperature = temperature
end
end
config = ModelConfig.new("openai", "gpt-4.1-mini")
puts config.provider
puts config.model
puts config.temperature
config.temperature = 0.7
puts config.temperature
attr_reader creates getter methods.
attr_accessor creates both getter and setter methods.
There is also attr_writer, which only creates a setter.
In Rails code, this pattern is everywhere.
Use classes to model real application concepts
Suppose you are building a tiny AI feature that scores whether a support ticket needs escalation.
class Ticket
attr_reader :title, :body, :priority
def initialize(title:, body:, priority: "normal")
@title = title
@body = body
@priority = priority
end
def urgent?
priority == "high"
end
end
class EscalationClassifier
def initialize(ticket)
@ticket = ticket
end
def classify
return "urgent" if @ticket.urgent?
return "urgent" if @ticket.body.downcase.include?("production down")
"normal"
end
end
ticket = Ticket.new(
title: "Login issue",
body: "Customer says production down in Europe",
priority: "normal"
)
classifier = EscalationClassifier.new(ticket)
puts classifier.classify
This is already cleaner than passing hashes around forever.
The Ticket class owns ticket-related behavior.
The EscalationClassifier class owns classification behavior.
That separation matters once your codebase grows.
Inheritance: one class builds on another
Inheritance lets a subclass reuse behavior from a parent class.
class LLMClient
def initialize(api_key)
@api_key = api_key
end
def headers
{
"Authorization" => "Bearer #{@api_key}",
"Content-Type" => "application/json"
}
end
end
class OpenAIClient < LLMClient
def endpoint
"https://api.openai.com/v1/chat/completions"
end
end
client = OpenAIClient.new("secret-key")
p client.headers
puts client.endpoint
OpenAIClient inherits from LLMClient, so it gets the headers method automatically.
This is useful when several classes share a stable core.
But don’t get carried away. Deep inheritance trees become annoying fast. In modern Ruby and Rails code, composition and mixins are often preferred over “class hierarchy soup.”
Override methods and call super
A subclass can replace a parent method and still reuse part of the original with super.
class BaseMessageFormatter
def format(message)
"[base] #{message.strip}"
end
end
class ChatMessageFormatter < BaseMessageFormatter
def format(message)
"[chat] #{super}"
end
end
formatter = ChatMessageFormatter.new
puts formatter.format(" Hello from the model ")
Output:
[chat] [base] Hello from the model
You will see super in Rails controllers, models, and framework internals.
Modules group shared behavior
A module is not instantiable. You use it to organize methods, constants, or shared behavior.
module JsonRenderable
def as_json
instance_variables.each_with_object({}) do |ivar, hash|
key = ivar.to_s.delete("@")
hash[key] = instance_variable_get(ivar)
end
end
end
class EmbeddingResult
include JsonRenderable
def initialize(id, score)
@id = id
@score = score
end
end
result = EmbeddingResult.new("doc_42", 0.91)
p result.as_json
Output:
{"id"=>"doc_42", "score"=>0.91}
Here the module gives reusable behavior to any class that includes it.
Mixins: Ruby’s practical superpower
When you include a module, its instance methods become available in the class.
module Retryable
def with_retries(max_attempts: 3)
attempts = 0
begin
attempts += 1
yield
rescue => e
retry if attempts < max_attempts
raise e
end
end
end
class EmbeddingFetcher
include Retryable
def fetch
with_retries(max_attempts: 2) do
puts "Fetching embeddings..."
# pretend network request happens here
"ok"
end
end
end
puts EmbeddingFetcher.new.fetch
This is a mixin. It’s a way to share behavior without forcing everything into inheritance.
Rails uses this style heavily.
If you understand include SomeModule, a lot of Rails code suddenly becomes much less mysterious.
include vs extend
This trips people up once, then it’s easy.
-
includeadds module methods as instance methods -
extendadds module methods as class methods to a specific object or class
module Slugifiable
def slugify(text)
text.downcase.gsub(/\s+/, "-")
end
end
class Article
extend Slugifiable
end
puts Article.slugify("Ruby For AI")
If you used include Slugifiable, you would call slugify on an instance instead.
Composition beats cleverness
A good Ruby codebase usually has small classes with one clear job.
For AI work, that often means classes like:
PromptBuilderChatClientEmbeddingIndexerDocumentChunkerResponseValidator
That is better than one giant AIService class doing everything.
Here is a simple composed example:
class PromptBuilder
def build(question)
"Answer this clearly for a beginner: #{question}"
end
end
class FakeLLM
def call(prompt)
"MODEL RESPONSE: #{prompt}"
end
end
class ChatService
def initialize(prompt_builder:, llm:)
@prompt_builder = prompt_builder
@llm = llm
end
def answer(question)
prompt = @prompt_builder.build(question)
@llm.call(prompt)
end
end
service = ChatService.new(
prompt_builder: PromptBuilder.new,
llm: FakeLLM.new
)
puts service.answer("What is a mixin?")
That is easy to test, easy to swap, and easy to grow.
What to remember
If you remember nothing else, remember this:
- classes create objects
-
initializesets up state - instance variables use
@ -
attr_readerand friends generate getters/setters - inheritance reuses parent behavior
- modules group shared behavior
- mixins with
includeare common and important - composition is usually cleaner than deep inheritance
That’s enough to read a lot of real Ruby and Rails code without panicking.
Try this next
Save this as oop_ruby.rb and run it:
ruby oop_ruby.rb
Then experiment:
- add a
SystemPromptBuildersubclass - create a
Loggablemodule and mix it into multiple classes - add a second LLM client and inject it into
ChatService
That kind of small practice is how this stuff becomes natural.
Next up, we’ll cover Ruby patterns you’ll keep seeing in serious code: procs, lambdas, closures, and the enumerable tricks that make Ruby feel like Ruby.
Top comments (0)