Skip to content

Implement dependency injection without framework

Published: at 02:30 PM

Translates:

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