Skip to content

Signal Through the Noise

Honest takes on code, AI, and what actually works

Menu
  • Home
  • My Story
  • Experience
  • Services
  • Contacts
Menu

Ruby’s Building Blocks: When to Use What (And Why)

Posted on January 8, 2026January 8, 2026 by ivan.turkovic

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

AspectStructData
MutabilityMutable by defaultAlways immutable
Ruby versionAll versions3.2+
Use caseTemporary grouping, prototypingValue objects, domain concepts
Thread safetyNeed to be carefulSafe 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:

  1. Will this object have significant behavior? → Class
  2. Do I need validation on initialization? → Class
  3. Will I use inheritance? → Class
  4. Is this a central domain concept? → Class
  5. 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:

  1. Does this operation involve multiple collaborators?
  2. Do I need to inject dependencies for testing?
  3. Is this operation reusable across different contexts?
  4. 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::Handler should live in payments/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

  1. Keep mixins focused. One capability per module.
  2. Document expectations. If you need methods from the including class, document them.
  3. Prefer stateless mixins. Behavior without instance variables is simpler.
  4. Use included hook 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

MechanismMethods becomeTypical use case
includeInstance methodsShared object behavior
extendClass methodsShared class-level behavior
module_functionBoth (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”

  1. Is it truly immutable (conceptually)? → Data (if on Ruby 3.2+)
  2. Am I prototyping or is this temporary? → Struct
  3. Do I need validation or complex initialization? → Class
  4. Will it accumulate significant behavior? → Class

“I need to share behavior”

  1. Is it an “is-a” relationship? → Inheritance (class)
  2. Is it a “has-capability” relationship? → Mixin (module with include)
  3. Are these pure functions with no state? → module_function
  4. Do I need both class and instance methods? → include + extend pattern

“I need to organize code”

  1. Grouping related classes? → Module as namespace
  2. Avoiding name collisions? → Module as namespace
  3. Encapsulating an operation with dependencies? → Service object
  4. 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.

Leave a Reply Cancel reply

You must be logged in to post a comment.

This site uses Akismet to reduce spam. Learn how your comment data is processed.

  • Instagram
  • Facebook
  • GitHub
  • LinkedIn

Recent Posts

  • Ruby’s Building Blocks: When to Use What (And Why)
  • A CTO Would Be Bored by Tuesday
  • What I Wrote About in 2025
  • A Christmas Eve Technology Outlook: Ruby on Rails and Web Development in 2026
  • The Future of Language Frameworks in an AI-Driven Development Era

Recent Comments

  • A CTO Would Be Bored by Tuesday - Signal Through the Noise on Contact Me
  • What I Wrote About in 2025 - Ivan Turkovic on From Intentions to Impact: Your 2025 Strategy Guide (Part 2)
  • From Intentions to Impact: Your 2025 Strategy Guide (Part 2) - Ivan Turkovic on Stop Procrastinating in 2025: Part 1 – Building Your Foundation Before New Year’s Resolutions
  • שמוליק מרואני on Extending Javascript objects with a help of AngularJS extend method
  • thorsson on AngularJS directive multiple element

Archives

  • January 2026
  • December 2025
  • November 2025
  • October 2025
  • September 2025
  • August 2025
  • July 2025
  • May 2025
  • April 2025
  • March 2025
  • January 2021
  • April 2015
  • November 2014
  • October 2014
  • June 2014
  • April 2013
  • March 2013
  • February 2013
  • January 2013
  • April 2012
  • October 2011
  • September 2011
  • June 2011
  • December 2010

Categories

  • AI
  • AngularJS
  • blockchain
  • development
  • ebook
  • Introduction
  • mac os
  • personal
  • personal development
  • presentation
  • productivity
  • ruby
  • ruby on rails
  • sinatra
  • start
  • startup
  • success
  • Uncategorized

Meta

  • Log in
  • Entries feed
  • Comments feed
  • WordPress.org
© 2026 Signal Through the Noise | Powered by Superbs Personal Blog theme