When we first hear about Dependency Injection (DI), it might sound complicated. But once we understand it, we’ll see how simple it is. Let me explain it in a way that makes sense.
There are two key concepts we often discuss in this context: Dependency Injection and Inversion of Control. Both are different things but are often used together.
Dependency Injection
It is a design pattern used in object-oriented programming where a class receives its dependencies from an external source rather than creating them internally.
Instead of hardcoding dependencies, a class defines what it needs. let’s take a look at an example in everybody’s favorite language java
// Class representing a simple dependency
class Printer {
public void print(String message) {
System.out.println(message);
}
}
// Class that depends on the Printer class
class Document {
private Printer printer;
// ✅ Constructor Injection
public Document(Printer printer) {
this.printer = printer;
}
// ❌ Instead of this
public Document() {
this.printer = new Printer();
}
public void printDocument(String content) {
printer.print(content);
}
}
// Main class to demonstrate Dependency Injection
public class DependencyInjectionExample {
public static void main(String[] args) {
// Creating the dependency instance
Printer printer = new Printer();
// Injecting the dependency into the Document class
Document document = new Document(printer);
// Using the injected dependency to print the document
document.printDocument("Hello, this is a document.");
}
}
Explanation:
- Printer: A simple class that provides a method to print messages.
- Document: This class depends on the
Printer
class. It receives an instance ofPrinter
through its constructor, demonstrating Dependency Injection. - DependencyInjectionExample: This is the main class where we create an instance of
Printer
, inject it intoDocument
, and call the method to print a document.
In the above code, there is no loose coupling involved, which is the primary goal of dependency injection. To achieve that, let’s first understand Inversion of Control (IOC).
Inversion of Control
IOC simply means you give control to the user, instead of acting on it on your own. In technical terms your code should not depend on implementations rather on abstractions, in that way it’s flexible. Let’s understand this by an example.
// Service interface
public interface MessageService {
void sendMessage(String message, String recipient);
}
// Implementation of the MessageService
public class EmailService implements MessageService {
@Override
public void sendMessage(String message, String recipient) {
System.out.println("Email sent to " + recipient + " with message: " + message);
}
}
// Client class that uses Dependency Injection
public class User {
private MessageService messageService;
// Constructor Injection
public User(MessageService messageService) {
this.messageService = messageService;
}
public void sendMessage(String message, String recipient) {
messageService.sendMessage(message, recipient);
}
}
// Main class to demonstrate Dependency Injection
public class DependencyInjectionWithIOC {
public static void main(String[] args) {
// Creating the service instance
MessageService emailService = new EmailService();
// Injecting the service into the User class
User user = new User(emailService);
// Sending a message
user.sendMessage("Hello, Dependency Injection!", "john.doe@example.com");
}
}
Explanation:
- MessageService: An interface that defines the contract for sending messages.
- EmailService: A concrete implementation of the
MessageService
interface that sends messages via email. - User: A client class that depends on
MessageService
(on abstraction not on implementation). The dependency is injected through the constructor. - DependencyInjectionWithIOC: The main class where we create an instance of
EmailService
, inject it into theUser
class, and call the method to send a message.
Dependency Injection is a set of software design principles and patterns that enables you to develop loosely coupled code. The ultimate purpose of using DI is to create maintainable software within the object-oriented paradigm.
There is a common knowledge in design patterns.
Program to an interface, not an implementation.
Loose coupling keeps your code flexible, and that flexibility makes it easier to maintain. Dependency Injection is just a way to achieve loose coupling.
Benefits of Dependency Injection with IOC
- Enables late bindings (like an app that supports both SQL server and Postgres implementation)
- Ease in Unit Testing
Now that you have basic understanding about Dependency Injection and IOC, let’s see how DI is being used in various industry standard frameworks like ASP .Net Core and Spring.
Dependency Injection Containers
Spring and ASP.NET Core use Dependency Injection containers to automatically manage dependencies instead of relying on manual injection. This allows developers to focus on their main tasks without worrying about connecting different parts of the code.
Benefits of Using DI Containers:
- Easier Maintenance: You can change dependencies without modifying the classes that use them.
- Better Testing: You can easily replace real objects with mock objects during testing.
- Less Code: It reduces repetitive code, making development faster and simpler.
Opinion on DI Containers
In many cases developers want to control the code they write often not rely on complex mechanisms of frameworks. that’s why we are going to learn how to do manual dependency injection in Golang HTTP apps and I’m sure you will realize in most cases we don’t need mechanism like DI containers.
For your information you can also do automatic wiring of dependency in go apps, using popular libraries like github.com/google/wire
and github.com/uber-go/fx
.
Manual Dependency Injection in Golang
I like the simplicity go provides, less keywords, less clutter. I wrote a simple web crud API to explain this, the github repository you can find here.
In my demo app, there is a products handler which constitutes all the route handlers related to products. It depends on a logging interface (a simple interface for logging which has implementation - with inbuilt log
and other using github.com/sirupsen/logrus
package), and product service interface.
type ProductHandler struct {
logger logger.Logger
productService services.ProductService
}
func NewProductHandler(l logger.Logger, service services.ProductService) *ProductHandler {
return &ProductHandler{
logger: l,
productService: service,
}
}
In above code, my NewProductHandler
depends on logging.Logger
and services.ProductService
interface, we can pass any implementation of NewProductHandler
which implements both interfaces.
type Logger interface {
Info(msg string)
Error(msg string)
}
type ProductService interface {
List() ([]models.Product, error)
Get(id int) (models.Product, error)
Create(models.Product) error
}
In my main.go
file, you can see how the dependency is being passed
func main() {
dbConn := db.Connect("go_di_example.sqlite")
db.CreateTable(dbConn)
r := chi.NewRouter()
// creating logrus implementation
var logrusLog logger.Logger = &logger.LogrusLogger{}
productStore := stores.NewProductStore(logrusLog, dbConn)
// first productService object is initialised
productService := services.NewProductService(logrusLog, productStore)
// then productService is passed on to NewProductHandler
productHandler := handlers.NewProductHandler(logrusLog, productService)
r.Mount("/products", productHandler.HandlerRoutes())
log.Println("======== Starting server on :8080 =======")
log.Fatal(http.ListenAndServe(":8080", r))
}
Also let’s take a look, how the product handler is using the dependency in one of its handler method
func (h *ProductHandler) list(w http.ResponseWriter, r *http.Request) {
// logrus logging is used here as we have passed logrus implemention from main.go
h.logger.Info("Listing products")
// List() method is called here from the productService dependency
products, err := h.productService.List()
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"data": products})
return
}
In the same manner, you must have noticed Product Service
also depends on implementation of Product Store
// product_service.go
type ProductService interface {
List() ([]models.Product, error)
Get(id int) (models.Product, error)
Create(models.Product) error
}
type productService struct {
logger logger.Logger
store stores.ProductStore //depends on ProductStore
}
func NewProductService(l logger.Logger, s stores.ProductStore) ProductService {
return &productService{logger: l, store: s}
}
// main.go
productStore := stores.NewProductStore(logrusLog, dbConn)
productService := services.NewProductService(logrusLog, productStore)
It’s as simple as it looks, no framework magic, simply calling of functions and implementing interfaces.
I highly suggest, cloning the repo and moving around this tiny codebase to get the feel of it even if it’s slightly difficult to understand.
That’s it, I hope you get the idea of Dependency Injection and how to effectively use it in your next big AI enabled Todo application.