A few years ago I co-founded a SaaS startup with a few friends. As the technical co-founder, I was the main point of contact for everything related to software. This meant that when our company had to go through a technical due diligence, I had to face all the questions from the auditers and their feedback on our software stack.

One of the discussion points was the architecture of our backend source code. The one thing that stuck with me during this discussion was when we started talking about how our application sends emails. The conversation went something like this:

Them: So how does your application send emails?

Me: Well, we use a cloud service provider. Our backend talks to this provider over HTTP via their REST API and they handle the actual email delivery.

Them: Do you have an example of where in your codebase this happens?

Me: There are a few HTTP endpoints which need to send emails. User registration is one, for instance.

Them: What happens to your codebase if you need to switch the service provider overnight?

Me: ...

The problem

Let's try to visualize the problem for a moment. Assume we have an HTTP endpoint on the backend that gets called whenever a user registers on our platform. And apart from storing the user data in the database, this endpoint also sends the user an email so we can verify their email address.

import os

import requests
from starlette.endpoints import HTTPEndpoint

class UserRegistrationEndpoint(HTTPEndpoint):
    def post(self):
        # ...
        
        response = requests.post(
            EMAIL_SERVICE_ENDPOINT,
            auth=("api", os.getenv("EMAIL_SERVICE_API_KEY")),
            json={
                "to": user.email,
                "from": "[email protected]",
                "subject": "Please verify your email!",
                "text": "Please click on this link to verify your email: {{ link }}",
                "html": "Please click on this link to verify your email: {{ link }}",
            }
        )
        
        if response.status_code not in (200, 201, 202):
            # log something / notify someone
        
        # ...

The main problem here is that UserRegistrationEndpoint needs to know the internals of the email service provider's API. This includes things like what the request / response body should look like, how to authenticate with the provider, etc. So in case there are any changes with the provider's API, we need to adjust UserRegistrationEndpoint too. Such changes could take a few different forms:

  1. the service provider could introduce a breaking change in their API (for instance changing the authentication mechanism, which for us would mean adjusting the auth argument), or
  2. they could change the response format in which they tell us about the email delivery status, or
  3. we want to swap out the service provider completely in favor of a different provider, which would likely change all the arguments to requests.post

Normally, this wouldn't have been a big problem if there was only one such endpoint.

Imagine though that there are 10 HTTP endpoints, each with a requests.post call similar to the snippet above. What's more? If you have integration tests for these endpoints, you're most likely mocking requests.post in the tests and asserting the arguments with which the function is called. Changing the underlying call inside the endpoint means adjusting all the associated test cases.

The solution: Facade

It turns out that this problem is so well known that there's a specific design pattern which can be used in such situations: a Facade.

According to its Wikipedia page:

Analogous to a facade in architecture, a facade is an object that serves as a front-facing interface masking more complex underlying or structural code. A facade can:

  • ...
  • serve as a launching point for a broader refactor of monolithic or tightly-coupled systems in favor of more loosely-coupled code

This is a perfect fit for us. While our underlying functionality may not be too complex at the moment, exposing a strictly defined email API to the HTTP endpoints layer will definitely decreasing the coupling between them.

To do this, we first need to define an (internal) API for sending emails. Next, we can add implementation code responsible for interacting with the service provider. The second step should be done in a way that the implementation conforms to the interface defined in the first step.

Let's start with defining the API.

class BaseService:
    def send(
        self, to: str, from_: str, subject: str, html: str, text: str
    ) -> None:
        raise NotImplementedError()

This snippet defines an interface in the form of a base class that child classes should inherit. Child classes are also required to override the send method, failing which the caller will receive a NotImplementedError.

Let's define one such child class now.

import os

class BaseService:
   # ...
        
class FooService(BaseService):
    def send(
        self, to: str, from_: str, subject: str, html: str, text: str
    ) -> None:
        endpoint = os.getenv("FOO_SERVICE_ENDPOINT")
        api_key = os.getenv("FOO_SERVICE_API_KEY")
        
        response = requests.post(
            endpoint,
            auth=("api", api_key),
            json={
                "to": to,
                "from": from_,
                "subject": subject,
                "text": html,
                "html": text,
            }
        )
        
        return response.status_code in (200, 201, 202)

def active_email_service():
    name = os.getenv("EMAIL_SERVICE")
    
    if name == "foo":
        return FooService()
    else:
        raise UnknownEmailServiceError(name)

We can now go back to the original HTTP endpoint and adjust it as follows:

import requests
from starlette.endpoints import HTTPEndpoint

from app.services.email import active_email_service

class UserRegistrationEndpoint(HTTPEndpoints):
    def post(self):
        # ...
        
        active_email_service().send(
            user.email,
            "[email protected]",
            "Please verify your email!",
            "Please click on this link to verify your email: {{ link }}",
            "Please click on this link to verify your email: {{ link }}",
        )
        
        # ...

Benefits of the new approach

This new approach has multiple benefits. Let's quickly have a look at the top three.

1. Ease of defining new service providers

It's now really easy for us to add new service providers and switch between them (overnight, if the need be). For instance, if we had to switch from the "foo" provider to "bar", we would define a BarService class as follows:

class BarService(BaseService):
    def send(
        self, to: str, from_: str, subject: str, html: str, text: str
    ) -> None:
        endpoint = os.getenv("BAR_SERVICE_ENDPOINT")
        api_key = os.getenv("BAR_SERVICE_API_KEY")
        
        return requests.post(
            endpoint,
            auth=("api", api_key),
            json={
                "to": to,
                "from": from_,
                "subject": subject,
                "text": html,
                "html": text,
            }
        )

... and then change active_email_service as follows:

def active_email_service():
    name = os.getenv("EMAIL_SERVICE")
    
    if name == "foo":
        return FooService()
    elif name == "bar":
        return BarService()
    else:
        raise UnknownEmailServiceError(name)

With this in place, changing service providers ends up becoming a matter of changing an environment variable.

2. Breaking API changes can be handled easier

Let's go back to the problem that comes up in case a service provider introduces a breaking API change. For instance, a service provider could change the username they require in their HTTP Basic Auth implementation.

In the original implementation, we would have had to adjust every single HTTP endpoint that was sending emails. Accordingly, we would have also had to adjust all the associated tests.

With the new approach in place, the scope of such changes is strictly limited to *Service.send methods. For instance, if the "foo" service changes their username from api to api_user, this can be adjusted in FooService.send as follows:

class FooService(BaseService):
    def send(
        self, to: str, from_: str, subject: str, html: str, text: str
    ) -> None:
        ... 
        
        return requests.post(
            # ...
            auth=("api_user", api_key),
            # ...
        )

The HTTP endpoints can carry on with their lives as though nothing happened.

3. Well-defined boundaries when writing tests

The third advantage is visible when writing tests, and is slightly related to the second advantage (of being able to handle breaking API changes easier).

After implementing this change, we have limited the HTTP endpoint's concern to the email service layer. This means that the endpoint tests should make sure that its asking active_email_service to do the right thing, based on the arguments being passed. Testing how the emails are sent is something that tests for FooService (and BarService) should take care of.

So when writing integration tests for UserRegistrationEndpoint, we no longer have to mock the requests module. Instead, we could (for example) create a mock service for the test environment or have active_email_service return a mock object which we can use to assert on function arguments. This specific implementation is up to you, depending on your application's source code.

Conclusion

I hope that through this post, I was able to make a good case for using the facade pattern and its applicability to the problem of working with external service providers.

If you're looking for more information on this specific pattern, the Wikipedia page is an excellent resource. And if you're interested in learning more about design patterns in general (and implementation examples in Python), I would definitely recommend checking out Python Design Patterns.


PS: Some of you might have noticed that this concept is applicable to working with external services in general, not just with email service providers. For instance, if your SaaS app is storing files on object storage services like Amazon S3 or Google Cloud Storage, you could use the same approach and add a facade layer for it.


Photo by Joanna Kosinska on Unsplash

Launch your next SaaS quickly using Python 🐍

User authentication, Stripe payments, SEO-optimized blog, and many more features work out of the box on day one. Simply download the codebase and start building.