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&rsquo;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