Breaking complexity with Dependency Injection: Introducing Obsidian
The essence of software engineering is about breaking down large, complex problems into smaller, independent pieces that are easier to understand. These pieces can then be woven together to form an application. This process is also known as Decomposition and Composition. In this post I’ll explain how Dependency Injection (DI), a technique used to separate two concerns — the creation of objects and the use of objects — can help us with Decomposition and Composition. I’ll also introduce a Dependency Injection library I co-created called React Obsidian.
What is Dependency Injection?
To understand what Dependency Injection is, we first need to understand what dependencies are. In their book “Growing Object-Oriented Software, Guided By Tests”, Steve Freeman and Nat Price define dependencies as: ”Services that the object requires from its peers so it can perform its responsibilities”. They then elaborate and give an example: “…the object cannot function without these services. It should not be possible to create the object without them. For example, a graphics package will need something like a screen or canvas to draw on — it doesn’t make sense without one”
Now that we know what dependencies are, the term Dependency Injection becomes pretty self-explanatory — dependency Injection is the process of providing dependencies to objects. And DI frameworks simplify passing dependencies to objects by automating the injection process.
You’re probably familiar with one form of DI — passing dependencies in through the constructor. Injecting dependencies as constructor arguments is the best form of DI. By doing so, we clarify which objects a class requires in order to perform its responsibilities and enforce this constraint on the class’s definition.
Decomposition — or where do dependencies come from?
Large classes are complex — and complexity undermines understanding. The only way we can deal with complexity is to try to avoid it by decomposing our code into small, well-named, understandable pieces. Dependencies are a direct result of decomposition. By extracting logic to classes and exposing it via a public API, we don’t need to bother ourselves with the low-level implementation details. Rigorous decomposition lets us work at higher levels of abstraction which, ultimately, enables faster work.
Most of the time, I find myself using the Single Responsibility Principle (SRP) as a guide when breaking up code. The SRP states that if an object is getting too complex, that’s often a sign that it’s handling multiple concerns. An object should be responsible for one thing, and one thing only. Thus when adding a new feature, this principle helps us decide whether to modify an existing object or to create a new object to depend on. If we can’t describe what an object does without using any conjunctions (“and”, “or”), then that object is violating the principle and should be broken up into collaborating classes.
Let’s go over a quick example to better understand this principle. The following class is part of an ecommerce system, and is responsible for placing orders made by customers. As you can see, the class violates the SRP as it is responsible for multiple concerns: calculating the final purchase price, validating shipping address, and placing orders.
Let’s refactor the OrderPlacer class by encapsulating the price calculation and address validation in dedicated classes (decomposition). We’ll then pass these new classes as peers through the constructor (composition).
Now, our class has one responsibility — placing orders. It requires dependencies to fulfill its role and these dependencies also have a single responsibility.
Composition
As we decompose our code into many small classes, we need to somehow introduce them to one another. We always prefer to pass dependencies via constructor injection, but we should be careful not to pass too many dependencies. Because having too many dependencies often indicates that a class is doing too much and should be split into smaller classes. The same logic applies to imports. A long list of imports can imply a class is dealing with too many domains and that can lead to complexity.
One challenge that arises from constructor injection is how to propagate dependencies to classes. As the code base grows, its main entry point tends to function more like a “matchmaker” whose responsibilities are to instantiate classes and introduce them to one another. But even after the classes are instantiated, propagating them to certain parts of our application can prove difficult. Components, for example, are typically not instantiated by us, meaning the only way to provide them with dependencies is via imports, or by some service locator like React.context. Both methods are less preferable because they don’t make the dependencies part of the Component definition.
This is where Dependency Injection frameworks come into the picture. DI takes care of instantiating dependencies and resolving them so developers don’t need to worry about creating too many classes or how to introduce classes as dependencies.
Dependency Injection with Obsidian
As I’ve mentioned before, DI is all about decoupling the creation and usage of dependencies. That’s why the first thing we need to do before we can actually inject a dependency is to declare how it should be created. Dependencies are declared in objects called graphs. These graphs represent the Object Graph formed by the relationships between dependencies.
Let’s look at an example where we put to use everything we learned so far. Let’s declare a graph that provides two dependencies — an HttpClient and a BiLogger. The BiLogger is used to report business insight events to our server and it depends on HttpClient since it needs to send network requests. We’ll then use our graph to inject a class, a component, and a hook with an instance of the BiLogger.
This is our graph! It’s a @Singleton @Graph — a graph that’s instantiated once across the lifespan of an application. We can see that it @Provides two dependencies and that biLogger has a dependency on HttpClient since it receives an HttpClient as an argument.
When objects are created in a centralized place where the dependencies of each object are clearly visible, their relationships become easier to understand. Another huge advantage of graphs is that each provider method is essentially a seam. Michael Feathers defines seams as “a place where you can alter behavior in your program without editing in that place”. This is useful, for instance, in tests, where dependencies need to be replaced with mocks or stubs. Alternatively, a different dependency that conforms to the same interface can be resolved in accordance with some server experiment.
Oh, and If you’re wondering what those keywords prefixed with an `@` symbol are — those are Decorators. It’s a stage 2 proposal that we’re leveraging for a more expressive API. Obsidian uses these decorators to save injection-related metadata.
Class injection
Once we have our graph set up we can use it to inject classes. Injecting a class is pretty simple. All we need to do is mark the class as @Injectable and specify the graph from which to inject. We then annotate the constructor params we want to @Inject and that’s it! From now on, ButtonController can be instantiated without the need to provide its dependencies explicitly.
The example above uses constructor overloading to satisfy the TypeScript compiler. This way we can instantiate the class without providing the biLogger dependency, while still declaring it as a required dependency.
Injecting React constructs
In addition to injecting classes, Obsidian can inject hooks, class components, and functional components. Let’s see how Obsidian handles each of these constructs.
Class components
React components are restricted to a single constructor argument — props. This slightly complicates matters for us because it means we can’t inject constructor arguments. That’s why for class components, we only support injecting class properties.
Functional components
Functional components (FC) are another strange animal. Not only are they restricted to a single argument (props), but as unbound functions, the TypeScript Decorators implementation doesn’t support them. To support FC’s, we decided to expose a classic decorator that takes care of injecting dependencies into props. Injecting props makes sense because the FC’s prototype serves as its entry point. Similarly to how a constructor is the entry point for a class.
Hooks
Injecting hooks is done in a similar manner. Injected hooks are decorated with an injectHook decorator, only this time, there’s a slight limitation to how you can write your hooks. Because Obsidian uses reflection to resolve dependencies, it needs to be aware of the names of the dependencies required by the hook. To do that, Obsidian expects hooks to receive a single argument, just like components. Obsidian then wraps this argument with a Proxy allowing it to trap property getters.
Conclusion
Decomposition and Composition are integral to breaking complexity in our applications. Rigorous decomposition results in many small, easy to understand objects that need to be composed together. Dependency Injection helps us visualize the boundaries of these objects and form connections between them to form a working system.
Obsidian is open source and can be found on wix-incubator/react-obsidian. The library is still in alpha, but you’re more than welcome to try it out. Obsidian is developed React-Native first, but we plan on fully supporting React in the future.