Ruby gives us an abundance of organizational tools. Struct, Data, classes, modules as namespaces, modules as mixins, service objects, and the include/extend/module_function trinity. Each is well-documented individually, but there’s a gap: when and why to choose one over another.
This isn’t about rules. Ruby’s philosophy encourages pragmatism-take what you need and move forward. But pragmatism doesn’t mean arbitrary choices. There’s idiomatic intent behind each construct, and understanding that intent makes your code clearer to others and to your future self.
Ruby is not C++, and we shouldn’t think in C++ limitations. But I also don’t subscribe to the view that we should avoid classes whenever possible. Classes are a powerful tool when used appropriately. The goal is fit-choosing the construct that best expresses your intent.
Struct: The Lightweight Data Container
What It Is
Struct is a class factory. It generates a new class with named attributes, an initializer, accessor methods, equality comparison, and a reasonable to_s. All from a single line.
Person = Struct.new(:name, :email)
alice = Person.new("Alice", "alice@example.com")Code language: PHP (php)
When to Use Struct
1. Quick data grouping without ceremony
When you need to bundle a few related values together and don’t want the overhead of defining a full class:
# Returning multiple related values from a method
Result = Struct.new(:success, :data, :errors)
def process_order(order)
# ... processing logic ...
Result.new(true, processed_order, [])
endCode language: PHP (php)
2. Replacing hashes for clarity
When you find yourself passing hashes with consistent keys, a Struct adds structure and catches typos:
# Instead of this:
config = { host: "localhost", port: 3000, ssl: false }
puts config[:hostt] # Silent nil, typo undetected
# Use this:
Config = Struct.new(:host, :port, :ssl, keyword_init: true)
config = Config.new(host: "localhost", port: 3000, ssl: false)
config.hostt # NoMethodError - typo caught!Code language: PHP (php)
3. Prototyping before committing to a class
Struct is perfect for early-stage code when you’re still discovering what shape your data needs:
# Start here
Coordinate = Struct.new(:lat, :lng)
# Later, if needed, graduate to a class with behaviorCode language: PHP (php)
4. Adding light behavior
Struct accepts a block for adding methods:
Money = Struct.new(:amount, :currency) do
def to_s
"#{currency} #{amount}"
end
def +(other)
raise "Currency mismatch" unless currency == other.currency
Money.new(amount + other.amount, currency)
end
endCode language: PHP (php)
When Struct Stops Being Appropriate
You need immutability. Struct instances are mutable by default:
point = Point.new(1, 2)
point.x = 10 # This works, which might not be what you wantCode language: PHP (php)
You need validation. Struct’s initializer accepts anything:
Person = Struct.new(:name, :age)
Person.new(nil, -5) # No complaintsCode language: PHP (php)
You need complex initialization logic. Custom initialize in a Struct can get awkward.
You’re building a core domain object. If it’s central to your application and will accumulate behavior, start with a class.
The Struct Graduation Moment
When you find yourself:
- Adding more than 3-4 methods to a Struct block
- Needing custom validation
- Wanting to control mutability
- Wishing you could use inheritance properly
It’s time to graduate to a class. There’s no shame in this-Struct served its purpose during exploration.
Data: The Immutable Value Object (Ruby 3.2+)
What It Is
Data is Ruby’s answer to “I want a Struct, but immutable.” Introduced in Ruby 3.2, it creates value objects that cannot be modified after creation.
Point = Data.define(:x, :y)
origin = Point.new(0, 0)
origin.x = 5 # NoMethodError - no setter existsCode language: PHP (php)
When to Use Data
1. When immutability is semantically correct
Some concepts are inherently immutable. A coordinate doesn’t change-you create a new one:
Point = Data.define(:x, :y) do
def translate(dx, dy)
Point.new(x + dx, y + dy) # Returns new instance
end
endCode language: PHP (php)
2. Safe sharing across threads or contexts
Immutable objects can be shared without fear of unexpected modification:
Config = Data.define(:api_key, :timeout, :retries)
APP_CONFIG = Config.new(api_key: "secret", timeout: 30, retries: 3)
# Safe to use from multiple threadsCode language: PHP (php)
3. Hash keys and set members
Data objects make excellent hash keys because their hash value never changes:
Coordinate = Data.define(:lat, :lng)
visited = {}
visited[Coordinate.new(51.5, -0.1)] = "London"Code language: PHP (php)
4. Functional programming patterns
When you’re transforming data through pipelines rather than mutating it:
Order = Data.define(:items, :status) do
def with_status(new_status)
Order.new(items: items, status: new_status)
end
end
order = Order.new(items: [...], status: :pending)
processed = order.with_status(:shipped) # New object, original unchangedCode language: PHP (php)
Data vs Struct: The Decision
| Aspect | Struct | Data |
|---|---|---|
| Mutability | Mutable by default | Always immutable |
| Ruby version | All versions | 3.2+ |
| Use case | Temporary grouping, prototyping | Value objects, domain concepts |
| Thread safety | Need to be careful | Safe by design |
Choose Data when:
- The concept is inherently immutable (coordinates, money, dates)
- You want to enforce immutability at the language level
- You’re on Ruby 3.2+
Choose Struct when:
- You need mutability
- You’re on older Ruby
- You’re prototyping and don’t know the final shape yet
Regular Class: The Full-Powered Tool
When Classes Are The Right Choice
Classes remain the right choice more often than minimalist philosophies might suggest. Here’s when:
1. Complex initialization with validation
class User
attr_reader :email, :name, :created_at
def initialize(email:, name:)
raise ArgumentError, "Invalid email" unless email.include?("@")
raise ArgumentError, "Name required" if name.nil? || name.empty?
@email = email.downcase
@name = name.strip
@created_at = Time.now
freeze # Optional: make immutable after initialization
end
endCode language: CSS (css)
2. Rich behavior that grows with the domain
When an object will accumulate methods as you understand the domain better:
class ShoppingCart
def initialize
@items = []
@discounts = []
end
def add(product, quantity: 1)
@items << CartItem.new(product, quantity)
self
end
def apply_discount(discount)
@discounts << discount
self
end
def subtotal
@items.sum(&:total)
end
def total
@discounts.reduce(subtotal) { |amount, discount| discount.apply(amount) }
end
def empty?
@items.empty?
end
# ... more methods will come as requirements emerge
end
3. When you need inheritance
Struct and Data can be subclassed, but it’s awkward. Classes make inheritance natural:
class Vehicle
attr_reader :make, :model
def initialize(make:, model:)
@make = make
@model = model
end
def description
"#{make} #{model}"
end
end
class Car < Vehicle
attr_reader :doors
def initialize(make:, model:, doors: 4)
super(make: make, model: model)
@doors = doors
end
endCode language: CSS (css)
4. Encapsulation with controlled access
When you need private state and careful public interfaces:
class BankAccount
def initialize(initial_balance)
@balance = initial_balance
@transactions = []
end
def deposit(amount)
raise ArgumentError, "Amount must be positive" unless amount > 0
record_transaction(:deposit, amount)
@balance += amount
end
def balance
@balance.dup # Don't expose internal state directly
end
private
def record_transaction(type, amount)
@transactions << { type: type, amount: amount, at: Time.now }
end
end
The Class Decision Framework
Ask yourself:
- Will this object have significant behavior? → Class
- Do I need validation on initialization? → Class
- Will I use inheritance? → Class
- Is this a central domain concept? → Class
- Do I need private methods or encapsulated state? → Class
Don’t be afraid of classes. They’re not “heavyweight”-they’re expressive. A well-designed class communicates intent clearly.
Single-Purpose Objects (Service Objects)
What They Are
A single-purpose object encapsulates one operation or use case. It typically has:
- A single public method (usually
call) - All dependencies injected
- A focused responsibility
class SendWelcomeEmail
def initialize(mailer: DefaultMailer, logger: Rails.logger)
@mailer = mailer
@logger = logger
end
def call(user)
@logger.info "Sending welcome email to #{user.email}"
@mailer.deliver(
to: user.email,
subject: "Welcome!",
template: :welcome
)
end
end
# Usage
SendWelcomeEmail.new.call(user)Code language: CSS (css)
When Service Objects Add Clarity
1. Complex operations with multiple steps
When an operation involves coordination:
class ProcessOrder
def initialize(payment_gateway:, inventory:, notifier:)
@payment_gateway = payment_gateway
@inventory = inventory
@notifier = notifier
end
def call(order)
charge_result = @payment_gateway.charge(order.total, order.payment_method)
return Result.failure(charge_result.error) unless charge_result.success?
@inventory.reserve(order.items)
@notifier.order_confirmed(order)
Result.success(order)
end
endCode language: CSS (css)
2. Operations that need testing with different dependencies
Service objects make dependency injection natural:
# In tests
fake_mailer = FakeMailer.new
service = SendWelcomeEmail.new(mailer: fake_mailer)
service.call(user)
expect(fake_mailer.sent_emails.size).to eq(1)Code language: PHP (php)
3. Cross-cutting operations that don’t belong to any single model
class ReconcileInventory
def call(warehouse_report, system_records)
discrepancies = []
warehouse_report.each do |item|
system_count = system_records.count_for(item.sku)
if item.count != system_count
discrepancies << Discrepancy.new(item.sku, item.count, system_count)
end
end
discrepancies
end
endCode language: JavaScript (javascript)
When Service Objects Add Unnecessary Abstraction
1. Trivial operations
Don’t create a service object for simple operations:
# Unnecessary abstraction
class UpdateUserName
def call(user, new_name)
user.update(name: new_name)
end
end
# Just do this
user.update(name: new_name)Code language: CSS (css)
2. When the operation clearly belongs to a model
If it’s fundamentally about one object, make it a method on that object:
# Probably unnecessary
class ActivateUser
def call(user)
user.update(active: true, activated_at: Time.now)
end
end
# Better as a method
class User
def activate!
update(active: true, activated_at: Time.now)
end
endCode language: CSS (css)
3. When you’re just wrapping another service
Adding indirection without value:
# Don't do this
class SendEmail
def call(to:, subject:, body:)
ActionMailer::Base.mail(to: to, subject: subject, body: body).deliver_now
end
end
The Service Object Litmus Test
Before creating a service object, ask:
- Does this operation involve multiple collaborators?
- Do I need to inject dependencies for testing?
- Is this operation reusable across different contexts?
- Would putting this in a model make the model too large or unfocused?
If yes to multiple questions, a service object probably helps. If it’s just moving code around, skip it.
Module as Namespace
What It Is
A module used purely for organization-grouping related constants, classes, and methods under a name.
module Payments
class Processor
# ...
end
class Refund
# ...
end
class Receipt
# ...
end
end
# Usage
processor = Payments::Processor.newCode language: JavaScript (javascript)
When to Use Module Namespacing
1. Grouping related classes
When you have a family of classes that work together:
module Reports
class Generator
def initialize(data)
@data = data
end
def as_pdf
Formatters::PDF.new(@data).render
end
def as_csv
Formatters::CSV.new(@data).render
end
end
module Formatters
class PDF
# ...
end
class CSV
# ...
end
end
endCode language: JavaScript (javascript)
2. Avoiding name collisions
When generic names might clash:
module MyApp
class User # Won't conflict with gems that define User
end
class Error < StandardError # Won't conflict with other Error classes
end
endCode language: JavaScript (javascript)
3. Organizing a gem or library
module Stripe
class Customer
end
class Charge
end
class Error < StandardError
end
module Webhook
class Handler
end
end
endCode language: JavaScript (javascript)
4. Module-level utility methods
Using module_function for stateless utilities:
module MathUtils
module_function
def factorial(n)
return 1 if n <= 1
n * factorial(n - 1)
end
def fibonacci(n)
return n if n <= 1
fibonacci(n - 1) + fibonacci(n - 2)
end
end
# Can call directly on module
MathUtils.factorial(5) # => 120Code language: PHP (php)
Namespace Guidelines
- Don’t over-nest. Three levels deep is usually the maximum before navigation becomes painful.
- Match directory structure.
Payments::Webhook::Handlershould live inpayments/webhook/handler.rb. - Use for libraries, be cautious in apps. Deep namespacing in application code can add friction.
Module as Mixin
What It Is
A module that’s included in classes to share behavior. Unlike inheritance, which is “is-a,” mixins are “has-capability.”
module Taggable
def add_tag(tag)
tags << tag unless tags.include?(tag)
end
def remove_tag(tag)
tags.delete(tag)
end
def tagged_with?(tag)
tags.include?(tag)
end
private
def tags
@tags ||= []
end
end
class Article
include Taggable
end
class Photo
include Taggable
endCode language: CSS (css)
When to Use Mixins
1. Sharing behavior across unrelated classes
When multiple classes need the same capability but don’t share an inheritance relationship:
module Slugifiable
def slug
@slug ||= title.downcase.gsub(/\s+/, '-').gsub(/[^\w-]/, '')
end
end
class BlogPost
include Slugifiable
attr_accessor :title
end
class Product
include Slugifiable
attr_accessor :title
endCode language: CSS (css)
2. Extracting cross-cutting concerns
Concerns that appear across your domain:
module Auditable
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def audit_changes(*fields)
@audited_fields = fields
end
def audited_fields
@audited_fields || []
end
end
def save_with_audit
changes = audited_changes
result = save
AuditLog.record(self, changes) if result && changes.any?
result
end
private
def audited_changes
# Compare current state with original
end
endCode language: PHP (php)
3. Composition over inheritance
When you want to compose capabilities rather than build a hierarchy:
module Printable
def print
puts to_s
end
end
module Comparable
def <=>(other)
sort_key <=> other.sort_key
end
end
class Person
include Printable
include Comparable
attr_reader :name, :birth_date
def sort_key
birth_date
end
def to_s
"#{name} (born #{birth_date})"
end
endCode language: HTML, XML (xml)
When Mixins Are Not The Answer
1. When inheritance makes more sense
If there’s a clear “is-a” relationship:
# Don't do this
module AnimalBehavior
def eat; end
def sleep; end
end
class Dog
include AnimalBehavior
end
# Do this
class Animal
def eat; end
def sleep; end
end
class Dog < Animal
endCode language: PHP (php)
2. When the mixin needs too much from the including class
If your mixin assumes many methods exist:
# Problematic - assumes too much
module Publishable
def publish!
validate! # Assumes this exists
set_published_at! # Assumes this exists
notify_subscribers! # Assumes this exists
update_status! # Assumes this exists
end
endCode language: PHP (php)
This creates hidden coupling. The including class must implement a specific interface that isn’t enforced or documented.
3. When state management gets complex
Mixins that manage significant state can create confusion:
# Gets confusing quickly
module StateMachine
def initialize
@state = :initial
@transitions = {}
end
# ... lots of state management
endCode language: CSS (css)
Consider a composed object instead.
Mixin Best Practices
- Keep mixins focused. One capability per module.
- Document expectations. If you need methods from the including class, document them.
- Prefer stateless mixins. Behavior without instance variables is simpler.
- Use
includedhook sparingly. It adds magic that can confuse.
Include, Extend, and Module Function
These three mechanisms serve different purposes. Understanding them prevents confusion.
Include: Instance Methods
include adds module methods as instance methods of the including class.
module Greetable
def greet
"Hello, I'm #{name}!"
end
end
class Person
include Greetable
attr_accessor :name
end
alice = Person.new
alice.name = "Alice"
alice.greet # => "Hello, I'm Alice!"
Person.greet # => NoMethodError
Use include when: You want objects (instances) to have the behavior.
Extend: Class Methods
extend adds module methods as class methods (or singleton methods on any object).
module Findable
def find(id)
all.detect { |record| record.id == id }
end
def all
@records ||= []
end
end
class User
extend Findable
end
User.all # => []
User.find(1) # => nil
user = User.new
user.all # => NoMethodError
Use extend when: You want the class itself to have the behavior.
Combining Include and Extend
A common pattern for modules that provide both:
module Searchable
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def search(query)
all.select { |record| record.matches?(query) }
end
end
# Instance methods
def matches?(query)
searchable_content.include?(query.downcase)
end
def searchable_content
raise NotImplementedError, "Include searchable_content in #{self.class}"
end
end
class Article
include Searchable
attr_accessor :title, :body
def searchable_content
"#{title} #{body}".downcase
end
end
# Now we have both:
Article.search("ruby") # Class method
article.matches?("ruby") # Instance method
Module Function: Both Ways
module_function makes methods callable both on the module directly AND as private instance methods when included.
module Formatting
module_function
def titleize(string)
string.split.map(&:capitalize).join(' ')
end
def truncate(string, length: 50)
return string if string.length <= length
"#{string[0...length]}..."
end
end
# Call on module directly
Formatting.titleize("hello world") # => "Hello World"
# Or include in a class
class Document
include Formatting
def formatted_title
titleize(@title) # Available as private method
end
endCode language: HTML, XML (xml)
Use module_function when: You have pure utility functions that might be called directly on the module OR mixed into classes.
The Quick Reference
| Mechanism | Methods become | Typical use case |
|---|---|---|
include | Instance methods | Shared object behavior |
extend | Class methods | Shared class-level behavior |
module_function | Both (module + private instance) | Pure utilities |
Modern Idiom: ActiveSupport::Concern
Rails popularized a cleaner pattern for combined include/extend:
module Searchable
extend ActiveSupport::Concern
included do
scope :search, ->(query) { where("content LIKE ?", "%#{query}%") }
end
class_methods do
def most_searched
# ...
end
end
# Instance methods go directly in module body
def matches?(query)
content.include?(query)
end
endCode language: PHP (php)
This is syntactic sugar but widely understood in the Rails world.
Decision Framework: Bringing It All Together
When starting with a new abstraction, walk through these questions:
“I need to group some data”
- Is it truly immutable (conceptually)? →
Data(if on Ruby 3.2+) - Am I prototyping or is this temporary? →
Struct - Do I need validation or complex initialization? → Class
- Will it accumulate significant behavior? → Class
“I need to share behavior”
- Is it an “is-a” relationship? → Inheritance (class)
- Is it a “has-capability” relationship? → Mixin (module with
include) - Are these pure functions with no state? →
module_function - Do I need both class and instance methods? →
include+extendpattern
“I need to organize code”
- Grouping related classes? → Module as namespace
- Avoiding name collisions? → Module as namespace
- Encapsulating an operation with dependencies? → Service object
- Simple operation on one object? → Method on that object
“I’m not sure”
Start simple. It’s easier to graduate from Struct to class than to simplify an over-engineered class. Ruby’s flexibility means you can refactor as understanding grows.
Conclusion: Fit Over Fashion
Ruby gives you many tools because different situations call for different solutions. The goal isn’t to use the “best” tool in some abstract sense-it’s to use the tool that best fits your current situation.
- Struct for quick data grouping and prototyping
- Data for immutable value objects
- Class for domain objects with behavior, validation, and growth potential
- Service objects for complex operations with multiple collaborators
- Module namespaces for organization and collision avoidance
- Module mixins for sharing capabilities across unrelated classes
- include/extend/module_function for controlling how shared behavior manifests
Don’t fear classes because someone said they’re “heavy.” Don’t create service objects because it’s “the pattern.” Don’t use mixins when inheritance is clearer.
Choose based on intent. Your code should communicate not just what it does, but why it’s structured the way it is. That’s the Ruby way: pragmatic, expressive, and always in service of clarity.
What patterns have you found most useful? I’d love to hear how you make these decisions in your own code.