Interface-mock-live (IML) pattern for connecting with third-party services in Python applications
To KISS or not to KISS
I remember how, several years ago at Doist, we talked about integrating with external services, like email providers or analytics tools. The question was whether to use a simple function or an abstraction layer.
We ended up with having two groups.
KISSers. The first one advocated for following the KISS principle and calling the service API directly. Something as simple as storing an API token in your settings, then calling
requests.get()
on an endpoint. Wrap this in a function you’re done.Abstractionists preferred to avoid direct connections to external services, instead opting for an abstraction layer. Unlike the KISSers, the abstractionists chose to define clear interfaces first. Then you would usually have at least two implementations. You would have a mock implementation for your development environment and for tests, and a real implementation for production.
The KISS proponents argued that adding an abstraction layer was just a waste of time. “We’re building an app, not a library,” they said. It’s not likely we’d need to juggle multiple integrations at once. For example, if you switch from Postmark to Sendgrid for sending greeting emails, you wouldn’t keep both in your code. When you change providers, you simply tweak the function to point to a new endpoint. Besides, if you only test and develop with a mock implementation, how can you be sure the real deal works as it should?
On the other side, the abstractionists believed that an abstraction layer doesn’t just promise future flexibility, but also brings immediate perks for development. It may sound a bit heavy, but this approach is actually quite common. For example, it’s similar to how you usually configure email settings in Django.
Back in the day, I couldn’t make up my mind about which side to pick. The arguments from both the KISSers and abstractionists were pretty convincing. Fast-forward a few years, after dabbling in various codebases as an independent consultant, I’ve started to lean towards the abstractionist way. These days, whenever I hook up a third-party service, I tend to follow the interface-mock-live pattern, even for my own projects. In what follows, I’ll explain how this method works, its benefits, and how to handle its potential downsides.
Third-party integration problem
Now, let’s get a clearer picture of the problem. Imagine you’re working with a monolithic codebase that relies on external services for various tasks – sending emails, geocoding, collecting analytics, and more.
Developing these integrations usually isn’t too tough at the start. A developer is assigned to the task. They generate an API key, tweak their provider and local environments, and create a wrapper function to interact with the third-party service.
Once everything’s set, it’s time for deployment to production. The developer oversees this process, assisting with setting up the production environment and other details. If they’re diligent (or if the team has robust processes in place), they’ll document everything they’ve done, detailing the behavior and other important information. The task is complete, the ticket is closed, and the team eagerly shifts focus to the next challenge.
The issue arises when a second developer, after pulling the latest code, suddenly finds their app malfunctioning. Trying to test the sign-in flow, they’re greeted with a “Mixpanel not configured” error, or something similar. Clearly, something’s changed, but what exactly? Now, they have to set aside their current task, figure out what the first developer did, and configure their own environment accordingly.
Imagine a team of six developers. Each one occasionally adds a new integration, and the remaining five have to constantly update their setups, add mock decorators to their tests, and so on. This quickly spirals into an unproductive cycle. The larger the app and the team, the more severe the issue becomes.
For new developers, the problem is even more daunting. It’s no longer a simple task to set up a basic version of the app. Newcomers are overwhelmed with the need to create dozens of accounts for various third-party services, configure their environments, or deal with persistent exceptions from obscure parts of the app. This can be extremely frustrating, turning what should be an exciting onboarding process into a week-long ordeal, just to get the app running on a semi-functional local setup.
To recap:
- Developers need access to external services, which require configuration. In a large codebase, you might encounter dozens of these external integrations.
- Calls to these services are frequently side effects of other actions, making isolation difficult or impossible. For instance, testing a simple action like user creation can involve numerous mocks, patches, and specific conditions like “if in dev mode, don’t send email.”
Interface-Mock-Live (IML) pattern
Adopting the IML pattern makes setting up development environments much easier, simplifies writing tests, and clearly localizes changes in integrations within the codebase.
Now, let’s dive into an example of the IML pattern, where we’ll explore its benefits and limitations.
I’ll share a simplified version of a recent project where I integrated an email provider into an application – in this case, Mailgun.
A simple KISS example, slightly tweaked from the Mailgun QuickStart guide, goes like this:
import os
import requests
MAILGUN_DOMAIN_NAME = os.getenv("MAILGUN_DOMAIN_NAME")
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY")
def send_email(from_: str, to: str, subject: str, text: str):
response = requests.post(
f"https://api.mailgun.net/v3/{MAILGUN_DOMAIN_NAME}/messages",
auth=("api", MAILGUN_API_KEY),
data={
"from": from_,
"to": to,
"subject": subject,
"text": text,
},
)
response.raise_for_status()
In this snippet, we set up the global configuration for Mailgun using environment variables. The rest is pretty much by the book. To get this code working, just make sure these variables are set in your environment, and you’re good to go.
Then, we can use the code like this:
def register(request: HttpRequest):
# Register the user, and then send a welcome email
# ...
send_email(
from_="welcome@example.com",
to=request.user.email,
subject="Welcome to our service",
text=f"Thanks for signing up, {request.user.name}",
)
An average kisser would just make the setup work, commit the changes to the repo, and then call it a day. This approach works, but it comes with a few side effects:
- The Mailgun integration needs to be configured on every developer’s machine, in the CI for automated tests, and in the production environment, among others.
- When the Mailgun integration is active, it really sends out emails — and we don’t always want that happening.
There are more issues with this setup, but I’ll delve into those later.
One way to address this is by using a boolean flag in the settings, like MAILGUN_SEND_EMAIL
. You can have it turned off in development and on in production. The Interface-Mock-Live approach is essentially a more advanced version of this boolean flag, offering more flexibility for future adjustments and enhancements.
IML implementation
Step 1. Define an Interface as an Abstract Base Class.
The interface for an email sender could look something like this:
# file: email/interface.py
import abc
class IEmailSender(abc.ABC):
@abc.abstractmethod
@classmethod
def from_environ(cls) -> IEmailSender:
...
@abc.abstractmethod
def send_email(self, from_: str, to: str, subject: str, text: str) -> None:
...
I typically start class names with I
to highlight that they describe an interface.
Notice how we don’t specifically mention Mailgun in the class and module names. Sure, maybe Mailgun is all we’ll ever need, but as we’re building an abstraction layer, it’s wise to keep our options open for potentially implementing other providers in the future.
Also, starting with the interface rather than the implementation shifts the focus to our own code and business needs, rather than being constrained by the specifics of the service’s API. In my experience, when I began with the API and then built an abstraction around it, I often ended up with an abstraction layer too tightly coupled to the specific implementation, creating unnecessary constraints.
Another addition here is the classmethod which I’ll use later for a unified approach to initializing the object from environment variables. Depending on your framework’s setup, this classmethod could leverage something like django.conf.settings or a similar mechanism to create an instance.
Step 2. Define the Mock Implementation
The mock class essentially does nothing (or almost nothing), embodying the Null object pattern. As the name suggests, it’s also an implementation of the mock pattern in software testing, as outlined in Test Doubles.
# file: email/mock.py
import logging
logger = logging.getLogger(__name__)
class MockSender(IEmailSender):
@classmethod
def from_environ(cls) -> IEmailSender:
return MockSender()
def send_email(self, from_: str, to: str, subject: str, text: str) -> None:
logger.info("Sending email from %s to %s", from_, to)
In this setup, instead of dispatching a real email, the mock class just logs a message. This can be really handy in a development environment, where you might want to visually verify that certain actions, like user registration, are triggering email sends.
Step 3. Define the Live Implementation
Now, it’s time to create a live implementation, which could be one among many, or perhaps the only one you’ll need.
# file: email/mailgun.py
import os
import requests
class MailgunSender(IEmailSender):
@classmethod
def from_environ(cls) -> IEmailSender:
return MailgunSender(
domain_name=os.getenv("MAILGUN_DOMAIN_NAME"),
api_key=os.getenv("MAILGUN_API_KEY")
)
def __init__(self, domain_name: str, api_key: str):
self.domain_name = domain_name
self.api_key = api_key
def send_email(self, from_: str, to: str, subject: str, text: str) -> None:
resp = requests.post(
f"https://api.mailgun.net/v3/{self.domain_name}/messages",
auth=("api", self.api_key),
data={
"from": from_,
"to": to,
"subject": subject,
"text": text
}
)
resp.raise_for_status()
Notice how the constructor uses arguments to initialize itself. This design enables the reuse of the same class in different environments or to initialize the class independently of any configuration in unit tests.
Additionally, the @from_environ
classmethod acts as a bridge, connecting the class with the rest of the framework.
Step 4. Initialize Implementation
The exact method of initialization can vary depending on your framework. In this example, we’ll use environment variables to fetch the implementation.
# File: email/services.py
import os
from importlib import import_module
from functools import cache
@cache
def get_sender() -> ISender:
email_sender = os.getenv("EMAIL_SENDER", "myapp.email.mock.MockSender")
module, classname = email_sender.rsplit(".", 1)
class_ = getattr(import_module(module), classname)
return class_.from_environ()
In this approach, the implementation class is determined by the EMAIL_SENDER environment variable. It’s then instantiated by calling its from_environ() class method. The result is cached.
Step 5. Use It
With everything set up, the registration function can now be implemented as follows:
def register(request: HttpRequest):
# Register the user, and then send a welcome email
# ...
sender = get_sender()
sender.send_email(
from_="welcome@example.com",
to=user.email,
subject="Welcome to our service",
text=f"Thanks for signing in, {request.user.name}",
)
This implementation uses the get_sender function to obtain the appropriate email sender (be it mock or live, depending on the environment). It then utilizes this sender to dispatch a welcome email after user registration.
Advantages of the IML pattern
Implementing three classes and a helper function instead of a single, simple function may seem excessive. However, there are some good reasons to adopt it. Even unmodified, your mock implementation is helpful:
- Consistently Available: It’s always up and running, providing quick and predictable responses. You can explore its state without worrying of downtime or inconsistency.
- Speeds Up Development: Think about the drag of hitting the same button a thousand times, each with a delay from third-party services. Mocks eliminate this wait, noticeably quickening your development cycle.
- Safe Development Zone: It’s a sandbox where no test emails mistakenly reach real clients. You develop with the assurance that there are no unintended consequences.
- Ease of Onboarding: As systems grow, they tend to accumulate external dependencies, much like a ship collecting barnacles. For a new developer, this means either creating numerous external accounts or dealing with parts of the system that are broken or error-prone. Mocks sidestep this issue, making everything work right from the start. When it’s time to focus on a specific service, simply switch to the real implementation.
- Promotes Simplicity in Interfaces: The need to create a workable mock version of a real service naturally steers you towards more streamlined interfaces. You’ll lean towards designs that are straightforward, avoiding unnecessary complexities.
But there’s more you can do with mocks.
- Throw Exceptions: While working on an email implementation, I needed to develop a fail-safe email delivery mechanism: if an exception is thrown upstream, the message is queued, and sending attempts are repeated. With only real email sending, testing this would be a long and a painful road. My mock implementation includes an optional
raise_exception
attribute, enabling me to write unit tests that cover the necessary business logic. - Return Mock Data: For my Text Refiner, which uses OpenAI for grammar correction, a mock implementation significantly sped up the development process. The OpenAI API introduces a delay, which becomes cumbersome when testing repeatedly. With a mock, I could focus on UI development without breaking the workflow, thanks to immediate mock responses.
- Switch Between Implementations Easily: It may sound straightforward, but I want to emphasize this anyway. An interface that isn’t tied to a specific implementation makes it easy to swap different implementations in and out. All the changes stay in one place.
- Use a Proxy or Router: Not only can you switch between implementations, but you can also use both at the same time, choosing one, another, or all of them simultaneously, and dynamically re-routing function calls. In Page Analytics, I used Mixpanel for user events, but it didn’t do everything I needed. So, I decided that good old SQL plays better for some cases. I made a second implementation that used the same interface as my Mixpanel client but stored data in tables. Since Mixpanel was still good for some things, and SQL for others, I ended up making a proxy that sent data to both, sort of like the Unix tee command. And yeah, the proxy was also an implementation of the same interface.
- Move Your Data Easily: With an interface that controls where data goes, you can quickly set up a dual writing pattern. This is super helpful for moving data around. To see how this works, check out Online migrations at scale from Stripe.
These features – switching implementations, using proxies, and moving data – show just how handy this approach can be.
Drawbacks of mock implementations
While mock implementations are useful, they’re not perfect. One big issue is that you might end up using an integration in production that hasn’t been tested enough.
A real third-party system often doesn’t work as smoothly as you’d expect. It might unexpectedly throw errors for some seemingly valid input, not accept certain input combinations, or give back something you didn’t expect. Your authentication keys could expire, or the service might go down. These problems don’t show up until you actually start using the real system. And when you do find these issues, you can’t always write a test for them because your tests use the mock version.
Writing tests against the real deal is tough. In unit tests, the real implementation is often a blind spot, not really covered. I don’t have a perfect solution, but to make it a bit easier, here’s what I do.
I break down the work into smaller parts or utility functions. For example, if the real implementation formats the parameters, and then sends it, I take the formatting part and make it into its own function that I can test separately.
Exaggerating a bit for clarity, here’s how the MailgunSender
code could be structured:
class MailgunSender(IEmailSender):
def send_email(self, from_: str, to: str, subject: str, text: str) -> None:
data = self.prepare_request(from_, to, subject, text)
resp = requests.post(
f"https://api.mailgun.net/v3/{self.domain_name}/messages",
auth=("api", self.api_key),
data=data,
)
self.validate_response(resp)
def prepare_request(self, from_: str, to: str, subject: str, text: str) -> dict[str, str]:
return {
"from": from_,
"to": to,
"subject": subject,
"text": text
}
def validate_response(self, resp) -> None:
resp.raise_for_status()
Now, I can test prepare_request()
and validate_response()
separately, leaving only the request sending line without a test.
Testing the Real Implementation (Disabled by Default): I have tests for the real integration, but they’re usually turned off. They depend on the external system being set up, which can range from simply grabbing a test API key to more complex setups like creating specific objects in that system (like Stripe products with certain parameters or specific email templates). These tests are off by default. In pytest, I use markers and skipif clauses to control this, and unittests offer skipIf. The easiest way to turn these tests on is through environment variables, such as running WITH_EXTERNAL_SERVICE_FOO=true pytest
.
Mimicking Real System Behavior in Mocks: If I encounter a notable discrepancy between how the real and mock implementations behave, I try to mimic this behavior in the mock. This is particularly useful if I need to simulate errors. I can replicate these errors reliably in the mock and then see how the rest of the system handles them.