If you are a coder who instinctively senses that something is wrong with DI frameworks this article is for you. I will present to you my personal recipe to implement all practical DI aspects. This article considers a few typical classes and interfaces, and two types of lifecyles: Singleton and Scoped. The recipe I show here uses the natural capabilities of the Java language.
Introduction
For some time now IOC and dependency injection are singled out as the most important coding design pattern to follow. You will encounter questions about it in every code interview. Developers are listing DI frameworks they've used on their resumes. Recruiters will ask you whether you have experience with particular DI frameworks. The majority opinion is that in order to follow this very important coding architectural principal you need to use some sort of framework. It's such a shame that the majority got it all wrong. The simple commonly misunderstood fact is that implementing DI and using a DI framework is not the same thing.
Its quite difficult to promote this point of view. I find that I constantly have to explain myself over and over again whatever project I'm working on. Experienced engineers keep looking at me like I'm crazy. You'd think I just told them they live in the matrix. But I'm not the only one, there are others. A small yet insistent group who refuse to adopt the trend.
If you are a coder who instinctively senses that something is wrong with DI frameworks this article is for you. I will present to you my personal recipe to implement all practical DI aspects, at least those that I have encountered and consider necessary. Fair warning, after reading this you may receive a slight shock. The solution is so simple you will not be able to stop wondering how is it that all the hype surrounding DI frameworks came to be.
Background
The following short list of points pretty much summarizes the reasons you would have for not using a DI framework:
- Dependency injection is used to encourage loose coupling of code. Ironically, using a DI framework will couple your entire application to the framework.
- Using a DI framework heavily can cause performance problems not easily resolvable. Unexpected relationships between object can cause entire object chains to be created unnecessarily.
- Using a DI framework does not guarantee the correctness of "wiring" your objects. The only way to check the "wiring" is correct is at run time which is a waste of precious development time.
- Some DI framework use annotations which introduces behavior into interfaces and classes where it should not be, violating some OOP principles and best practices.
- Some DI frameworks promote other anti patterns like setter injection.
- Implementing DI manually uses natural type safety at compile time.
- Manual DI is more performant then using a framework.
- With Manual DI you have complete control of your application, object creation and lifetime management, debuggable and testable with standard development tools.
For further reading you may find some of this online articles useful:
The code
Lets list the important features provided by DI frameworks we need to implement:
- Map interfaces to their appropriate concrete class implementations.
- Create instances while providing them with all necessary dependencies.
- Control object lifecycle. Bind object to an application scope, or make it a singleton.
- Provide a placeholder for proper cleanup and disposal of resources.
Consider the following typical classes and interfaces:
public interface DB extends AutoCloseable {
List<Record> fetchRecords(String someParameter);
}
public class DBImpl implements DB {
private final String connectionString;
public DBImpl(String connectionString) {
this.connectionString = connectionString;
}
@Override
public List<Record> fetchRecords(String someParameter) {
return new ArrayList<>();
}
@Override
public void close() throws Exception {
}
}
public interface ReportService {
void generateReport(String filename,String otherParameter);
}
public class ReportServiceImpl implements ReportService {
private final DB db;
public ReportServiceImpl(DB db) {
this.db=db;
}
@Override
public void generateReport(String filename, String otherParameter) {
List<Record> records=db.fetchRecords(otherParameter);
}
}
Our first attempt at a manual DI implementation is:
public interface DIYDI1 {
DB getDB();
ReportService getReportService();
}
public class DIYDI1Impl implements DIYDI1 {
private static DIYDI1 instance=new DIYDI1Impl();
public static DIYDI1 getInstance() {
return instance;
}
@Override
public DB getDB() {
return new DBImpl("db connection string");
}
@Override
public ReportService getReportService() {
return new ReportServiceImpl(getDB());
}
}
To get an instance of ReportService
we can simply use:
DIYDI1Impl.getInstance().getReportService();
This clearly satisfies requirements 1 and 2. The solution however is very impractical because each time an object is needed it will be recreated from scratch including all its dependencies. If for example we had another service that uses the database it would be impossible to share a database connection between the services. If the connection is expensive to create there can be severe performance penalties.
To overcome this limitation we need to introduce some sort of object lifecycle management. We will consider two types of lifecycles we want to support:
Singleton is a familiar lifecycle. Typically they are initialized when the application starts. Sometimes lazy initialization is used the first time the singleton is accessed.
A scoped lifecycle is a bit trickier. It is simply a block of code executed between the start and end of some application event. Some scope examples are: handling an http request, a Quartz job or reading a message off a JMS queue. We need to be able to bind the lifetime of an object to the scope such that it will be created one time at the most during the scope.
This is typical for database connections which are relatively expensive to create. Reusing the same connection several times throughout the scope improves performance significantly. It is also common for connections to be closed at the end of the scope. Our solution needs to support this. It should track all objects created throughout the scope and cleanup or close resources at the end of the scope.
Another interesting scope property is that each scope usually involves only one thread. In our implementation we will create a new DIYDI object at the start of each scope. It should be accessed only by the scope thread and therefore does not need to be thread safe.
Let's add some more service examples to our code:
public interface InMemoryDataService {
Record getData(String recordID);
}
public class InMemoryDataServiceImpl implements InMemoryDataService {
private final ConcurrentMap <String, Record> _cachedRecords;
public InMemoryDataServiceImpl(DB db) {
_cachedRecords=new ConcurrentHashMap<>();
}
@Override
public Record getData(String recordID) {
return _cachedRecords.get(recordID);
}
}
public interface AlertService {
void sendAlert(String customerID,String recordID);
}
public class AlertServiceImpl implements AlertService {
private final DB db;
private final InMemoryDataService inMemoryDataService;
public AlertServiceImpl(DB db,InMemoryDataService inMemoryDataService) {
this.db=db;
this.inMemoryDataService=inMemoryDataService;
}
@Override
public void sendAlert(String customerID, String recordID) {
Record record=inMemoryDataService.getData(recordID);
Customer customer=getCustomer(customerID,db);
sendAlert(customer, record);
}
private Customer getCustomer(String customerID, DB db2) {
return null;
}
private void sendAlert(Customer customer, Record record) {
}
}
Also let's add another layer of abstraction to our database access method by declaring a configuration interface to hold our connection string. Our database access class can then depend on configuration to obtain the connection string:
public interface Configuration {
String getConnectionString();
}
public class ConfigurationImpl implements Configuration {
public ConfigurationImpl(Object externalArgs) {
}
private String connectionString;
@Override
public String getConnectionString() {
return connectionString;
}
}
public class DBImpl implements DB {
public DBImpl(Configuration configuration) {
this(configuration.getConnectionString());
}
}
Finally our revised DIYDI interface and implementation which includes object lifecycle management:
public interface DIYDI2 extends AutoCloseable {
void initSingletons(Object externalArgs);
Configuration getConfiguration();
DB getDB();
ReportService getReportService();
InMemoryDataService getInMemoryDataService();
AlertService getAlertService();
}
public class DIYDI2Impl implements DIYDI2 {
private static Configuration configuration;
private static InMemoryDataService inMemoryDataService;
private DB db;
private ReportService reportSerivce;
private AlertService alertService;
@Override
public void initSingletons(Object externalArgs) {
configuration=new ConfigurationImpl(externalArgs);
inMemoryDataService=new InMemoryDataServiceImpl(getDB());
}
@Override
public Configuration getConfiguration() {
return configuration;
}
@Override
public InMemoryDataService getInMemoryDataService() {
return inMemoryDataService;
}
@Override
public DB getDB() {
if (db==null)
db=new DBImpl(getConfiguration());
return db;
}
@Override
public ReportService getReportService() {
if (reportSerivce==null)
reportSerivce=new ReportServiceImpl(getDB());
return reportSerivce;
}
@Override
public AlertService getAlertService() {
if (alertService==null)
alertService=new AlertServiceImpl(getDB(), getInMemoryDataService());
return alertService;
}
@Override
public void close() {
if (db!=null)
try {
db.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
We can see that singleton services are backed by a static field. They will be initialized via the initSingletons
method during application startup. Scoped services are backed by member fields, which ensure that the service will only be created once during the scope. They will be created only if they are used during the scope. The DIYDI
object also implements AutoCloseable
. The close method will be called at the end of scope which will close the database connection if it was opened and potentially free up other scoped resources.
This is an example of how the class may be used within a single scope:
try (DIYDI2 diydi2=new DIYDI2Impl()) {
ReportService reportService=diydi2.getReportService();
reportService.generateReport("SomeFile.txt", "paramValue");
AlertService alertService=diydi2.getAlertService();
alertService.sendAlert("YourName", "1234");
}
During this scope the ReportService
and AlertService
will share a database connection. Typically you will not use the DIYDI
object like this. The details depend on the natural scope and API's exposed by your application. It is common to have a base class which can be instantiated by your applications natural scope factory. It will handle creating and closing the DIYDI
object. Then you can subclass it or compose it with other executor like classes to control the flow of the application.
Conclusion
Hopefully by now you can appreciate how easy it is to implement dependency injection by yourself. In software development an important guiding principle is "Keeping It Simple". The recipe I have shown here uses only the natural capabilities of the Java language. This is much simpler then introducing a framework for creating all objects. It also eliminates possible complications that can happen when we don't always fully understand the DI framework we are using, which are usually discovered much later in the development lifecycle. But most importantly it brings back control over the application back to where it belongs: Developers.