What is Dependency Injection (DI)?
Dependency Injection (DI) is an implementation technique that relies on interfaces to inject instances that implement those interfaces. Below is a implementation example used Spring boot:
public interface Service {
void execute();
}
@Component
public class ServiceImpl implements Service {
@Override
public void execute() {
System.out.println("Service Executed");
}
}
@Component
public class Client {
private Service service;
// Inject dependency from the constructor
@Autowired
public Client(Service service) {
this.service = service;
}
public void doSomething() {
service.execute();
}
}
The above is an implementation in a statically typed language. In dynamically typed languages, explicit interfaces are not necessary:
class Service {
execute() {
console.log("Service Executed");
}
}
class Client {
constructor(service) {
this.service = service;
}
doSomething() {
this.service.execute();
}
}
In dynamically typed languages, as long as the methods used by the class depending on the interface are implemented, it can be executed without implementing all interfaces. This trait is called duck typing.
class RealService {
execute() {
console.log("Service Executed");
}
execute2() {
console.log("Service Executed 2");
}
}
class MockService {
execute() {
console.log("Mock Service Executed");
}
}
class Client {
// you can inject MockService here because the client only use the execute() method
constructor(service) {
this.service = service;
}
doSomething() {
this.service.execute();
}
}
DI is often used to make testing easier by separating complex logic from side effects such as interactions with databases or communication with public REST APIs. It is an essential technique for anything other than one-off programs.
// Production code
public interface Service {
void execute();
}
@Component
public class DatabaseService implements Service {
@Override
public void execute() {
System.out.println("Database Service Executed");
}
}
@Component
public class Client {
private final Service service;
@Autowired
public Client(Service service) {
this.service = service;
}
public void doSomething() {
service.execute();
}
}
// ClientTest.java
@SpringBootTest
public class ClientTest {
@Autowired
private Client client;
@Autowired
private StubService stubService;
@Configuration
static class TestConfig {
@Bean
public Service service() {
return new StubService();
}
@Bean
public Client client(Service service) {
return new Client(service);
}
}
@Test
public void testDoSomething() {
client.doSomething();
// assertions here
}
public class StubService implements Service {
private boolean executed = false;
@Override
public void execute() {
this.executed = true;
System.out.println("Stub Service Executed");
}
public boolean isExecuted() {
return executed;
}
}
}
Implementing DI Without a Framework
In mature languages like Java and C# used in enterprise field, full-stack frameworks that include DI frameworks are provided. However, in languages often used in microservices or dynamically typed languages, the options are limited, and DI frameworks may not always be available. Even so, implementing DI from scratch is possible. Simply initialize the class dependent on the interface at the same location where the interface-implementing class is also initialized and pass it to the constructor.
public class ManualDIExample {
public static void main(String[] args) {
Service service = new ServiceImpl(); // initiate interface implementation
Client client = new Client(service); // inject dependency at the constructor
client.doSomething();
}
}
However, DI should not be done in all over the place. It must be centralized in one location. The best place for this is the entry point. The entry point is the function or method that is first called when a program is executed. In JS or Python, you can think of it as the script file that is first called.
// index.js, executed by the command `node index.js`
const service = new Service();
const client = new Client(service);
client.doSomething();
The entry point (often the main function in many languages) is the function that does the dirty work.
This is consciously kept as simple as possible, using procedural or structured programming.
If it is not possible to modify the entry point due to framework constraints, concentrate DI as much as possible at the very beginning of the process, such as at each REST API endpoint handler.
Below is an example of an AWS Lambda function. Lambda functions are invoked in response to various requests and do not have a clear entry point equivalent.
exports.handler = async (event) => {
const service = new Service();
const client = new Client(service);
client.doSomething();
return {
statusCode: 200,
body: JSON.stringify("Success"),
};
};
Initialization and Injection of Singleton Classes
A singleton class is one where only one instance exists during the execution of a application life cycle. This means that the instance’s state is shared across all threads from a practical point of view. Typically, DB connections are referred through a singleton (DB connections are generally limited to 10-100, and they quickly overflow in serverless environments or when horizontally scaled).
The initialization and injection of singleton classes vary by language and framework, but usually, a mechanism called “context” is often available and utilized.
from fastapi import FastAPI, Depends
class DatabaseConnection:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(DatabaseConnection, cls).__new__(cls)
# init db connection
return cls._instance
def get_db():
return DatabaseConnection()
app = FastAPI()
@app.get("/items/")
def read_items(db: DatabaseConnection = Depends(get_db)):
# use database here
return {"message": "used database"}
It is indeed possible to implement the dependency class itself as a singleton and resolve dependencies and initialize at the entry point, but such an implementation should be avoided. Dependency classes should be separated because they need to be tested. To conduct tests, each test case must have an independent state; however, singleton classes share their state across all test cases.
Even if, at the current stage, a singleton dependency class does not have a state that poses problems for testing, it cannot be guaranteed by type. Therefore, the only way to ensure safety is to make it known as a matter of team protocol.
For instance, in Kotlin, a class marked with the object keyword
has the nature of a singleton, which is somewhat better than languages
where it is not clear until you look at the internal implementation.
However, there is still no such thing as a “singleton type”, so the problem remains.
// production code
interface Service {
fun execute()
}
object ClientSingleton {
private lateinit var service: Service
var called: Boolean = false
private set
fun init(service: Service) {
this.service = service
}
fun execute() {
service.execute()
called = true
}
}
// test
class ClientSingletonTest {
@Test
fun testExecuteSetsCalledTrue() { // unexpectable test
ClientSingleton.init(StubService())
assertFalse(ClientSingleton.called)
ClientSingleton.execute()
assertTrue(ClientSingleton.called)
}
@Test
fun testExecuteWithoutCalling() { // unexpectable test
ClientSingleton.init(StubService())
assertFalse(ClientSingleton.called)
}
class StubService: Service {
fun execute() {
println("StubService::execute() is called.")
}
}
}
When the Entry Point Becomes Complex
You can introduce builder functions when the entry point becomes be complex. This is a function (sub procedure) to handle the complex initiation setups of interface implementations.
function buildService() {
// complex code here...
return new Service();
}
function main() {
const service = buildService();
const client = new Client(service);
client.doSomething();
}
main();
Complex dependency graph of implementations
Although not ideal, if the implementation itself involves complex dependencies that need to be concealed, one approach could be to define a container that encapsulates these processes. This homemade DI container could potentially simplify the management of these dependencies.
package main
import "fmt"
type InterfaceA interface {
PerformActionA()
}
type ConcreteA struct{}
func (c *ConcreteA) PerformActionA() {
fmt.Println("Action A is performed")
}
type InterfaceB interface {
PerformActionB()
}
type ConcreteB struct{}
func (c *ConcreteB) PerformActionB() {
fmt.Println("Action B is performed")
}
type Container struct {
serviceA InterfaceA
serviceB InterfaceB
}
func NewContainer() *Container {
// long initiate processes here...
return &Container{
serviceA: &ConcreteA{},
serviceB: &ConcreteB{},
}
}
type ServiceProviderA interface {
GetInstanceA() InterfaceA
}
func (impl *Container) GetInstanceA() InterfaceA {
return &ConcreteA{}
}
type ServiceProviderB interface {
GetInstanceB() InterfaceB
}
func (impl *Container) GetInstanceB() InterfaceB {
return &ConcreteB{}
}
type Client struct {
srvProviderA ServiceProviderA
srvProviderB ServiceProviderB
}
func NewClient(srvProviderA ServiceProviderA, srvProviderB ServiceProviderB) *Client {
return &Client{
srvProviderA: srvProviderA,
srvProviderB: srvProviderB,
}
}
func (c *Client) ExecuteActions() {
c.srvProviderA.GetInstanceA().PerformActionA()
c.srvProviderB.GetInstanceB().PerformActionB()
}
func main() {
container := NewContainer()
client := NewClient(container, container)
client.ExecuteActions()
}
However, this technique requires a language like Go or Rust, which facilitates multiple inheritance, or a dynamically typed language that allows duck typing. Otherwise even if you want to test a class that depends on only some interfaces of them, you need to prepare a container that encapsulates whole interfaces at test time.
// InterfaceA.java
public interface InterfaceA {
void performActionA();
}
// ConcreteA.java
public class ConcreteA implements InterfaceA {
@Override
public void performActionA() {
System.out.println("Action A is performed");
}
}
// InterfaceB.java
public interface InterfaceB {
void performActionB();
}
// ConcreteB.java
public class ConcreteB implements InterfaceB {
@Override
public void performActionB() {
System.out.println("Action B is performed");
}
}
// Container.java
public interface Container {
InterfaceA getInstanceA();
InterfaceB getInstanceB();
}
// ContainerImpl.java
public class ContainerImpl implements Container {
private InterfaceA serviceA = new ConcreteA();
private InterfaceB serviceB = new ConcreteB();
@Override
public InterfaceA getInstanceA() {
return serviceA;
}
@Override
public InterfaceB getInstanceB() {
return serviceB;
}
}
// Client.java
public class Client {
private InterfaceA serviceA;
public Client(Container container) {
this.serviceA = container.getInstanceA();
}
public void executeActions() {
serviceA.performActionA();
}
}
// ClientTest.java
class ClientTest {
@Test
void executeActions() {
Client client = new Client(new MockContainer());
client.executeActions();
}
private class MockContainer implements Container {
@Override
public InterfaceA getInstanceA() {
throw new UnsupportedOperationException("Not supported yet.");
}
@Override
public InterfaceB getInstanceB() {
throw new UnsupportedOperationException("Not supported yet.");
}
}
}
Does scale Framework-less DI?
My opinion: Yes.
DI without a framework scales sufficiently within the bounds manageable by a monolith. In startups, where human resources are often fluid, the importance of code that is easy to onboard and understand is high.
For this reason, if a de facto standard framework is not available in the product’s language, it may be better to avoid introducing a DI framework.
Unaddressed Considerations
- Ensuring testability when DI is unavailable.
- DI in frameworks with strong constraints, like React.
- Reference patterns for singletons when context mechanisms are unavailable.
- Efficient implementations in languages like Go and Rust that allow separation of data and behavior.