Abstracting the Optimizely SDK — Part 2: Keeping it clean

In this second installment in the series we’ll outline how we’ve modeled our Optimizely SDK implementation as a light-weight dependency injection (DI) framework.

In the previous post we described how we extended the Optimizely SDK to fetch user state from a thread local object, specifically the Mapped Diagnostic Context (MDC), when making API calls. This motivation of wrapping the SDK was to simplify the interface by not requiring the developer to provide these details every time they wanted to experiment on some new feature or rollout. This was a great improvement and made the SDK easier to integrate and more consistent, but we still were left with blocks of conditional statements like this in our code.

Variation variation = OptimizelyClientFactory.getClient()
MyInterface myInterface;switch (variation.getKey()) {
case “firstImplementation”:
myInterface = new FirstImplementation();
case “secondImplementation”:
myInterface = new SecondImplementation();
myInterface = new DefaultImplementation();

While this works in an ad-hoc, one-off experiment, it quickly started to clutter up our codebase, distracting the reader from the core business logic. It was also proving more difficult to unit test and required regular maintenance rounds to remove this logic once the experiment had run it’s course.

Iterating to something better

What became clear is that the primary business logic does not care about the underlying implementation, but simply about the interface (or contract) that the implementation provides. By subscribing to this dependency inversion principle we immediately start to think in terms Dependency Injection (DI) frameworks. We want Optimizely to provide an instance of an implementation and hide the machinery on why or how that particular implementation was selected.

With this concept in mind we threw away the leaky abstraction above, and imagined an API that removes all decision and experimentation awareness from the implementer and delegates that to the OptimizelyClient, such as:

MyInterface interface = OptimizelyClientFactory.getClient()

To make this possible we developed a custom annotation framework for tagging our classes with the appropriate API keys required to make calls into the Optimizely SDK. This pattern removes the parameters of the experiment and variations from the core logic and moves them directly to the class implementations that they represent.

Here is how we’d annotate both the interface and the concrete class definitions from the example above:

public interface MyInterface {…}
public class FirstImplementation implements MyInterface {…}
public class SecondImplementation implements MyInterface {…}

Why annotations?

Annotations are a form of metadata that can be added to instance variables, constructors, methods, classes, etc. When configured appropriately that metadata can be made available at runtime to build custom logic around. Annotations are fairly common in Java as a way of adding cross-cutting functionality through the use of reflection and they are typically found in application, testing, and serialization frameworks.

How it works

When the OptimizelyClient is first instantiated, an annotation processor scans the classpath for any classes annotated with @OptimizelyFeature or @OptimizelyVariation and registers the class definition along with it’s annotations. When subsequent calls are made to getFeature for a particular class, the OptimizelyClient looks up the api key that was annotated, in this case “myFeature”, and calls the SDK to return the respective Optimizely variation key.

The variation key is then used to map to the @OptimizelyVariation name of the respective implementation. That implementation is then instantiated and returned to the caller.

What about instance variables?

Being able to experiment with individual concrete class implementations is great and solves many of our internal use cases, but doesn’t leverage the full power of the SDK, namely Optimizely Feature Variables. Feature Variables provide another level of configurability that can be defined as part of the feature and experimented.

To support these variables, the annotation framework provides a new annotation, @OptimizelyVariable, that is used to tag instance variables as parameters under an Optimizely Feature.

For example, let’s say we have a class that stores a database connection configuration:

public class DbConfiguration {public static String URL = "database.dz.optly.com";
public static int PORT = 4321;
public static long TIMEOUT_MS = 30000;
public static int MAX_RETRIES = 3;

As is, it’s static config that would require an application deployment to update, but as an Optimizely Feature we can have a dynamic, testable and measurable configuration.

public class DbConfiguration {
public String url = "database.dz.optly.com";
public int port = 4321;
public long timeoutMs = 30000;
public int maxRetries = 3;

Similar to before, when getFeature is called from the OptimizelyClient, that request gets mapped as an API call in the Optimizely SDK for the Feature named “DB_CONFIG”. But since this class has members defined with @OptimizelyVariable, the framework make additional calls to getFeatureVariable from the SDK and override the respective values in the class instance.

The framework we developed for our internal experimentation program, modeled around dependency injection, has removed the friction and overhead typically associated with experimentation. As a result, our application code remains clean, readable, and unit testable all contributing to the overall health and maintainability of the application.



Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store