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:
- 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 - they could change the response format in which they tell us about the email delivery status, or
- 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.