Anti-Corruption Layers Are Good

??? words · ??? min read

This is a work in progress from The Pipeline. It will likely change before it is published to the main listing of articles. It is currently at the "detailed" stage.

TODO: intro

What Is An Anti-Corruption Layer?

Anti-corruption layers are a design pattern for integrating external systems, in a nice, structured way. The term comes from the Domain Driven Design (DDD) community, but it is similar to the concept of “ports and adapters” or “hexagonal architecture,” which might be more familiar to the Ruby community.

The idea is to completely encapsulate all of the implementation details of the integration, underneath a simplified application-specific interface. All interactions with the external system are done through this interface, instead of being spread across the whole application in a haphazard way.

Anti-corruption layers make good use of OOP buzzwords like encapsulation, polymorphism, and duck typing. These concepts have a tendency to be overused and misapplied in inappropriate situations, but this is a situation where they can provide tangible benefits.

Example Implementation

For this article, we’ll be using DNS hosting as an example of an external system. Let’s say our app allows users to choose their own subdomain, like fred.example.com. To implement this, we need to add a DNS record to our example.com domain for each subdomain. Let’s also say that our domain is hosted on Cloudflare, so we will need to use their API.

Our anti-corruption layer would look something like this:

class CloudflareDNSProvider
  BASE_URL = "https://api.cloudflare.com/client/v4"

  attr_reader :zone, :auth_key

  # configuration is passed in through #initialize
  def initialize(zone:, auth_key:)
    @zone = zone
    @auth_key = auth_key
  end

  # this is the interface that the application uses
  def create_subdomain(subdomain)
    response = HTTP
      .headers('X-Auth-User-Service-Key' => auth_key)
      .accept(:json)
      .post("#{BASE_URL}/zones/#{zone}/dns_records", json: {
        type: 'CNAME',
        name: "#{subdomain}.example.com.",
        content: 'www.example.com.',
      })

    body = response.body.parse

    # the return value is a result object
    if body.fetch('success')
      Resonad.Success
    else
      Resonad.Failure(error_message(body))
    end
  end

  private

    def error_message(body)
      body.fetch('errors').map do |error|
        "Error #{error.fetch('code')}: #{error.fetch('message')}"
      end.join('. ')
    end
end

This object is usually created when the app boots up. There are a few places this could happen, here is one simple approach for Rails apps:

# config/environments/production.rb

DNS_PROVIDER = CloudflareDNSProvider.new(
  zone: ENV.fetch('CLOUDFLARE_ZONE'),
  auth_key: ENV.fetch('CLOUDFLARE_AUTH_KEY'),
)

Then the constant can be used from anywhere within the app.

class UsersController < ApplicationController
  def create
    #...
    result = DNS_PROVIDER.create_subdomain(user.slug)
    if result.ok?
      render json: { success: true }
    else
      render json: { error: result.error }, status: 422
    end
  end
end

Lastly, we will want a stub provider, which implements the same interface, with extra methods to help out during testing.

class StubDNSProvider
  def initialize
    @created_subdomains = []
    @always_fail_message = nil
  end

  # this is the same interface as CloudflareDNSProvider
  def create_subdomain(subdomain)
    if @always_fail_message
      return Resonad.Failure(@always_fail_message)
    end

    if @created_subdomains.include?(subdomain)
      Resonad.Failure("Error 1234: Subdomain already exists")
    else
      @created_subdomains << subdomain
      Resonad.Success
    end
  end

  # this method is used to test our apps logic
  def has_created_subdomain?(subdomain)
    @create_subdomains.include?(subdomain)
  end

  # this method is used to test the failure cases of our app
  def always_fail!(message)
    @always_fail_message = message
  end
end

The stub provider takes the place of the real provider during testing.

RSpec.describe "Creating a user" do
  before { stub_const('DNS_PROVIDER', dns_provider) }
  let(:dns_provider) { StubDNSProvider.new }

  it "creates a subdomain" do
    post "/users", name: "Marge Simpson"

    # nice, descriptive expectations
    expect(dns_provider).to have_created_subdomain('marge-simpson')
  end

  it "handles failure gracefully" do
    # force the stub to return a failure
    dns_provider.always_fail!("Monorail!")

    post "/users", name: "Maggie Simpson"

    expect(last_response.status).to eq(422)
    expect(JSON.parse(last_response.body)).to eq({
      error: "Monorail!",
    })
  end
end

Anti-Corruption Layers Are Good

  • They make the calling code better
    • They move code/responsibilities out of other classes (like controllers/models) into dedicated classes. The total code hasn’t shrunk (it has actually grown slightly), it has just been moved elsewhere.
    • The calling code reads better, because the friendly interface indicates who is being called (which external system), and what is being called (a nicely named method).
    • It makes the level of abstraction consistent. The low level implementation is moved out, leaving only the higher level operation.
    • Show before and after
  • They allow you to swap out implementation
    • Because these layers have well defined interfaces, the implementations can easily be swapped out. If you want to change from one provider to another, you can just write a new layer for the new provider. You can keep the old provider’s layer around too, and even switch between the two at run time with something like Flipper.
    • The interface also allows you to mock out the layer during test, which we will see later.
  • They make your existing tests better
    • Having a well-defined interface allows you to write a mock that implements the interface.
    • The mock is used exactly the same way as the real implementation.
    • The mock records the way in which it is used.
    • The mock has methods for querying its state, which can be used from tests.
    • Following RSpec naming conventions allows the tests to read nicely.
    • Things that were hard to test without actually hitting the real external system are now very easy to test.
  • They provide better test coverage
    • Because it’s easier to test side effects, people will be more likely to actually test their stuff
    • You can write adapter tests, which fully exercise the external system in isolation, instead of trying to test the external system by testing your own app
    • VCR is really good here, and you shouldn’t have a lot of the pains that VCR tests sometimes have
  • They prevent external systems from muddying your design
    • This is one of the main goals of the pattern, at least from a DDD perspective
    • Implementation details are entirely encapsulated, so they can’t leak out into your code
    • The interface of the layer is tailored to your app, not the external service
    • The layer is responsible from translating into “App-anese” into the language of the external service (e.g. “create subdomain” to “create CNAME DNS record”)

Response to likely objections

  • It’s more code
    • The implementation has to go somewhere. You can either mix it into your application code, or you can isolate it into its own class.
    • The amount of extra code depends on how complicated the interface is. Simple interfaces require almost no extra code, complicated interfaces might require a substantial amount of boilerplate.
  • It’s unnecessary complexity
    • The complexity already existed in your app.
    • If you were using a a gem for the external dependency, you’ve just incorporated all of the gems complexity, and now you’re married to it.
    • A well defined interface does the opposite: it simplifies application code and tests. Yes, there will be extra work to implement the adapter, but it’s isolated, so it should be a “one and done” situation.

Conclusion

  • Anti-corruption layers are a design pattern that makes interacting with external dependencies easier.
  • Anti-corruption layers are good.
    • They make calling code better
    • They make tests better
    • They protect your application code from foreign concepts leaking in
    • They increase test coverage
    • They are more flexible
  • The downsides are limited
    • It’s not that much more code
    • The conceptual overhead is low, considering that those concepts already exist either in your application, or in a gem you’re using
  • Next up: how to write an anti-corruption layer in X easy steps

Got questions? Comments? Milk?

Shoot an email to [email protected] or hit me up on Twitter (@tom_dalling).

Next up: Backlog →

Join The Pigeonhole

Don't miss the next post! Subscribe to Ruby Pigeon mailing list and get the next post sent straight to your inbox.