Dependency Injection
Dependency injection is a pattern where a chunk of code doesn't directly reference its dependencies. Instead its caller provides the dependencies. This is made possible by the caller and the callee agreeing on contracts for the dependencies.
Doing this allows implementation of the dependencies to be easily swapped out, which is useful for writing unit tests.
Simple Example
Let's imagine we are developing a simple app. The majority of the logic in this app resides in a function called logic
. This function needs to call out to a cloud service using a third party client library. But we still want to be able to write unit tests for our app, so we will use dependency injection:
/** Client for some cloud service. */
interface ClientShape {
/** Make an HTTP call to some cloud service. */
call: () => Promise<void>;
}
/** A function in the service layer of our app. */
function logic(client: ClientShape) {
// Business logic...
client.call();
// More business logic...
}
ClientShape
is an interface covering the parts of the third party client our app cares about. In this case it's just the call
function.
logic
is the function that contains the majority of the business logic for our app. In the middle of all that business logic is the call to the cloud service. Because we are injecting the client in using an interface we can swap out the implementation trivially. For example this could be the entry point for our app:
/** The entry point for our app. */
function main() {
// Instantiate the real client
const client = new Client();
// Call the service layer while injecting the real client.
logic(client);
}
Here we are injecting the real third party client that actually makes the call to the cloud service. As long as it meets our interface this works. And if we wanted to write a test for our app it might look like:
/** A unit test for the service layer. */
function test() {
// Create a simple mock of the client.
const mock = {
call: async () => {},
};
// Call the service layer while injecting the mock.
logic(mock);
// Verify results...
}
Here we are creating a mock that meets our interface, and calling logic
with that. This avoids actually calling out to the cloud service during our tests.
Implicit Interfaces
The way that most languages do interfaces is explicit. For example:
interface ISomeInterface
{
void Example();
}
class SomeClass : ISomeInterface
{
public void Example()
{
// Do Stuff
}
}
We defined the ISomeInterface
interface, and in order for SomeClass
to implement ISomeInterface
we had to explicitly denote it with : SomeInterface
. Languages like TypeScript and Go turn this on its head. As long as a thing meets the specification of an interface it automatically implements it:
interface SomeShape {
example: () => void;
}
const thing: SomeShape = {
example: () => {},
};
Here we defined an interface called SomeShape
, and thing
does implement it, but we never had to denote that anywhere. Because thing
has a function called example
with the right arguments and return type it implicitly implements SomeShape
.
This is also referred to as duck typing coming from the popular saying:
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
Inversion of Control
To be clear the techniques presented here are not inversion of control. You are still calling your code. There is no dependency injection library, or dependency injection container. A lot of developers will have an aversion to dependency injection from previous experience with heavy handed frameworks, but you don't need them to take advantage of dependency injection.
Utility Types
Previously we manually defined the dependency interface, but we can use the utility types in TypeScript to effectively generate these interfaces for us. This is particularly useful with third party libraries since you won't have to mock every single thing in them for your tests.
// Import a large client with lots of functions.
import { GirthyClient } '@corp/girth'
// Strip it down to just the function we need.
type GirthyClientShape = Pick<GirthyClient, 'call'>;
// Use that for the dependency interface.
function logic (client: GirthyClientShape) {
// Business logic...
client.call();
// More business logic...
}
Here we use Pick
to strip GirthyClient
down to just call
. Then when we write tests we only have to mock call
.
Conclusion
Dependency injection makes unit testing much easier, and with implicit interfaces it's easy to implement in way that keeps you in control.