Skip to main content

Configurable applications

Designing applications to be flexible and configurable makes them more tolerable to changing requirements. The ability to change code frequently and quickly is one of the most important KPIs of any development team. This is generally made possible by a design that facilitates small pull requests, that modify a minimal amount of code across a minimal number of files.

The Dependency Injection pattern helps us write flexible code that is more tolerable to change by addressing three key concerns:

  • How can a class be independent from the creation of the objects it depends on?
  • How can an application, and the objects it uses support different configurations?
  • How can the behavior of a piece of code be changed without editing it directly?

In this article we will learn how Obsidian can help us address these concerns.

Configuring applications with providers

When using Obsidian, dependencies are declared and constructed in classes called Graphs. Each dependency is constructed by a method called a provider which acts as a Seam. Lets see how we can leverage them to make our apps flexible and configurable.

What are Seams?

A seam is a place where you can alter behavior in your program without editing in that place.

Working Effectively with Legacy Code by Michael Feathers

Example 1: Interchangeable dependencies according to external configurations

In this example we'll learn how to change the concrete object returned by a provider according to an external configuration. In a real life scenario, the external configuration would represent an A/B test or a feature toggle.

Step 1: Declare a graph

Lets declare a simple graph that provides a single dependency: an HTTP client used to make network requests.

@Singleton() @Graph()
class ApplicationGraph extends ObjectGraph {
@Provides()
httpClient(): HttpClient {
return new HttpClient();
}
}

Our HttpClient is using the standard fetch API to make network requests and for the sake of simplicity, it only supports GET and POST requests.

class HttpClient {
async get(url: string): Promise<any> {
const response = await fetch(url, { method: 'GET' });
return await response.json();
}

async post(url: string, body: any): Promise<any> {
const response = fetch(url, { method: 'POST', body: JSON.stringify(body) });
return await response.json();
}
}

Step 2: Implement another HTTP client

Just like our current HTTP client, the new client will only support GET and POST requests. The only difference is that it will use the axios library to make network requests.

class AxiosClient {
async get(url: string): Promise<any> {
const response = await axios.get(url);
return response.data;
}

async post(url: string, body: any): Promise<any> {
const response = await axios.post(url, body);
return response.data;
}
}

Step 3: Make the clients interchangeable

To easily switch between the two clients, we'll use a well known principle called Dependency Inversion. This principle states that high-level modules should not depend on low-level modules. Both the HttpClient and the AxiosClient are low-level modules, so we'll make the ApplicationGraph depend on an abstraction called NetworkClient instead.

interface NetworkClient {
get(url: string): Promise<any>;
post(url: string, body: any): Promise<any>;
}

The two network clients will implement this interface:

class HttpClient implements NetworkClient {
override async get(url: string): Promise<any> {
const response = await fetch(url, { method: 'GET' });
return await response.json();
}

override async post(url: string, body: any): Promise<any> {
const response = fetch(url, { method: 'POST', body: JSON.stringify(body) });
return await response.json();
}
}

Step 4: Return the correct client according to the configuration

To determine which client to return, we'll use a new dependency called AppConfig which will be used to access the application's configuration.

@Singleton() @Graph()
class ApplicationGraph extends ObjectGraph {
@Provides()
httpClient(appConfig: AppConfig): NetworkClient {
return appConfig.shouldUseAxiosClient() ? new AxiosClient() : new HttpClient();
}

@Provides()
appConfig(): AppConfig {
return new AppConfig();
}
}

We're done! Now we can easily control which network client to use according the application's configuration.

Conclusion and after thoughts

In this example we learned how to make dependencies interchangeable, and how to control which dependency to use according to an external configuration. This is a very common use case in large scale applications where we need an extra layer of assurance that changes can be easily rolled back in case of a bug.

Two important things to note about this example:

  1. The provider's return type was changed to NetworkClient instead of HttpClient. This change could lead to further changes in the codebase, but it's a small price to pay for the flexibility it provides.
  2. We wanted to keep the example short and easy to follow, so the two HTTP clients are simplified implementations of an actual client. They also share the same API which made it easy to implement the NetworkClient interface and have the two clients implement it. If the two clients had different APIs, perhaps because they supported typed request options and responses, then we would have to create common interfaces that would represent the common parts of the two APIs and adapters that would convert the two clients' APIs to the common API and vice versa.

Example 2: Mocking dependencies in acceptance/integration tests

Acceptance and integration tests are a great way to test how an application behaves as a whole. In these type of tests, objects aren't mocked since we're testing how the objects behave when they interact with each other. But because tests also need to be predictable and stable, there are some operations that we do want to simulate. For example, we might want to mock a network client so that we don't make real network requests during the test as that would add an unwanted layer of unpredictability to the test.

In this example we'll learn how to mock a dependency and how to use that mocked instance across all objects involved in the test.

Step 1: Declare a graph

As in the previous example, we'll declare a simple graph that provides a single dependency: an HTTP client used to make network requests.

@Singleton() @Graph()
export class ApplicationGraph extends ObjectGraph {
@Provides()
httpClient(): HttpClient {
return new HttpClient();
}
}

Step 2: Mock the HTTP client

To provide a mocked HTTP client to all objects involved in the test, we'll create a new graph that extends the ApplicationGraph and overrides the httpClient provider. In the next step we'll learn how to use this graph in our tests.

import { mock } from 'jest-mock-extended';

@Singleton() @Graph()
export class ApplicationGraphForTests extends ApplicationGraph {
@Provides()
override httpClient(): HttpClient {
return mock<HttpClient>();
}
}

Step 3: Use the graph in the test

To use the graph in the test, we'll use Obsidian's test kit to use the ApplicationGraphForTests instead of the ApplicationGraph whenever it's needed.

import {mockGraphs} from 'react-obsidian';

describe('Test suite', () => {
beforeEach(() => {
mockGraphs({
// Instruct Obsidian to use the ApplicationGraphForTests instead of the ApplicationGraph
ApplicationGraph: ApplicationGraphForTests,
});
});

it('should do something', () => {
// ...
});
});