Inversion of Control (IoC) is one of the pillars of object-oriented programming. Without IoC, there is no object-oriented design – It is that important.
This principle amazes me because, especially when working with dynamically typed languages, people can spend years using an object-oriented language without ever learning about it. This fundamentally inhibits their ability to progress toward becoming senior developers. So, in this article, we’ll examine Inversion of Control (IoC) in Duck-typed languages.
What is Inversion of Control?
Software engineering’s “Inversion of Control” principle assigns control over certain program elements or objects to a container or framework. It is most frequently used in object-oriented programming.
With IoC, a framework can take over program flow and make calls to our custom code, unlike conventional programming, which requires our code to call libraries. Frameworks use abstractions with integrated additional behavior to make this possible. If we want to add behavior, we should either add our classes as plugins or extend the framework’s classes.
The advantages of IoC are as follows:
- The execution of a task from its implementation is decoupled.
- It is easier to switch between different implementations.
- A program’s modularity increases.
- It is easier to test a program by isolating a component or mocking its dependencies and allowing components to communicate through contracts.
The resolution of dependencies is the most important aspect of IoC. It is widely acknowledged that the biggest issue that IoC is attempting to address is dependency mismanagement. The program’s control is inverted when control is transferred from a component (like a web form) to a framework.
Throughout the years, multiple ways have been created to achieve Inversion of Control. Examples include Strategy design pattern, Factory pattern, Service Locator pattern, and Dependency Injection (DI).
When and How Would You Implement Inversion of Control?
No one is forcing you to use IoC when writing your code. Nonetheless, it offers various advantages that cannot be ignored.
IoC is a pattern used to decouple system levels and components. The pattern is implemented by injecting dependencies into a component when it is constructed. These dependencies are usually provided as interfaces for further decoupling and to support testability.
Moreover, it offers many advantages, which include:
- Forcing you to write more modular code.
- Decouples the application.
- Allows for simplified, centralized configuration.
- Control over the lifetime of dependencies.
- Takes care of long nested dependency chains.
Before moving on to implementing IoC, we must understand Dependency
What is Dependency Injection (DI)?
We can leverage the pattern of dependency injection to implement IoC. Instead of the individual objects connecting or “injecting” themselves into other objects, Dependency Injection uses an assembler.
IoC and DI are both straightforward ideas, but because they hugely impact how we build systems, it is crucial to understand them thoroughly.
Let’s look at an example to grasp better the DI pattern and how it achieves IoC.
Implementing Dependency Injection
Imagine you have a typical online application for managing notes. We want a class that shows us notes organized differently, such as notes that are still pending or previously completed notes.
class NotePresenter
def initialize
@note_storage = NoteTextStorage.new
end
def pending_notes
notes = @note_storage.get_all
notes.select { |n| n.pending? }
end
end
This implementation brings on some issues. What if we wish to alter how NoteTextStorage is implemented? The issue is that the NotePresenter class and its underlying persistency are closely related. It would be much nicer if we could send our Storage to the presenter, and it would function as before rather than changing our NotePresenter.
Dependency injection acts as a runtime glue, connecting components (in this case, the presenter and the store). We get loose coupling between these parts as a result.
How then can dependency injection be implemented in Ruby? A simple approach would be to pass it in the constructor:
class NotePresenter
def initialize(storage)
@note_storage = storage
end
def pending_notes
notes = @note_storage.get_all
notes.select { |n| n.pending? }
end
end
Naturally, for this to function, all storage objects supplied to the presenter must implement the same interface. This approach is straightforward and easy to follow and will often be sufficient. But what if other classes, in addition to our NotePresenter, use our storage solution? Here, a configuration-like strategy would be more appropriate. Most libraries for dependency injection operate in this manner. All the parts are configured centrally, along with instructions on assembling them.
Two gems for this strategy are provided by dry-rb and are called dry-container, and dry-auto inject.
Let’s use these two gems to put our example into practice:
dependency_cont
ainer = Dry::Container.new
We create an object here that will act as a container for our configuration (our gluing of components).
dependency_container.register
(‘note_storage’, -> { NoteTextStorage.new })
Here, we bound our NoteTextStorage program to a new configuration we registered with the keynote storage.
AutoInject = Dry::AutoInject
(dependency_container)
Using the upper line of code, we configured it once we were done. We are now prepared to add our dependencies to our classes:
class NotePresenter
include AutoInject[‘note_storage’]
def pending_notes
notes = note_storage.get_all
notes.select { |n| n.pending? }
end
end
Here, we insert the configuration for our note storage into our NotePresenter. Through this, the configuration instance supplied is reachable via the note storage method. Let’s imagine we wish to modify how we implement underlying storage. Then, simply develop a new implementation and pass the class in our container as the note storage dependent is all that is required.
Wrapping Up
Congratulations! Everything there is to know about inversion of control in programming languages with duck-types has now been taught to you. In summary, IoC makes our code more tested, flexible for modifications, and loosely coupled. You may enhance this much more with the dependency injection method. Additionally, DI offers a means to distinguish between how an object is made and how it is used. As a result, you should now try to implement IoC in your code and test out different IoC strategies.