Third-Party Code Boundaries

by Javier Treviño Saldaña

In order to protect your system from breaking due future third-party code changes, you can write classes in terms of the use cases you care about.

Every library you bring in has a special/unique way of achieving what you want, by isolating that “uniqueness” you protect your system from change, and allow alternatives easily.

Boundary Class

As an example use case, let’s say we’re interested in showing the latest 3 tweets made by an account.

Let’s pretend the Twitter library of your choice wants you to write:

@tweets = client.user("javiersaldana").tweets.take(3)

def client
  Twitter::REST::Client.new do |config|
    config.consumer_key        = "YOUR_CONSUMER_KEY"
    config.consumer_secret     = "YOUR_CONSUMER_SECRET"
    config.access_token        = "YOUR_ACCESS_TOKEN"
    config.access_token_secret = "YOUR_ACCESS_SECRET"
  end
end

This is a real example and luckily is very readable. Still, I would take it a step further and protect that readability by wrapping the code in a class that separates the rest of my system from changes to the library.

@tweets = Twitter.user("javiersaldana").tweets.recent.take(3)

class Twitter
  def self.user(username)
    new(client.user(username))
  end

  def initialize(user)
    @user = user
  end

  def tweets
    @user.tweets
  end

  private

  def self.client
    Twitter::REST::Client.new ...
  end
end

I can keep writing the same code I liked, and if the library maintainers decide to go in a different direction, I can just update my boundary class and the rest of my system won’t know it.

Now let’s say a few years go by, and a security vulnerability forces you to upgrade your Twitter dependency, but with the upgrade comes a new way to access the tweets:

feed = client.feeds.get("javiersaldana")
@tweets = feed.tweets.take(3)

If I hadn’t protected the rest of my system with a wrapper, I’d have to update the contract everywhere. Instead I can keep the contract my system cares about:

@tweets = Twitter.user("javiersaldana").tweets.recent.take(3)

Just by updating:

class Twitter
  def self.user(username)
-    new(client.user(username))
+    new(client.feeds.get(username))
  end

  def initialize(user)
    @user = user
  end

  def tweets
    @user.tweets
  end

  private

  def self.client
    # ...
  end
end

This is also known as the adapter design pattern, it helps maintain your system’s “view of the world” instead of polluting it with incompatible architectures or goals.

Dependency Inversion Principle

Let’s change gears a little bit. Now imagine we created a boundary class to send text messages (SMS) because we want to notify a customer they will receive an order soon.

class SMS
  def send(to:, message:)
    client.messages.create(
      to: to,
      from: DEFAULT_FROM,
      body: message
    )
  end

  private

  def client
    Twilio::REST::Client.new ...
  end
end

You can take the boundary class a step further by following the Dependency Inversion Principle, which states:

A) High-level modules should not depend upon low-level modules.
Both should depend upon abstractions.

B) Abstractions should not depend upon details.
Details should depend upon abstractions.

That is, instead of coupling your use case with the mechanics of sending an SMS, take the SMS to a higher level of abstraction. Write code in terms of your (high-level) use case.

You want to send an SMS to achieve something. In this pretend scenario we want to notify a customer about an order on its way.

customer_notifier = Notifications::Customer::SMS.new
customer_notifier.order_shipped(order)

class Notifications::Customer::SMS
  def order_shipped(order)
    SMS.send(
      to: order.customer_phone_number,
      message: "Order #{order.id} is on its way"
    )
  end
end

By raising the level of abstraction we gain flexibility and clarity. We can change the notification mechanism completely (to a voice call notification for example) and the rest of the system won’t even notice because we used an abstraction. The reader would understand at first glance you’re notifying a customer about their orders status. It doesn’t matter how at high-level flows. And thus we invert the dependency, because the SMS details are dictated by the order_shipped notification abstraction.

This technique also helps increase our automated tests coverage. Just like we can create a voice call notifier, or email notifier, we could also create a “fake” notifier to spy calls in our test environment, and verify it receives the expected notification.


xkcd: The General Problem

xkcd: (Solving) The General Problem

I find that when someone’s taking time to do something right in the present, they’re a perfectionist with no ability to prioritize, whereas when someone took time to do something right in the past, they’re a master artisan of great foresight.