Writing Your Own Ruby DSL

Photo by Jason D on Unsplash
I was working with a SOAP API recently and I wanted to have classes that described the API action, but I didn’t want to have a bunch of boilerplate code every time I needed to reach out to the API. The classes that I had seen others use in our project to implement these interactions were very long, and had a LOT of duplicated logic between them.
For privacy, trade secret, and security reasons, I can’t share that original code with you. However, I’ve modified the original code, and here is the gist of it (you’ll have to make some assumptions about Success
, Failure
, Timer
, and other classes). The exact inner workings of this class aren’t important. Just glance over this to get a feel for the complexity and level of duplication that there could be.
# frozen_string_literal: true
class BillingStatus
include Singleton
WS_BASE_URL = Settings.soap_api.base_url
WS_WSDL_ENDPOINT = Settings.soap_api.wsdl.billing_status
WS_USERNAME = Settings.soap_api.username
WS_PASWD = Settings.soap_api.password
extend Savon::Model
attr_accessor :package, :response, :status
client wsdl: "#{WS_BASE_URL}#{WS_WSDL_ENDPOINT}",
pretty_print_xml: true,
soap_version: 1,
raise_errors: false,
log_level: :debug,
open_timeout: 10,
read_timeout: 30,
log: !(Rails.env.production? || Rails.env.test?),
namespaces: {'xmlns:soap' => 'http://example.com/ws/soapheaders'},
convert_request_keys_to: :none,
namespace_identifier: :agen,
soap_header: {'soap:locale' => 'en_US',
'soap:authentication' => {'soap:username' => WS_USERNAME,
'soap:password' => WS_PASWD }
}
operations :get_billing_status
def call(account_number)
@_only_one.synchronize do
@_timer = Timer.duration
begin
message = { accountNumber: account_number.to_s }
self.response = get_billing_status(message: message)
if response.success?
self.package = response.hash.dig(:envelope, :body, :get_billing_status_response, :return)
self.status = {online: true, message: Time.now.to_default_s}
res = Array[package].flatten.collect {|r| r.is_a?(Hash) ? OpenStruct.new(r) : r }
Rails.logger.debug "#{self.class.name}##{__method__} Success: #{res.present?}, Duration: #{Timer.duration(@_timer)}"
package.nil? ? Failure.([],"Unable to Process Request for #{account_number}: Not Found!", :content) : Success.(res || [])
elsif response.soap_fault?
self.package = response.hash.dig(:envelope, :body, :fault, :faultstring)
Rails.logger.error "#{self.class.name}##{__method__} Success: #{false}, Duration: #{Timer.duration(@_timer)}, res: #{package}"
Failure.([],package, :component)
elsif response.http_error?
self.package = response.http
self.status = {online: false, message: package}
Rails.logger.error "#{self.class.name}##{__method__} Success: #{false}, Duration: #{Timer.duration(@_timer)}, res: #{package.inspect}"
Failure.([],package, :access)
else
self.package = response.hash()
self.status = {online: false, message: package}
Rails.logger.error "#{self.class.name}##{__method__} Success: #{false}, Duration: #{Timer.duration(@_timer)}, res: #{package}"
Failure.([],package, :component)
end
rescue => e
cause = e.message.gsub("\n",' ').scan(/<p>(.+)<\/p>/).flatten.first
self.package = cause
self.status = {online: false, message: package}
Rails.logger.error "#{self.class.name}##{__method__} Success: #{false}, #{e.class.name}:#{cause}, Duration: #{Timer.duration(@_timer)}"
Failure.([],cause, :provider, e.message)
end
end
end
protected
def initialize
@_only_one = Mutex.new
@status = {online: true, message: Time.now.to_default_s}
Rails.logger.debug "#{self.class.name}##{__method__} Initialized"
true
rescue => e
@status = {online: false, message: e.message }
end
end
My task was to add a few more calls to this API, and I did not want to duplicate this class again. Really, what I wanted was to be able to configure an interaction with this API and call it as simply as possible. Something more like this:
class BillingStatus
include SoapAPIBase
wsdl Settings.soap_api.wsdl.billing_status
action :get_billing_status
response :get_billing_status_respnose
end
To use this class would look like this (in a Rails context, this could be a controller’s #show
action, for example):
def show
@billing_status = BillingStatus.call(params[:account_id])
end
Simple, right? Well, the reuse would be simple.
First, if you don’t know what metaprogramming is, check out this article. And if you don’t know what a DSL is, here’s a good read.
Ok. Now that we’re on the same page, how would we go about doing this?
Step One: Create a Module
Easy enough, right?
module SoapAPIBase
end
But why a module? We’re creating a module so that when we include
the module in our class, the module code is executed and the functionality is added to the class we included it in.
Step Two: Understand What We’re Trying to Do
The next thing we need to take a look at is our desired outcome. We want to have a class method (e.g., wsdl
) that will define an instance method for us, and return the parameter we provided.
That’s a bit dense, so let’s break it down a little. Another way to write the target implementation is like so (note the parentheses):
class BillingStatus
include SoapAPIBase
wsdl('my_wsdl')
action(:get_billing_status)
response(:get_billing_status_respnose)
end
This makes our goal a little more clear: we’re calling class methods and passing a parameter.
Step Three: Write the Implementation
Our module (SoapAPIBase
) needs to define those methods.
module SoapAPIBase
self.class.define_method(:wsdl) { |arg| }
self.class.define_method(:action) { |arg| }
self.class.define_method(:response) { |arg| }
end
Great! And if we run this code, we won’t have a RuntimeError (but we are far from done).
irb(main):001* module SoapAPIBase
irb(main):002* self.class.define_method(:wsdl) { |arg| }
irb(main):003* self.class.define_method(:action) { |arg| }
irb(main):004* self.class.define_method(:response) { |arg| }
irb(main):005> end
=> :response
irb(main):006* class BillingStatus
irb(main):007* include SoapAPIBase
irb(main):008*
irb(main):009* wsdl 'my_wsdl'
irb(main):010* action :billing_summary
irb(main):011* response :billing_summary_response
irb(main):012> end
=> nil
irb(main):013> b = BillingStatus.new
=> #<BillingStatus:0x000000011d934180>
What our module is doing is defining methods called wsdl
, action
, and response
. According to the Ruby documentation
, #define_method
accepts a block (or Proc or method), which is what will be used as the method body, and whatever arguments are passed to the block will be the method arguments. So, right now, our module is generating this:
class BillingStatus
def self.wsdl(arg)
end
def self.action(arg)
end
def self.response(arg)
end
wsdl 'my_wsdl'
action :billing_status
response :billing_status_response
end
The last few lines of that last class definition are just calling those class methods. So, now we need to put a useful body in those class methods.
module SoapAPIBase
self.class.define_method(:wsdl) do |arg|
define_method(:wsdl) { arg }
end
self.class.define_method(:action) do |arg|
define_method(:action) { arg }
end
self.class.define_method(:response) do |arg|
define_method(:response) { arg }
end
end
And, as we talked about before, #define_method
is going to create a method in our class. Except this time, we’re using it to define an instance method. So, now, when we call the #wsdl
class method, for example, that call will create an instance method called #wsdl
, which will simply return what we passed in to that class method (in this case, 'my_wsdl'
). Check it out!
irb(main):013* module SoapAPIBase
irb(main):014* self.class.define_method(:wsdl) do |arg|
irb(main):015* define_method(:wsdl) { arg }
irb(main):016* end
irb(main):017*
irb(main):018* self.class.define_method(:action) do |arg|
irb(main):019* define_method(:action) { arg }
irb(main):020* end
irb(main):021*
irb(main):022* self.class.define_method(:response) do |arg|
irb(main):023* define_method(:response) { arg }
irb(main):024* end
irb(main):025> end
=> :response
irb(main):026* class BillingStatus
irb(main):027* include SoapAPIBase
irb(main):028*
irb(main):029* wsdl('my_wsdl')
irb(main):030* action(:get_billing_status)
irb(main):031* response(:get_billing_status_respnose)
irb(main):032> end
=> :response
irb(main):033> b = BillingStatus.new
=> #<BillingStatus:0x000000010dfbb130>
irb(main):034> b.wsdl
=> "my_wsdl"
Step Four: Clean Up Duplication
Now, clearly there’s some code duplication going on here. Let’s clean it up:
module SoapAPIBase
%i[wsdl action response].each do |method_name|
self.class.define_method(method_name) do |arg|
define_method(method_name) { arg }
end
end
end
Et voilĂ ! Now, you have the tools to go write your own Ruby DSL! Have fun!
Bonus: Making it Work with Savon
One last thing. How did I make this work for my use case and hook into the [Savon][https://www.savonrb.com] library? It was pretty straightforward:
require 'active_support/concern'
module SoapAPIBase
extend ActiveSupport::Concern
WS_BASE_URL = 'http://example.com/'
WS_USERNAME = 'customapp'
WS_PASWD = 'supersecretpassword'
%i[wsdl action response].each do |method_name|
self.class.define_method(method_name) do |arg|
define_method(method_name) { arg }
end
end
included do
def initialize(params)
@params = params
end
def call
result = client.call(action, message: params).deep_symbolize_keys
# Error handling here
result.body.dig(response, :result)
end
private
attr_reader :params
def client
@client ||= Savon.client(
wsdl: "#{WS_BASE_URL}#{wsdl}",
soap_version: 1,
log: !(Rails.env.production? || Rails.env.test?),
namespaces: {'xmlns:soap' => 'http://example.com/ws/soapheaders'},
log_level: :debug,
soap_header: { 'soap:locale' => 'en_US',
'soap:authentication' => {'soap:username' => WS_USERNAME,
'soap:password' => WS_PASWD }
}
)
end
end
class_methods do
def call(params)
new(params).call
end
end
end
This SoapAPIBase
module allowed me to use my BillingStatus
class exactly as outlined earlier in this article:
def show
@billing_status = BillingStatus.call(params[:account_id])
end
In my case, this returned a hash which was very easy to use. Imagine something like:
{
account_id: 12345,
status: 'active',
last_bill_date: '2025-09-01',
}
Well, there you have it! A custom Ruby DSL and a way to work with Savon as though the interactions were more like models. I hope this was useful and helpful to you. If so, drop me a line! I’d love to hear from you.
Published September 20, 2025 by Toby Chin