Apr 18 2020

Avoid Vendor Lock-in with Interfaces

Vendor lock-in can be a painful experience. There are two main issues that I have noticed:

  1. Tight coupling to the service
  2. Data migration from one service to another.

I will address the first issue in this post.

Tight coupling is a design flaw that should be mitigated if possible. The best time to eliminate coupling is when it is first written. Loose coupling allows us to swap services with minimal effort.

It is super important to get this pattern correct. I foresee service coupling becoming a larger issue in the future. Most software nowadays integrates with a bevy of SaaS products. As this trend continues to grow, so will service coupling. It will be increasingly important to be able to change services “on a whim”. Some reasons that come to mind as reasons to change services are:

With this being said, let us imagine using an authentication service. I choose this because authentication is a hard problem to get right. It is one of those features that everybody needs and I have found myself reimplementing it countless times. When done correctly, the best choice may be delagating to an authentication provider.

We will have two different authentication implementations. Both implementations adhere to some interface. The first implementation will use the provider as a dependency. The second will be a mock implementation: this allows for offline development.

Typescript is awesome for interfaces, so that is what we will use.

export interface AuthService {
  login(): Promise<void>;
  logout(): Promise<void>;
  isAuthenticated(): Promise<boolean>;
  getJwtToken(): Promise<string>;
}

Next we can implement both strategies. Again, one strategy will use the authentication provider. The other will be a mock.

Let’s implement the authentication provider. I recommend finding an open source client to leverage. Usually providers will maintain a package to interface with their service. The goal is to wrap the client’s functionality with our implementation. The following class is both a strategy pattern and an adapter pattern.

import createVendorAuthClient, { VendorAuthClient } from 'someauthservice';

import { AuthService } from './authService'
import { VendorSession, Session } from '../../models/session'

export default class VendorAuthService implements AuthService {
  client: VendorAuthClient;
  redirectUri: string;

  constructor() {
    this.client = createVendorAuthClient()
    this.redirectUri = process.env.REACT_APP_REDIRECT_URI
  }

  async login() {
    return this.client.loginWithRedirect({ redirect_uri: this.redirectUri });
  }

  async logout() {
    return this.client.logout({ returnTo: this.redirectUri });
  }

  async isAuthenticated() {
    return client.isAuthenticated();
  }

  async getJwtToken() {
    return this.client.getToken()
  }
}

We will only need to touch this file when the service provider releases breaking changes. Think about the equivelent in a tightly coupled codebase. It is likely that the provider client would be referenced in many locations – providing many sources of error. Furthermore, imagine the dreaded vendor lock-in scenario. A tightly coupled system sounds like a nightmare to change providers.

Now imagine we are in a coffeeshop with slow internet. It would be annoying to have to call out to the provider on every refresh. Ideally this could be mocked out. Thankfully we can add another strategy while abiding by the same interface. This time most of our functions are stubbed since we do not need authentication.

import { AuthService } from './authService'

const IS_AUTHENTICATED = process.env.REACT_APP_IS_AUTHENTICATED

export default class MockAuthService implements AuthService {
  async login() {
    console.info('Mocking login initialization.')
  }

  async logout() {
    console.info('Mocking logout.')
  }

  async isAuthenticated() {
    return Promise.resolve(IS_AUTHENTICATED);
  }

  async getJwtToken() {
    return Promise.resolve('some.jwt.token')
  }
}

Now we get blazing fast development since everything occurs locally! This pattern can be implemented beyond authentication. Anything that calls out to a SaaS service or homegrown API can do this too.

A drawback to this system is that there is much more design forethought. There is also a decent amount of verbosity when developing against an interface.

On another note, how do we integrate this strategy pattern with the application? It is really important where the initialization of the strategy occurs. The ideal design is to resolve all of the resources on load. Then the service can be provided via dependency injection to the other components. The dependency resolution can be written with the factory pattern.

import { ServiceNotFoundError } from '../index'
import VendorAuthService from './vendorAuthService'
import MockAuthService from './mockAuthService'

const defaultEnv = process.env.NODE_ENV
const defaultIsOffline = !!process.env.REACT_APP_OFFLINE

export default function(environment = defaultEnv, offline = defaultIsOffline) {
  let service

  if (offline || environment === 'test') {
    service = new MockAuthService()
  } else if (environment === 'production' || environment === 'development') {
    service = new VendorAuthService()
  } else {
    throw new ServiceNotFoundError(`Could not find environment: ${environment}`)
  }

  return service
}

Finally, this function can be imported into your root app.js file. From there, we can create the authentication service and inject it. I personally like to use React, so I will reach for a context.

This covers a sufficient solution to one of the vendor lock-in issues. Unfortunately, this is just one aspect that needs to be considered. The whole other can of worms that I notice is the actual data migration. Often times this can be just as painful (if not more) to swap services.

Also (unfortunatley) there is no “one size fits all” for moving data. Each service may structure their data differently. This normally calls for writing one-off migration scripts for moving data.