WDYTA using dry-monad in repository?

Hey,
I’m interested in your opinion? So far I’ve used dry-monads just in interactors. Now I have some API’s (i.e. http), which I hide behind a repository:

i.e.:

class Repo
  attr_reader :conn

  def initialize
    endpoint = Payments::Slice[:settings].api_endpoint
    @conn = Faraday.new(url: endpoint) do |conn|
      conn.response :logger
      conn.response(
        :json,
        :content_type => "application/json",
        :parser_options => { :symbolize_names => true },
      )
    end
  end

  def filtered(**filter)
    get("customers", filter)
  end

  private

  def get(path, params = {})
    response = conn.get(path, params)
    if (response.success?)
      response.body
    else
      raise Repo::Errors::BasicError, "Basic Error at Repo: #{response.status} #{response.body}"
    end
  end
end

With sql-repos ((i.e. rom-rb, rom-sql)), I don’t feel like dry-monads would offer me great benefit, but in the above case it is tempting to change the code to following:

i.e.:

class Repo
  attr_reader :conn

  def initialize
    endpoint = Payments::Slice[:settings].api_endpoint
    @conn = Faraday.new(url: endpoint) do |conn|
      conn.response :logger
      conn.response(
        :json,
        :content_type => "application/json",
        :parser_options => { :symbolize_names => true },
      )
    end
  end

  def filtered(**filter)
    customers = yield get("customers", filter)
    Success(customers)
  end

  private

  def get(path, params = {})
    response = conn.get(path, params)
    if (response.success?)
      Success(response.body)
    else
      Failure("Basic Error at Repo: #{response.status} #{response.body}")
    end
  end
end

What do you think?

The only difference is that when using the repo I have to query the result, but the error handling (which is hard for unreliable http-services) is nicer.

Thanks!

Yeah, I generally use monads everywhere in large applications. This way you can compose fault tolerant pipelines. Based on your example above, one thing that might be of value is extracting and encapsulating your connection object so it can be injected into your Repo#initialize method. This would also mean you could reuse it in multiple objects based on business logic needs.

Here’s an example of my Ghub::API::Client as found in the Ghub gem. In my case, my Ghub::API::Client is the low level API connection object (using your terminology above). …but notice how my API client exposes the HTTP methods where each answers back a monad. Basically, the encapsulation is pushed down to a lower level from which to build more complex objects upon.

1 Like

One of the primary places I tend to use repos is within monadic operation objects, so yes this is a very useful thing to have. I retain the regular method interfaces though.

Here’s how:

T = Dry.Types(default: :strict)
M = Dry::Monads

# Automagically generate monadic interfaces for traditional methods
#
# @param [:result, :maybe] method_types the type of monad to wrap the expression in
#
# @example Result type
#   class Foo
#     extend MonadicMethods(:result)
#
#     def foo = "foo"
#     def bar = raise ArgumentError, "BarError"
#   end
#
#   Foo.new.foo  #=> "foo"
#   Foo.new.foo! #=> Success("foo")
#   Foo.new.bar! #=> Failure(ArgumentError)
#
# @example Maybe type
#   class Bar
#     extend MonadicMethods(:maybe)
#     def foo = "foo"
#     def bar = nil
#   end
#
#   Bar.new.foo  #=> "foo"
#   Bar.new.bar  #=> nil
#   Bar.new.foo? #=> Some("foo")
#   Bar.new.bar? #=> None()
#
# @raise [ArgumentError] unexpected monad type supplied
#
# @return Module
# rubocop:disable Naming/MethodName
def MonadicMethods(*method_types)
  T::Array.of(T::Symbol.enum(:maybe, :result))[method_types]

  Module.new do
    define_singleton_method(:inspect) { "MonadicMethods[#{method_types.map(&:inspect).join(", ")}]" }

    define_method :method_added do |method_name|
      unless /[?!]$/ =~ method_name
        if method_types.include?(:result)
          signature = :"#{method_name}!"

          remove_method signature if public_instance_methods.include?(signature)

          define_method signature do |*args, **kwargs, &block|
            M::Success(__send__(method_name, *args, **kwargs, &block))
          rescue => err
            M::Failure(err)
          end
        end

        if method_types.include?(:maybe)
          signature = :"#{method_name}?"

          remove_method signature if public_instance_methods.include?(signature)

          define_method signature do |*args, **kwargs, &block|
            M::Maybe(__send__(method_name, *args, **kwargs, &block))
          end
        end
      end
    end
  end
rescue Dry::Types::ConstraintError => err
  ArgumentError
    .new(err.message)
    .tap  { |arg| arg.set_backtrace(err.backtrace) }
    .then { |arg| raise arg }
end
1 Like

Thank you @bkuhlmann @alassek. Great input!

1 Like