Seven Days of Design Patterns | Part 4 - Adapter Pattern

May 29, 2019

Recently I ran into a rather interesting problem. The app I was working on needed to add support for multiple labs. The application is one that helps folks order medical tests and get them to a nearby lab without all the hoops involved of going through a doctor or doctors. For many years there was just one lab provider, let’s call them Legacy Labs. However, they have decided to shut down all of their integrations so now our application needs to support at least one new lab provider. Let’s call this lab, Future labs.

Of course we can’t just perform hard cut over. We need a way for both lab providers to co-exist for at least some span of time. This enables customers to continue to receive lab results from the primary/old lab provider. Now the question is: “how on earth do that”. Let’s tackle this one problem at a time. The easiest way to do that is to list out the pieces.

  • Allow users to find a lab location belonging to either provider
  • Allow users to receive lab tests results from either provider

For the sake of aruments let’s say that all the types of labs and details are the same. That way we can get right into the nitty gritty.

Alright so let’s tackle the first problem of locating a lab provider. Currently the class looks something like this.

class LabLocations
  def initialize(zip_code)
    @zip_code = zip_code
  end

  def fetch
    @results = []

    begin
      @results = HTTParty.get('https://legacy-labs.com/locations', query: { zip_code: @zip_code })
    rescue => e
      Rails.logger.error(e)
    end

    @results
  end
end

This seems pretty straight forward right? We could probably get away with a pretty simple addition…

class LabLocations
  ...

  def fetch
    @results = []

    begin
      @results = legacy_locations.concat(future_locations)
    rescue => e
      Rails.logger.error(e)
    end

    @results
  end

  private

  def legacy_locations
    HTTParty.get('https://legacy-labs.com/locations', query: { zip_code: @zip_code }) rescue []
  end

  def future_locations
    HTTParty.get('https://future-labs.com/labLocations', query: { postalCode: @zip_code }) rescue []
  end
end

There is just a couple of problems here. First, location records returned by each provider have different attributes and some of the attribute values come in different formats. For example, LegacyLab.hours is a block of text with newline characters. FutureLab.business_hours returns back an array of hashes. To make matters even more complicated, the two labs share various lab locations.

class LabLocations
  def initialize(zip_code)
    @zip_code = zip_code
  end

  def all
    legacy + future
  end

  def legacy
    HTTParty.get('https://legacy-labs.com/locations', query: { zip_code: @zip_code }) rescue []
  end


  def future
    HTTParty.get('https://future-labs.com/labLocations', query: { postalCode: @zip_code }) rescue []
  end
end

This isn’t too bad. We now have an object that knows how to return the various location types or the abiltiy to combine them. However, we still don’t have location objects that use the same language.

class LabLocations
  def initialize(zip_code)
    @zip_code = zip_code
  end

  def all
    LegacyLabLocationAdapter.new(@zip_code).all + FutureLabLocationAdapter.new(@zip_code).all
  end
end

def LabLocationAdapter
  def initialize(zip_code)
    @zip_code = zip_code
  end

  def all
    raise "Not Yet Implemented"
  end
end

class LegacyLabLocationAdapter < LabLocationAdapter
  def all
    locations = HTTParty.get('https://legacy-labs.com/locations', query: { zip_code: @zip_code }) rescue []
    standardize_locations(locations)
  end

  private

  def standardize_locations(locations)
    locations.map do |location|
      LabLocation.new.tap do |lab_location|
        lab_location.hours = location.hours
        lab_location.address = location.address
      end
    end
  end
end


class FutureLabLocationAdapter < LabLocationAdapter
  def all
    locations = HTTParty.get('https://future-labs.com/labLocations', query: { postalCode: @zip_code }) rescue []
    standardize_locations(locations)
  end

  private

  def standardize_locations(locations)
    locations.map do |location|
      LabLocation.new.tap do |lab_location|
        lab_location.hours = hours_text_from_data(location.hours)
        lab_location.address = location.address
      end
    end
  end

  def hours_text_from_data(hours)
    text = ""

    hours.each do |day, hours_value|
      text << "#{day}: #{hours_value}\n"
    end

    text
  end
end

Let’s go over this piece by piece. First we separate we the logic for fetching lab locations into two distinct objects. These objects are responsible for returning valid LabLocation objects from a given source. To make things a little cleaner we created a little LabLocationAdapter that defined a common interface for our adapter classes. This makes it easier to create other adapters for additional data sources while maintaining a consistent structure and method signature layout. What’s the benefit of that? Let’s take this one final step further.

class LabLocations
  ADAPTERS = [
    LegacyLabLocationAdapter,
    FutureLabLocationAdapter,
    StarLabLocationAdapter
  ]

  def initialize(zip_code)
    @zip_code = zip_code
  end

  def all
    results = []
    ADAPTERS.each { |adapter| results << adapter.new(@zip_code).all }
    results
  end
end

class StarLabLocationAdapter < LabLocationAdapter
  def all
    locations = HTTParty.get('https://star-labs.com/locations', query: { postalCode: @zip_code }) rescue []
    standardize_locations(locations)
  end

  private

  def standardize_locations(locations)
    locations.map do |location|
      LabLocation.new.tap do |lab_location|
        lab_location.hours = hours
        lab_location.address = location.full_address
      end
    end
  end
end

Nice! Now we have a really clean way to add new providers with complete transparency to the code that calls out to LabLocations.all. This means we can maintain our usual interface, but the details under the hood have changed to support our new requirements.

We are almost there. The last requirement is that when a customer receives results from a lab, the application needs to parse and store those for the customers to access as they always have. For brevity sake lets just create the object structure and leave the implementation details for our imaginations.

First let’s peak at the current implementation.

class CreateResultsForOrder
  def initialize(order)
    @order = order
  end

  def call
    results = HTTParty.get("https://lagacy-labs.com/orders/#{@order.provider_id}/results") rescue []
    results.each do |result|
      order_result = OrderResult.from_legacy_result(result)
      order_result.order = @order
      order_result.save!
    end
    true
  end
end

If we take our adapter approach and apply it here, we might see something like this:

class CreateResultsForOrder
  def initialize(order)
    @order = order
  end

  def call
    adapter = adapter_for_order.new(@order)
    adapter.call
  end

  private

  def adapter_for_order
    case @order.lab_provider
    when :legacy_labs then LegacyLabsOrderResultsAdapter
    when :future_labs then FutureLabsOrderResultsAdapter
    when :star_labs then StarLabsOrderResultsAdapter
    else
      raise "No adapter specified for #{@order.lab_provider}"
    end
  end
end

class OrderResultsAdapter
  def initialize(order)
    @order = order
  end

  def call
    raise "Not yet implemented!"
  end
end

class LegacyLabsOrderResultsAdapter < OrderResultsAdapter
  def call
    # implementation details here
  end
end

class FutureLabsOrderResultsAdapter < OrderResultsAdapter
  def call
    # implementation details here
  end
end

class StarLabsOrderResultsAdapter < OrderResultsAdapter
  def call
    # implementation details here
  end
end

Using the adpater pattern again, we can now fetch results from all three of our lab providers, transform them to OrderResult objects, and persist them to the database. All while maintaining the public interface of our original CreateResultsForOrder class.

The adapter pattern is a wonderful tool for abstracting away varying interfaces to a particular problem. Whether it is writing to something that acts like a file, persisting data in database through various engines, or even pulling Geolocation data out of various sources. I hope you all have enjoyed the topic today and I wish you all happy coding.

Comments

comments powered by Disqus