Skip to content

Instantly share code, notes, and snippets.

@keathley
Last active March 14, 2024 15:16
Show Gist options
  • Save keathley/0654eb9020c2deead8180d110dcc24aa to your computer and use it in GitHub Desktop.
Save keathley/0654eb9020c2deead8180d110dcc24aa to your computer and use it in GitHub Desktop.
A description of how to take advantage of the dry-monad gem.

How we're using dry-monad

Imagine that we have an application where user's have a location. We want to find the weather for their current location using an external service call. We want to provide this functionality in a json api. A basic implementation might look like this.

def create
  user = User.find(params[:id])
  location = user.location
  if location
    zip = location.zip_code
    Circuit.circuit(:weather)
      .command { 
          weather = WeatherAPI.current_weather(zip)
          render json: { current_weather: weather }
      }
      .fallback { render json {errors: "Couldn't talk to weather"} }
      .run
  else
    render json: { errors: "No location" }
  end
rescue ActiveRecord::RecordNotFound => e
  render json: { errors: "User not found" } 
end

There are multiple places where this code can fail and depending on that failure we need to take different actions. We may want to halt execution or render specific errors.

An even more incidious problem is that none of the logic in this action is re-usable. All of our logic for gathering data, acting on that data, and returning results is coupled together.

We want to decouple the core logic of looking up a users weather report from the controller logic of rendering json. This allows our weather lookup code to be more reusable and easier to test. A first pass might look something like this.

module WeatherLookup
  class Result
    attr_reader :value

    def initialize(value, success)
      @value = value
      @success = success
    end

    def success?
      @success
    end
  end

  def self.for_user(user_id)
    user = User.find(user_id)
    location = user.location
    if location
      zip = location.zip_code
      Circuit.circuit(:weather)
        .command { 
            weather = WeatherAPI.current_weather(zip)
            Result.new(weather, true)
        }
        .fallback { Result.new("Weather api is down", false) }
        .run
    else
      Result.new("No location", false)
    end
  rescue ActiveRecord::RecordNotFound => e
    Result.new("No user", false)
  end
end

def create
  result = WeatherLookup.for_user(params[:id])

  if result.success?
    render json: { current_weather: result.value }
  else
    render json: { errors: result.value }
  end
end

Our module can now return a Result type and the controller can decide what it wants to do with that result. What we've really done here is inverted control of this operation and given the control back to the caller. This refactor certainly cleans up the controller code. But our new weather module could still use some work.

Results

It turns out that our Result class is remarkably close to a very common pattern. This pattern is nicely encapsulated in the dry-monads gem. Using the gem we can re-factor our code like so.

def create
  result = WeatherLookup.for_user(params[:id])
    
  if result.success?
    render json: { current_weather: result.value! }
  else
    render json: { errors: result.failure }
  end
end

module WeatherLookup
  def self.for_user(user_id)
    Try(ActiveRecord::RecordNotFound) { User.find(params[:id]) }
      .to_maybe
      .fmap { |user| user.location }
      .fmap { |location| location.zip_code }
      .bind { |zip| get_weather(zip) }
  end

  def self.get_weather(zip)
    Circuit.circuit(:weather)
      .command { Success(WeatherAPI.current_weather(zip)) }
      .fallback { Failure("The weather api is down") }
      .run
  end
end

Each of our seperate actions now returns either a Success or a Failure. Both of these define a common interface which allows operations to be composed together using the functions fmap and bind. While these names aren't very descriptive the functions are actually straightforward.

fmap allows you to take a value out of its context (in this case our Result), apply a function to the unwrapped value, and then place it back inside the context.

bind provides a way to take a Result and apply a function on it that returns a new Result. In our example the WeatherAPI call will return a Success or Failure depending on if the circuit has been broken or not. We can use bind to compose this method with the result of getting a users zip code.

The magic of this interface is that the blocks passed to fmap and bind will only be called if the Result is a Success. If the Result is a Failure then both fmap and bind return the Failure and won't yield the block. What this means in practice is that if any of our operations fail the rest of the operations will be skipped and we'll return the original failure. For instance if a user doesn't have a location then we'll return None and skip looking up the zip code and making a weather api call.

Interfaces and Monads

fmap and bind aren't specific to the Result type. They're part of a more generic interface known as a Monad. There are several other types of monads including Maybe, List, and State. For our purposes we'll mostly be concerned with Results and Maybes.

The power of this interface is that it allows for easy composition of operations. If we wanted to provide clothing recommendations for a user based on their current weather we could do something like this:

 def recommendations
  result = WeatherLookup
    .for_user(params[:id])
    .bind { |weather| ProductRecommender.for_weather(weather) }
    
  if result.success?
    render json: { clothes: result.value! }
  else
    render json: { errors: result.failure }
  end
end

Looking up a users weather is completely decoupled from any sort of product recommendations. Using our interface we're able to compose and extend functionality without sacrificing the re-usability of these functions.

Conclusion

The Monad interface provides a powerful tool for composing small, side-effecting operations. My hope is that this gives you a better understanding of how they work and how we might benefit from them.

The documentation for the dry-monads gem is here: http://dry-rb.org/gems/dry-monads/

If you wanna learn more about the theory behind Functors and Monads then this is a great resource: http://adit.io/posts/2013-04-17-functors,_applicatives,_and_monads_in_pictures.html.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment