Pattern: Configuration & Builder
This guide explains how to implement Ruby configuration DSLs with a mutable Configuration and a separate Builder, including file loading and explicit freezing.
Design
Create the configuration first. Pass it to a builder, which applies the DSL to that same object. Return the configuration after evaluation, keeping it mutable.
Configurationowns the configuration state, defaults, readers, and mutation methods. It can be configured directly through its public API.Builderprovides the DSL and file-loading context. It changes the configuration through its public API. UseBuilderas the standard name for this role, including when it loads files.Configuration.buildevaluates a block through a builder and returns the configuration.Configuration.loadloads files in order into one configuration and returns it.Configuration#freezefinalises the configuration when the caller chooses..buildand.loaddo not call it automatically.
The builder does not keep a second set of configuration values to transfer later. There is no final Builder#build step, new(builder.attributes) conversion, snapshot copying, or automatic freezing. Configuration readers belong on Configuration; the builder only needs its configuration reference and any DSL context such as a root directory.
Keep domain invariants in configuration mutation methods so they also apply to direct callers. The builder can translate convenient DSL arguments into those operations. Use ordinary writers for simple values and methods such as add for collections. A DSL method can have the same name as a configuration reader because they are on different objects.
Example
The classes below share a project namespace. In a gem, they can live in separate configuration.rb and builder.rb files. entry and concurrency illustrate a collection and a scalar setting; use the project's own domain operations and defaults.
# frozen_string_literal: true
module Example
class Builder
def initialize(configuration, root = Dir.pwd)
@configuration = configuration
@root = File.expand_path(root)
end
attr :root
def entry(name, value)
@configuration.add(name, value)
end
def concurrency(value)
@configuration.concurrency = value
end
def self.load_file(configuration, path)
path = File.realpath(path)
builder = self.new(configuration, File.dirname(path))
builder.instance_eval(File.read(path), path, 1)
end
def load_file(path)
self.class.load_file(@configuration, File.expand_path(path, @root))
end
end
class Configuration
def self.build(root: Dir.pwd, &block)
configuration = self.new
builder = Builder.new(configuration, root)
if block
if block.arity.zero?
builder.instance_eval(&block)
else
block.call(builder)
end
end
return configuration
end
def self.load(paths)
configuration = self.new
Array(paths).each{|path| configuration.load_file(path)}
return configuration
end
def initialize
@entries = {}
@concurrency = 1
end
attr :entries
attr_accessor :concurrency
def add(name, value)
@entries[name.to_sym] = value
end
def load_file(path)
Builder.load_file(self, path)
return self
end
def freeze
return self if frozen?
@entries.freeze
super
end
end
end
The DSL and direct API operate on the same mutable state:
configuration = Example::Configuration.build do
entry :primary, "https://example.com"
concurrency 2
end
configuration.add(:secondary, "https://secondary.example.com")
configuration.concurrency = 4
Example::Configuration.build do |builder|
builder.entry(:primary, "https://example.com")
end
The example allows an omitted block to return defaults. With a zero-arity block, self is the builder; with an explicit block argument, the caller's self is preserved. Both factories return the configuration regardless of the block's or file's last expression.
Explicit Freezing
Implement Configuration#freeze to freeze the state owned by the configuration, then call super to freeze the configuration itself. Return self immediately when already frozen so repeated calls do not repeat finalisation. The same configuration object is returned; freezing does not construct a replacement.
The example owns the entries hash and freezes it in place. Its registered values are application-supplied objects retained by reference, so they remain untouched. Freeze owned nested configuration data as appropriate to its schema, but do not recursively freeze arbitrary handlers, policies, or other application objects. Freezing the outer object alone would still allow add to modify an unfrozen hash.
The caller decides when configuration is complete:
configuration.freeze
After this call, concurrency= and add raise FrozenError, including when called through a builder attached to the configuration. References previously obtained from configuration.entries refer to the same frozen hash. Registered objects keep their own lifecycle and mutability.
If finalisation prepares derived state, do that before calling super. Runtime methods must not depend on assigning new instance variables after the configuration has been frozen.
File Loading
Each file is evaluated by a new builder attached to the shared configuration. Its root is that file's directory. A nested load_file "other.rb" resolves against the calling builder's root and creates another builder for the referenced file. The original builder keeps its root throughout; no temporary root assignment or restoration is needed.
Resolve the file path before creating the builder, and pass the filename and starting line to instance_eval for useful source locations. Configuration files execute trusted application Ruby.
Multiple top-level files share one configuration. The example applies them in order, with later entries replacing earlier entries of the same name. Preserve the domain's duplicate and override rules when adapting the pattern. Evaluation errors propagate; mutations made before an error remain on an existing configuration. Loading is not transactional.
Applying the Standard
Use this design for new configuration DSLs and when standardising existing ones. Preserve existing public names and entry points as compatibility wrappers where needed; an existing Loader can retain its name while the implementation moves to Builder. Keep runtime query methods and domain validation on the configuration.
Verify that block and file factories return the configured object, that it can still be changed through the direct API, and that builders for multiple files update that same object. Explicit freezing should return that object, tolerate repeated calls, and prevent changes to its owned state through writers, collection readers, or attached builders, while leaving application objects untouched. For nested files, check relative resolution, stable builder roots, and exception source locations.