home articles sponsorship

Interfacing with external APIs: the facade pattern in Ruby

Interacting with third-party APIs is common practice in applications. You might need to convert addresses into geographic coordinates, fetch subscription information from marketplaces, prompt an LLM, etc.

Coding with Ruby, you’ll often find gems providing a first abstraction over third-party APIs: the client layer. The client layer is usually responsible for accessing the features of the external API.

One could use the client as is and sprinkle calls to the external API across their codebase. By doing so, teams will often duplicate logic, reinvent the wheel, and create multiple change points.

One way to DRY this is to create an authoritative representation of the external API that’ll define how it relates to your domain logic. This is where the structural design pattern called facade comes into play.

A brief introduction to the facade design pattern

Before we start, let’s acknowledge two caveats:

Let’s hear what seasoned developers and authors have to say about it.

The facade pattern defined by the Gang of Four

[Facades] provide a unified interface to a set of interfaces in a subsystem. Facade defines a higher-level interface that makes the subsystem easier to use. This can be used to simplify a number of complicated object interactions into a single interface.

Design Patterns: Elements of Reusable Object-Oriented Software

From this definition, we can list some key takeaways:

The facade pattern defined by Martin Fowler

Martin Fowler defines facades in opposition to gateways, which are often used interchangeably.

While Facade simplifies a more complex API, it’s usually done by the writer of the service for general use. A gateway is written by the client for its particular use.

[API gateway] is really more of a facade, since it’s built by the service provider for general client usage.

Martin Fowler

Some key points made by Martin Fowler in his post:

The facade pattern defined on Refactoring Guru

Facade is a structural design pattern that provides a simplified interface to a library, a framework, or any other complex set of classes. […]

A facade might provide limited functionality in comparison to working with the subsystem directly. However, it includes only those features that clients really care about.

Refactoring Guru

From this definition, we can infer that:

The facade pattern defined by yours truly

Overall, even trusted authors don’t fully agree on the definition of facades. And many more people have many more definitions.

I think these definitions also overlook one advantage of the facade pattern: it serves as a bridge1 between some features you need from an external system and why you need it (i.e. the context of your application).

For the rest of this post, I’ll use the term facade as:

A simplified interface for normalizing the interactions between a complex system and the context of my application.

And what about the difference between facades and adapters?

Adapters make an interface usable, usually by doing a lot of transformation/adaptation. Their scope is also narrower since adapters typically focus on making one object usable.

Facades merely provide an interface for a system of objects.

What do I need a facade for?

Now that we (mostly) agree on the definition, let’s conjure a bit of context first.

We’re building a text editor for students. It’s packed with features that’ll make their lives easier.

Context

Some students are currently working on an assignment. Before submitting their work, they’d like to check if their answers are correct and get additional suggestions.

To do that, our application can send information to an LLM and get a list of suggestions back. Easy peasy.

Let’s build it!

module Answers
  class SuggestionsController
    def create
      # LLM::Client is defined by a dummy gem
      llm_client = LLM::Client.new

      llm_client.login(credentials:).chat(parameters:)
    end

    private

    def credentials
      {
        access_token: ENV['LLM_ACCESS_TOKEN'],
        student_uuid: ENV['LLM_STUDENT_ID']
      }
    end

    def parameters
      {
        model: 'some_llm_model',
        messages:
          [
            { role: "system", content: "This is the question in my assignment: #{question.content}" },
            { role: "system", content: "Are there any ameliorations I could add to my answer: #{answer}" }
          ]
      }
    end

    def answer
      Answer.find(params[:answer_id])
    end

    def question
      answer.question
    end
  end
end

Despite being straightforward, our Answers::SuggestionsController already handles a handful of steps:

Some aspects of the code above bother me:

Let’s write some more code.

# app/controllers/answers/suggestions_controller.rb
module Answers
  class SuggestionsController
    def create
      llm_client = LLM::Client.new

      llm_client.login(credentials:).chat(parameters:)
    end

    # ...
  end
end

# app/controllers/admissions/applications_controller.rb
module Admissions
  class ApplicationsController
    def suggest_improvements
      llm_client = LLM::Client.new

      llm_client.login(credentials:).chat(parameters: default_parameters.merge(application_parameters))
    end

    private

    def credentials
      {
        access_token: ENV['LLM_ACCESS_TOKEN'],
        student_uuid: ENV['LLM_STUDENT_ID']
      }
    end

    def default_parameters
      {
        model: 'some_llm_model',
        messages: []
      }
    end

    def applications_parameters
      {
        messages:
          [
            { role: "system", content: "I'm working on an admission application to my local university: #{admission.content}" },
            { role: "system", content: "Are there any improvements I could add to my application: #{application}" },
            { role: "user", content: "Format the suggestion like this: #{formatting}" }
          ]
      }
    end

    def formatting
      <<-TXT
        Suggestions:
        -
        -
      TXT
    end
  end
end

Some notable points stand out:

Building an authoritative representation

Facades are a great solution for this type of problem.

A facade will serve as an authoritative representation of how the external API fits into our application, and it will gather the collective knowledge in one place.

1) Basic functionalities

First, let’s encapsulate the basic functionalities of the external API into a facade: the authentication strategy, a default configuration, and the instantiation of the client.

  # app/facades/llm_facade
  class LLMFacade
    attr_reader :configuration, :llm_client

    def initialize
      @configuration = Configuration.new

      @llm_client ||= LLM::Client.new.login(credentials: configuration.credentials)
    end

    class Configuration
      def initialize
        @credentials = credentials
      end

      private

      def credentials
        {
          access_token: ENV['LLM_ACCESS_TOKEN'],
          student_uuid: ENV['LLM_STUDENT_ID']
        }
      end
    end
  end

Let’s note that my facade is just a Ruby object: composable and testable. It has no dependencies. I could write this facade in any object-oriented language of my choice.

So, what happens in LLMFacade?

A facade object instantiates with a default configuration and an authenticated client. The core authentication logic is handled by the dummy gem. The facade only leverages the gem layer and returns a ready-made2 client.

I’m using a value object Configuration to represent the default configuration. I like this object-first approach over using a hash for encapsulating default values. It also makes testing easier.

2) Fetching suggestions for answers

Now that we have an object with basic functionalities, let’s add the ability to fetch suggestions.

  # app/facades/llm_facade
  class LLMFacade
    attr_reader :configuration, :llm_client

    def initialize
      @configuration = Configuration.new

      @llm_client ||= LLM::Client.new.login(credentials: configuration.credentials)
    end

    def fetch_suggestion_for(question, answer)
      llm_client.chat(parameters:
        default_parameters.merge(suggestion_parameters_for(question, answer))
      )
    end

    private

    def default_parameters = { model: configuration.model }

    def suggestion_parameters_for(question, answer)
      {
        messages: [
          { role: 'user', content: "This is the question I'm answering about: #{question}" },
          { role: 'user', content: "This is the answer I've written: #{answer}" }
        ]
      }
    end

    class Configuration
      attr_reader :model, :credentials

      def initialize(model = 'some_llm_model')
        @model = model
        @credentials = credentials
      end

      private

      def credentials
        {
          access_token: ENV['LLM_ACCESS_TOKEN'],
          student_uuid: ENV['LLM_STUDENT_ID']
        }
      end
    end
  end

Here’s a breakdown of what I changed:

As of now, our facade allows us to call LLMFacade.new.fetch_suggestion_for() in our controllers, which makes more sense in our application than the generic chat method.

3) Fetching suggestions for applications

The next use case we need to implement in the facade is the ability to fetch suggestions for admission applications.

  # app/facades/llm_facade
  class LLMFacade
    attr_reader :configuration, :llm_client

    def initialize
      @configuration = Configuration.new

      @llm_client ||= LLM::Client.new.login(credentials: configuration.credentials)
    end

    def fetch_suggestion_for(question, answer)
      llm_client.chat(parameters:
        default_parameters.merge(suggestion_parameters_for(question, answer))
      )
    end

    def fetch_suggestion_for_application(admission, application)
      llm_client.chat(parameters:
        default_parameters.merge(suggestion_parameters_for_application(admission, application))
      )
    end

    private

    def default_parameters = { model: configuration.model }

    def suggestion_parameters_for(question, answer)
      {
        messages: [
          { role: 'user', content: "This is the question I'm answering about: #{question}" },
          { role: 'user', content: "This is the answer I've written: #{answer}" }
        ]
      }
    end

    def suggestion_parameters_for_application(admission, application)
      {
        messages: [
          { role: "system", content: "I'm working on an admission application to my local university: #{admission.content}" },
          { role: "system", content: "Are there any improvements I could add to my application: #{application}" },
          { role: "user", content: "Format the suggestion like this: #{formatting}" }
        ]
      }
    end

    def formatting
      <<-TXT
        Suggestions:
        -
        -
      TXT
    end

    class Configuration
      # ...
    end
  end

If you thought, “But Remi, this new code looks awfully like the code used to fetch suggestions for answers!” you’re right.

The naming, the overarching concept, and the instructions are similar.

However, our new requirement also adds a custom instruction: formatting.

One advantage of the facade is that it gathers use cases in one place. Sure, the complexity grows along with your application, but since it’s not sprinkled everywhere in your application, it’s easy to identify and fix.

Let’s use that to our advantage and find an encompassing concept for these suggestions.

4) Refactoring using the flocking rules

Popularized by Sandi Metz, the flocking rules lean on the analogy of a bird flock where patterns emerge from displaying behaviors similar to those of surrounding individuals.

In our facade, the overall logic of generating feedback - whether for questions or admissions - is the same: a pattern emerges from the flock.

The flocking rules states that:

Let’s find the ad hoc abstractions for our parameters:

Now that we identified our similar behaviors, let’s refactor these two methods. Then, we’ll tackle the optional formatting parameter.

  # app/facades/llm_facade
  class LLMFacade
    attr_reader :configuration, :llm_client

    def initialize
      @configuration = Configuration.new

      @llm_client ||= LLM::Client.new.login(credentials: configuration.credentials)
    end

    def fetch_suggestion_for(directive, production, format = false)
      llm_client.chat(parameters:
        default_parameters
          .merge(suggestion_parameters_for(directive, production))
          .merge(formatting_parameters(format))
      )
    end

    private

    def default_parameters = { model: configuration.model }

    def suggestion_parameters_for(directive, production)
      {
        messages: [
          { role: 'user', content: "This is the directive I'm given: #{directive}" },
          { role: 'user', content: "This is the answer I've written: #{production}" }
        ]
      }
    end

    def formatting_parameters(format)
      return {} unless format

      {
        messages: [
          { role: 'user', content: "Format the suggestion like this: #{formatting}" }
        ]
      }
    end

    def formatting
      <<-TXT
        Suggestions:
        -
        -
      TXT
    end

    class Configuration
      ...
    end
  end

And voilà!

I removed the suggestion_parameters_for_application method, and updated the names of the parameters for suggestion_parameters_for.

To allow the optional formatting, I added a third argument with a boolean flag.

We could improve the naming of our methods with some riffing. Some ideas to best reveal the intention behind our code:

Using our facade in our controllers

Now that we have a working facade, let’s use it in our controller.

  # app/controllers/answers/suggestions_controller.rb
  module Answers
    class SuggestionsController
      def create
        llm_facade.fetch_suggestion_for(question, answer)
      end

      private

      def llm_facade
        LLMFacade.new
      end

      def answer
        Answer.find(params[:answer_id])
      end

      def question
        answer.question
      end
    end
  end

Sweet, right? No more boilerplate. No more crust. Just a very expressive and idiomatic call to our facade.

The facade pattern: key takeaways

  1. A facade creates an authoritative representation of an external API or any complex system you interact with.
  2. A facade serves as a bridge between an external API and the context of your application.
  3. A facade aggregates the use cases in one dedicated place.
  4. A facade prevents duplicated logic, reinventing the wheel, and creating multiple change points.

Cheers,

Rémi - @remi@ruby.social

  1. Are we, software engineers, so enticed with being actual engineers that we keep using architectural metaphors to lend ourselves some credibility? 

  2. Marcel Duchamp had the best mind for cool namings.