How the Provider pattern saved our project
A couple months ago my team started a pretty large rewrite of a portion of our codebase. We wanted to modernize the frameworks driving the UI, from CoffeeScript and AngularJS to Typescript and Angular 9, and replace some third-party dependencies with new and improved alternatives.
This new micro-service would deeply integrate with a number of third parties, and in the long run we weren’t really sure how those third parties would change or if they’d even run concurrently. Rather than try and answer this upfront, we opted to instead implement the “Provider pattern” in our app. In short, this architectural style is about encapsulating a third-party library in one file, and then requiring your application to interface with it through a generic superclass. Then if you want to change providers, you need only change the generic superclass; the rest of your application does not care.
A common example of the Provider pattern is probably your logging service. Most applications will log through a generic API like loggerService.warn
, but where those logs actually go can vary depending on what provider you choose. You could output to a simple log file, or perhaps a cloud service, or even STDOUT
; in all cases, the vast majority of your application code does not need to change when you change the provider.
It’s not too hard to adopt this pattern with other services, and the wins are huge.
Step 1: Define the layer between your provider and the rest of the application
Suppose our application is around authoring blog posts, and the third-party service is wherever we’re publishing our post. Maybe we want to post to Medium for now, but we might want to publish to Wordpress later. Here’s our initial setup:
export class PublishingService {
provider: PublishingInterface;constructor(private publisher: MediumPublisher) {
this.provider = publisher;
}
}
Most of our application code will inject PublishingService
and interact with publishingService.provider
for any action it wants to take, e.g. publishingService.provider.action
.
Step 2: Define your interfaces
There are usually two interfaces required: one that your providers will implement, and one that represents the resource being acted on.
interface PublishingInterface {
publish(post: Post): void {}
unpublish(post: Post): void {}
[…]
}interface Post {
author: Author;
title: string;
[…]
}
Note that the PublishingInterface
’s function names are around actions that users might be taking, and not around the underlying CRUD resource. By using publish
and unpublish
instead of edit(publish: boolean)
, we’re decoupling most of the application from how one provider implements publishing versus another. Maybe on Medium it is in fact editing a post, whereas in our custom Wordpress site it’s a completely different endpoint; our application doesn’t care, it just tries to publish.
Further, the Post
interface is decoupled from Medium’s concept of a post. Medium might have different names for attributes, but by normalizing our names we can accommodate other third parties in the same interface.
Step 3: Build our provider
In addition to implementing our PublishingInterface
, we also define a provider-specific interface that we transform our generic resource into. This isn’t always necessary, but I find it helps structure your data better.
interface MediumPost {
[ provider-specific attributes ]
}export class MediumPublisher implements PublishingInterface {
publish(post: Post): void {
[ transform Post into MediumPost, then call out to Medium ]
}unpublish(post: Post): void {
...
}
}
Step 4: Introducing a new provider
In the naive case of introducing a new provider, you would just swap one out for another by changing your PublishingService
:
export class PublishingService {
provider: PublishingInterface;constructor(private publisher: WordpressPublisher) {
this.provider = publisher;
}
}
If you only need one provider, this is perfect; Your application doesn’t need to change at all, and it will still seamlessly publish to your new provider.
Suppose instead you want to publish to Medium and Wordpress at the same time. There are two ways to do this: call both providers in your PublishingService
, or introduce a layer whose sole job is to call both. I prefer the latter because it makes going back to one provider much easier: just delete the extra layer and change your PublishingService
to whichever provider you choose.
export class PublishingService {
provider: PublishingInterface;constructor(private provider: GluePublisher) {
this.provider = provider;
}
}export class GluePublisher implements PublishingInterface {
constructor(private mediumPublisher: MediumPublisher, private wordpressPublisher: WordpressPublisher) {}publish(post: Post): void {
this.mediumPublisher.publish(post);
this.wordpressPublisher.publish(post);
}unpublish(post: Post): void { [...] }
}
Word of caution: don’t throw abstractions around freely
There is a lot of risk in introducing abstractions like this. It’s pretty easy to build an abstraction, but it can be really hard to remove. I’ve seen it implemented so poorly that there were 7 layers to dive through before you could go from the application interface to the provider itself.
By keeping our abstraction lean, and the interfaces clean, the Provider pattern has enabled us to experiment with different providers, run two at once to compare, and iteratively switch from one to another.