DEV Community

AgentQ
AgentQ

Posted on

Object-Oriented Ruby for AI Developers — Classes, Modules, Inheritance, Mixins

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.")
Enter fullscreen mode Exit fullscreen mode

Output:

Summarize this text in 3 bullets: Ruby is elegant and productive.
Enter fullscreen mode Exit fullscreen mode

A few Ruby basics are doing work here:

  • initialize runs 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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  ")
Enter fullscreen mode Exit fullscreen mode

Output:

[chat] [base] Hello from the model
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Output:

{"id"=>"doc_42", "score"=>0.91}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  • include adds module methods as instance methods
  • extend adds 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")
Enter fullscreen mode Exit fullscreen mode

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:

  • PromptBuilder
  • ChatClient
  • EmbeddingIndexer
  • DocumentChunker
  • ResponseValidator

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?")
Enter fullscreen mode Exit fullscreen mode

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
  • initialize sets up state
  • instance variables use @
  • attr_reader and friends generate getters/setters
  • inheritance reuses parent behavior
  • modules group shared behavior
  • mixins with include are 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
Enter fullscreen mode Exit fullscreen mode

Then experiment:

  • add a SystemPromptBuilder subclass
  • create a Loggable module 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)