Dependency Injection is a vast topic in itself, and it's difficult to find everything we should know at one place. Through this series of articles, I wish to share my learning about DI. In this part of the series, we will look into a tightly coupled application and the problems related to it.
Introduction
An application is said to be tightly coupled when its classes are dependent on other concrete classes. Also, a class has the responsibility to instantiate its dependencies on its own. As a result, the application will have a build failure until all its dependencies are resolved.
Case Study
Consider a class "Commerce" which uses the ProcessPayment method of the PaymentProcessor class. If we know nothing of dependency injection, our Commerce class would look like the following.
- public class Commerce
- {
- private PaymentProcessor _processor;
- public Commerce()
- {
- _processor = new PaymentProcessor();
- }
-
- public void ProcessPayment()
- {
-
- _processor.ProcessPayment();
- }
- }
-
- public class PaymentProcessor
- {
- public void ProcessPayment()
- {
-
- }
- }
Because Commerce class is using the PaymentProcessor class, it has an extra responsibility of instantiating it. And in order to do so, it has to know about PaymentProcessor at compile time only.
Assume, the user has multiple concrete implementations of PaymentProcessor, one for each different payment mode. In a tightly coupled application, the developer won't have the luxury of deciding which concrete implementation of PaymentProcessor shall be used by the Commerce class, based on some configuration.
While it looks fine with a set of two classes, we might end up over complicating our simple application as the dependencies grow. To understand that, let's have a look at a scenario in which the PaymentProcessor class uses a service and therefore increases the dependency level.
Multilevel Dependencies
Assume that PaymentProcessor uses a CurrencyConverter service, while processing the payment. Though Commerce class remains unchanged, our PaymentProcessor class now has an additional responsibility of instantiating the service before its use. Thus, the service class must be known to the PaymentProcessor at compile time, else we will get a build failure. Refer to the code below.
- public class PaymentProcessor
- {
- private CurrencyConverter _cc;
- public PaymentProcessor()
- {
- _cc = new CurrencyConverter();
- }
-
- public void ProcessPayment()
- {
- double convertedCurrency = _cc.ConvertToLocalCurrency();
-
- }
- }
-
- public class CurrencyConverter
- {
- public decimal ConvertToLocalCurrency()
- {
-
- }
- }
Testability
An application must be testable, not just as an end to end application; but we should also be able to write tests for every unit of code. Writing unit tests for code or logic is quite easy in our scenario. It won't be a problem even if we add some more dependencies to the classes.
As can be seen in the above image, when write a unit test for our ProcessPayment method, we receive the actual/live objects for the components used by the method. While this is not an issue for our small application, if we had classes that talk to some database or some other sort of backend service, we will be in big trouble. This is because every time we run a test, we will hit our database which might hamper our data.
In order to solve such problems, it is a best practice to use interfaces instead of concrete classes. These interfaces can then be mocked at the time of unit testing, which will prevent any call to the actual database. Also, it is a good practice to stop "newing-up" objects in classes. Instead, someone else (a factory or a component) has to take the responsibility of instantiating the dependencies and provide it to the dependent classes.
Summary
- With classes having to instantiate their dependencies (other classes), our simple application will get complicated as the level of dependencies increases.
- The application will not compile successfully until all the dependencies are resolved at compile time only.
- Coupled architecture limits the functionality to single implementation. It's not possible to choose between the implementations at run time based on any configuration.
- If classes perform DB work, it is difficult to test them without hitting the database.
- Avoid "newing-up" objects in classes.