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.
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:
- HttpClient
- AxiosClient
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();
}
}
class AxiosClient implements NetworkClient {
override async get(url: string): Promise<any> {
const response = await axios.get(url);
return response.data.json;
}
override async post(url: string, body: any): Promise<any> {
const response = await axios.post(url, body);
return response.data.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:
- The provider's return type was changed to
NetworkClient
instead ofHttpClient
. This change could lead to further changes in the codebase, but it's a small price to pay for the flexibility it provides. - 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', () => {
// ...
});
});