Rails Service Object with Dry-Monad yielding early

I have a couple of service objects in my rails application where I am using Dry-Monads to influence a pattern of “success” or “failure” by using their Do Notation. However, in my main service object, upon calling a secondary service object, the main class returns early instead of following down to the last success call.

ApplicationService

# frozen_string_literal: true

require 'dry/monads'

class ApplicationService
  include Dry::Monads[:result, :do]

  def self.call(*args, **kwargs, &block)
    new(*args, **kwargs, &block).call
  end

  def success!(val = nil)
    Success(val)
  end

  def fail!(err)
    Failure(err)
  end
end

Create Check Request

class CheckRequests::Create < ApplicationService
  attr_accessor :user, :params, :company, :check_request

  def initialize(user:, params:)
    @user = user
    @check_request = nil
    @company = find_company(params[:company_id])
    @params = set_default_params(params)
  end

  def call
    # Calling this service class seems to yield right here
    vendor_id = yield generate_vendor_id
    check_request = yield create_check_request(vendor_id)

    success!({ check_request: check_request })
  end

  private

  def create_check_request(vendor_id)
    params[:vendor_id] = vendor_id if vendor_id.present?
    success!(CheckRequest.create!(**params))
  rescue ActiveRecord::RecordInvalid => e
    fail!(e.record.errors)
  end

  def find_company(company_id)
    Company.find(company_id)
  rescue ActiveRecord::RecordNotFound => e
    fail!("Cannot find company with id #{company_id}")
  end

  def set_default_params(params)
    params = params.dup
    approved = params[:status] == 'Approved'
    params[:source_account_id] = company.default_source_account
    if approved
      params[:approved_at] = DateTime.now
      params[:approver] = user
    end

    params
  end

  def generate_vendor_id
    return success! unless vendor_needed?
    vendor_uniq = ::Quickbooks::Query.unique(company, params[:client])
    return success! unless vendor_uniq

    Qbo::Vendors::Create.new(
      company: company,
      client: params[:client],
      mailing_address: JSON.parse(params[:mailing_address])
    ).call
  end

  def vendor_needed?
    params[:vendor_id] == '0'
  end
end

Vendor Create Service Object

class Qbo::Vendors::Create < ApplicationService
  attr_accessor :company, :client, :mailing_address

  def initialize(company:, client:, mailing_address:)
    @company = company
    @client = client
    @mailing_address = mailing_address
  end

  def call
    yield create_qbo_vendor
  end

  private

  def create_qbo_vendor
    url = "#{ENV["QBO_BASE_URL"]}company/#{company.realm_id}/vendor"
    response = RestClient.post(
      url,
      { DisplayName: client, BillAddr: mailing_address }.to_json,
      {
        Authorization: "Bearer #{company.access_token}",
        content_type: "application/json"
      }
    )
    vendor = Hash.from_xml(response.body)
    success!(vendor.dig("IntuitResponse", "Vendor", "Id"))
  rescue RestClient::ExceptionWithResponse => e
    body = JSON.parse(e.http_body)
    err = body.dig("Fault", "Error", 0, "Detail") || e.message
    fail!(err)
  end
end

I’m sure I am just not looking at things correctly. Any and all help would be greatly appreciated!

Here’s the thing with your code, it doesn’t quite follow the rule where if a method returns Success/Failure in one path it must return Success/Failure in all other cases.
For instance,

  def find_company(company_id)
    Company.find(company_id)
  rescue ActiveRecord::RecordNotFound => e
    fail!("Cannot find company with id #{company_id}")
  end

It either returns a Company or a Failure. It must not. The code should look like

  def find_company(company_id)
    success!(Company.find(company_id))
  rescue ActiveRecord::RecordNotFound => e
    fail!("Cannot find company with id #{company_id}")
  end

Another case:

  def call
    yield create_qbo_vendor
  end

Currently, it returns a Failure if create_qbo_vendor returns a Failure or some value from vendor. Remember, yield unwraps Successes, it must end with a value:

  def call
    result = yield create_qbo_vendor

    success!(result)
  end

or, simply

  def call
    create_qbo_vendor
  end

First of all, you’ll need to correct all such places. Practically, such requirements are not so restricting, once you get the idea, writing code in that way becomes a habit.

@flash-gordon thanks for the quick reply! I have changed my code to reflect the changes you mentioned

class Qbo::Vendors::Create < ApplicationService
  attr_accessor :company, :client, :mailing_address

  def initialize(company:, client:, mailing_address:)
    @company = company
    @client = client
    @mailing_address = mailing_address
  end

  def call
    create_qbo_vendor
  end

  private

  def create_qbo_vendor
    url = "#{ENV["QBO_BASE_URL"]}company/#{company.realm_id}/vendor"
    response = RestClient.post(
      url,
      { DisplayName: client, BillAddr: mailing_address }.to_json,
      {
        Authorization: "Bearer #{company.access_token}",
        content_type: "application/json"
      }
    )
    vendor = Hash.from_xml(response.body)
    success!(vendor.dig("IntuitResponse", "Vendor", "Id"))
  rescue RestClient::ExceptionWithResponse => e
    body = JSON.parse(e.http_body)
    err = body.dig("Fault", "Error", 0, "Detail") || e.message
    fail!(err)
  end
end
class CheckRequests::Create < ApplicationService
  attr_accessor :user, :params, :company, :check_request

  def initialize(user:, params:)
    @user = user
    @check_request = nil
    @company = yield find_company(params[:company_id])
    @params = yield set_default_params(params)
  end

  def call
    vendor_id = yield generate_vendor_id
    check_request = yield create_check_request(vendor_id)

    success!({ check_request: check_request })
  end

  private

  def create_check_request(vendor_id)
    params[:vendor_id] = vendor_id if vendor_id.present?
    success!(CheckRequest.create!(**params))
  rescue ActiveRecord::RecordInvalid => e
    fail!(e.record.errors)
  end

  def find_company(company_id)
    success!(Company.find(company_id))
  rescue ActiveRecord::RecordNotFound => e
    fail!("Cannot find company with id #{company_id}")
  end

  def set_default_params(params)
    params = params.dup
    approved = params[:status] == 'Approved'
    params[:source_account_id] = company.default_source_account
    if approved
      params[:approved_at] = DateTime.now
      params[:approver] = user
    end

    success!(params)
  end

  def generate_vendor_id
    return success! unless vendor_needed? || !vendor_uniq?
    Qbo::Vendors::Create.new(
      company: company,
      client: params[:client],
      mailing_address: JSON.parse(params[:mailing_address])
    ).call
  end

  def vendor_needed?
    params[:vendor_id] == '0'
  end

  def vendor_uniq?
    ::Quickbooks::Query.unique(company, params[:client])
  end
end

But now, when #call is called on the CheckRequests::Create class, the method simply returns the ID of the generated vendor, vs flowing down the rest of the method. What might be causing this code to short circuit early?

Ok, I changed out my code to try to isolate the API call to be inside the single service object.

class CheckRequests::Create < ApplicationService
  attr_accessor :user, :params, :company, :check_request

  def initialize(user:, params:)
    @user = user
    @check_request = nil
    @company = yield find_company(params[:company_id])
    @params = yield set_default_params(params)
  end

  def call
    vendor_id = yield create_qbo_vendor(
      company: company,
      client: params[:client],
      mailing_address: JSON.parse(params[:mailing_address])
    )
    check_request = yield create_check_request(vendor_id)

    success!({ check_request: check_request })
  end

  private

  def create_check_request(vendor_id)
    params[:vendor_id] = vendor_id if vendor_id.present?
    success!(CheckRequest.create!(**params))
  rescue ActiveRecord::RecordInvalid => e
    fail!(e.record.errors)
  end

  def find_company(company_id)
    success!(Company.find(company_id))
  rescue ActiveRecord::RecordNotFound => e
    fail!("Cannot find company with id #{company_id}")
  end

  def set_default_params(params)
    params = params.dup
    approved = params[:status] == 'Approved'
    params[:source_account_id] = company.default_source_account
    if approved
      params[:approved_at] = DateTime.now
      params[:approver] = user
    end

    success!(params)
  end

  def create_qbo_vendor(client:, company:, mailing_address:)
    return success! unless vendor_needed?
    return success! unless vendor_uniq?

    url = "#{ENV["QBO_BASE_URL"]}company/#{company.realm_id}/vendor"
    response = RestClient.post(
      url,
      { DisplayName: client, BillAddr: mailing_address }.to_json,
      {
        Authorization: "Bearer #{company.access_token}",
        content_type: "application/json"
      }
    )
    vendor = Hash.from_xml(response.body)
    success!(vendor.dig("IntuitResponse", "Vendor", "Id"))
  rescue RestClient::ExceptionWithResponse => e
    body = JSON.parse(e.http_body)
    err = body.dig("Fault", "Error", 0, "Detail") || e.message
    fail!(err)
  end

  def vendor_needed?
    params[:vendor_id].to_i.zero?
  end

  def vendor_uniq?
    ::Quickbooks::Query.unique(company, params[:client])
  end
end

And its still exiting early when calling yield create_qbo_vendor. I fetch the success or failure like so:

# api/check_requests_controller.rb
def create
  response = CheckRequests::Create.new(user: current_user, params: check_request_params)
  handle_service_response(response)
end

# application_controller.rb
  def handle_service_response(service, **kwargs)
    service.call do |result|
      if result.success?
        response.header['Location'] = kwargs.delete(:location)
        render(json: result.value!) and return
      else
        render(json: { errors: result.failure }, status: :unprocessable_entity) and return
      end
    end
  end

Here’s the problem. You can’t pass a block to a method that uses do notation.
Specifically, this is wrong because the call method relies on yield passed automagically by include Dry::Monads[:result, :do]

    service.call do |result|
      if result.success?
        response.header['Location'] = kwargs.delete(:location)
        render(json: result.value!) and return
      else
        render(json: { errors: result.failure }, status: :unprocessable_entity) and return
      end
    end

It’s allowed to pass user blocks only to methods that don’t use yield for chaining Result values. Unfortunately, it’s not possible for dry-monads to distinguish if a method expects do notation or just yields a block.

Your code should be changed to

# using ruby 3.1+
# application_controller.rb
include Dry::Monads[:result] # import Success/Failure constants

def handle_service_response(service, **kwargs)
  case service.call
  in Success(json)
    response.header['Location'] = kwargs.delete(:location)
    render(json:)
  in Failure(errors)
    render(json: { errors: }, status: :unprocessable_entity)
  end
end