OneRuby.devAN ENGINEERING NOTEBOOK

ruby · 5 min read

Ruby metaprogramming: generate a small API without hiding its rules

Build a typed settings DSL with define_method, then test reflection, validation, inheritance and shared defaults. No eval or method_missing required.

A configuration object needs a host and a timeout. Writing two readers and two writers is easy. The repetition starts when every writer must check a type, apply a constraint, copy a string and reject invalid values without changing the old setting.

That is a reasonable place to consider metaprogramming: the repetition is a rule, and the rule is small enough to inspect. This experiment uses define_method to build a settings API from a declared schema. The interesting part is not saving keystrokes. It is keeping validation, reflection and inheritance predictable after methods are generated.

The API comes before the machinery

This is what an application declares:

Ruby
class HttpSettings < TypedSettings
field :host, type: String, default: "localhost" do |value|
!value.empty?
end
field :timeout, type: Integer, default: 5 do |value|
value.between?(1, 60)
end
end

The declaration belongs in application code. The field name, type and validator are trusted Ruby, not instructions accepted from a web request. Evaluating arbitrary validators from users would be code execution; a tidy DSL does not change that boundary.

An instance accepts keyword overrides and otherwise uses defaults. The host is a nonempty String. Timeout is an Integer from 1 through 60. Invalid assignments raise before changing the stored value, so catching the error does not leave a half-updated setting.

If readers and writers with no additional policy were sufficient, attr_accessor would be the simpler choice. The custom declaration earns its place by centralizing behavior that each handwritten writer would otherwise repeat.

Create actual methods

After checking the declaration, the builder installs a reader and writer:

Ruby
define_method(name) { @values.fetch(name) }
define_method("#{name}=") { |value| write_field(name, value) }

These are instance methods. They belong to a settings instance, not to the HttpSettings class object itself. Ruby's define_method documentation describes the block-based method definition used here.

The reader closes over the field name and fetches its value. The writer delegates to one ordinary private method, which retrieves the field specification, validates the new value and stores a copy. Keeping that logic in a named method makes it possible to put a breakpoint in one place.

Because the methods exist normally, respond_to?, method and arity behave normally. The tests check those properties as well as returned values. A library consumer may inspect an object before calling it; a DSL that works only when called directly has an incomplete interface.

Method_missing is unnecessary when the complete method set is known at declaration time. If you choose it for a genuinely open-ended interface, respond_to_missing? must express the same lookup rule. Otherwise introspection and dispatch disagree.

Reject names before creating anything

The schema accepts a deliberately narrow field-name pattern and rejects names that would shadow existing public, protected or private readers, including initialize. It also rejects an existing generated writer.

This prevents a declaration from casually replacing send or the constructor. It is not a security boundary around arbitrary Ruby: schema code already executes with the application's privileges. The goal is to catch mistakes while loading the class, when the declaration that caused them is nearby.

The builder validates the default before adding the field or defining its methods. A failed declaration therefore leaves no half-created reader behind. A test attempts to declare an Integer field with a String default, then checks both the schema and the method table.

Avoiding eval also matters for readability. No generated source string needs escaping, and a field value is never interpreted as Ruby. If a DSL later produces SQL, however, this alone says nothing about SQL safety: values still need bound parameters and identifiers need a separate explicit policy. Code generation is not sanitization.

Inheritance must not rewrite the parent

Each class stores only its local schema in a class-instance variable. Reading fields combines that schema with its parent's fields and returns a frozen Hash. A child can add retries while HttpSettings remains unchanged.

The tests construct an anonymous subclass, add a field and inspect both parent and child instances. This catches a common mistake: storing one mutable Hash that every class in the hierarchy edits.

The implementation rejects redefining an inherited field. Supporting overrides would require deciding whether changing a type or default is compatible with callers expecting the parent contract. Rejecting the operation keeps the experiment's rule small.

Declare fields before constructing instances. Adding a new field to a live class does not retroactively initialize the values of existing objects. A framework that supports runtime schema changes needs a migration or fallback rule; this example does not pretend to provide one.

Defaults have owners too

A String default can be shared accidentally between instances. Here, defaults and assigned strings are copied and frozen. Mutating the caller's original string after construction cannot change a setting, and trying to append through the reader raises.

This is intentionally limited to String and Integer fields. An Array of mutable objects would need a more detailed ownership policy. Supporting every type by calling dup once would merely move the shared-reference problem down one level.

The instance itself remains mutable through validated writers. Calling it an immutable object would be wrong even though the exposed strings and schema container are frozen.

Run the contract tests

Download the complete DSL and test suite. Use Ruby 3.3.2 and Minitest 6.0.6, installing that exact test gem version if necessary.

Terminal
ruby example.rb --seed 42

The local run passed 6 tests and 29 assertions covering ordinary reflection, failed assignments, unknown input, string ownership, subclass isolation and rejected declarations.

When reviewing a metaprogramming abstraction, try a typo, a conflicting method name and a subclass before admiring the declaration syntax. Those are ordinary API questions. Generated methods should answer them as clearly as handwritten ones.

Found a mistake or tried a different approach?

Send Alex a note ↗