Metaprogramming and building DSLs
define_method, send and public_send, method_missing with respond_to_missing?, class macros, refinements, and when to stop.
Dynamic methods
class Report
ATTRS = %i[title author total].freeze
ATTRS.each do |attr|
define_method(attr) { instance_variable_get("@#{attr}") }
define_method("#{attr}=") { |value| instance_variable_set("@#{attr}", value) }
end
def method_missing(name, *args, &block)
if name.to_s.start_with?("find_by_")
field = name.to_s.delete_prefix("find_by_")
return nil unless ATTRS.include?(field.to_sym)
@records.find { |r| r.public_send(field) == args.first }
else
super
end
end
def respond_to_missing?(name, include_private = false)
name.to_s.start_with?("find_by_") || super
end
end
report = Report.new
report.public_send(:title=, "Q3")
report.respond_to?(:find_by_author) # => truedefine_methodtakes a block, so it can close over locals - unlikeclass_evalwith a string, which does not and is also a code-injection risk.- Always pair
method_missingwithrespond_to_missing?, or duck typing, mocking andrespond_to?all lie. method_missingis slower and harder to debug than a real method; generate methods withdefine_methodin a loop when the names are known.public_sendrespects visibility;sendbypasses it and should be reserved for tests or deliberate internals.
💡
Metaprogramming trades clarity for brevity. If a reader cannot find where a method is defined with a text search, the code has become hostile. Prefer explicit code and use these tools when they remove real duplication.
Class macros and DSLs
module Validatable
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def validates(field, presence: false, format: nil)
validations << { field: field, presence: presence, format: format }
end
def validations
@validations ||= []
end
end
def valid?
self.class.validations.all? do |rule|
value = public_send(rule[:field])
next false if rule[:presence] && (value.nil? || value.to_s.empty?)
next false if rule[:format] && !value.to_s.match?(rule[:format])
true
end
end
end
class Signup
include Validatable
validates :email, presence: true, format: /\A[^@\s]+@[^@\s]+\z/
validates :name, presence: true
end
Signup.new.tap { |s| s.email = "[email protected]"; s.name = "Ada" }.valid? # => true# a block-based DSL
class RouteSet
def initialize(&block) = instance_eval(&block)
def get(path, to:) = (@routes ||= []) << [:get, path, to]
def post(path, to:) = (@routes ||= []) << [:post, path, to]
def routes = @routes || []
end
set = RouteSet.new do
get "/books", to: "books#index"
post "/books", to: "books#create"
endRefinements and safe alternatives
module MoneyFormat
refine Numeric do
def to_money
format("$%.2f", self / 100.0)
end
end
end
class Invoice
using MoneyFormat
def total = 12345.to_money # "$123.45"
end
12345.respond_to?(:to_money) # => false, outside the scope| Technique | Scope | Risk |
|---|---|---|
| Reopening a class | Global, permanent | Breaks other gems and Ruby upgrades |
prepend a module | Global, but explicit ordering | Low - visible in the ancestor chain |
| Refinement | Lexical, file or class scoped | Low, but confusing to newcomers |
method_missing | Class-wide | Silent typos, poor stack traces |
Before monkey-patching a core class, check whether a subclass or a small wrapper object would do. Global patches on String or Integer are the most common cause of a gem conflict that takes a day to find.
FAQ
When is method_missing justified?
For genuinely dynamic APIs: a proxy to a remote service, a configuration object reading arbitrary keys, an ORM's dynamic finders. When the set of names is known at load time, generate real methods instead.
What is a refinement good for?
Adding a method for one file without polluting the global namespace - formatting helpers, test-specific shorthands. The cost is that the behaviour is invisible from the class definition, so use it sparingly.
Related
Objects, modules and exceptions Concurrency: threads, fibers and Ractors
Last refreshed 2026-09-18.