Understanding Dependency Injection Using Go

What is dependency injection

One of design pattern where an object (or struct) receives its dependecies from an external source rather than creating them itself.

think of it this way: when chef wants to cook, instead of building their own cooking utensils like pots, pans and knives, someone else provides them to the kitchen. this allows the chef to focus on entirely on cooking without worrying about how the tools were made.

The Problem: Programming Without DI

Let’s look what happens when we build a backend api without implementing di. we might create router like this:

file: /router/router.go

// create a router struct 
type router struct {
    // the router "own" the creation of its dependencies
	handler *handler.handler
}

// create router constructor
func newrouter() *router {
    // problem: we creating the handler instance inside the constructor
    handlerinstance := handler.newhandler()
    return &router{
        handler: handlerinstance
    }
}

func (r *router) init() http.handler {
    mux := http.newservemux()
	mux.handlefunc("/", r.handler.gethelloworld)
	return mux
}

why this is bad

  1. tight coupling: the router is now “married” to a specific implementation of the handler. if the newhandler function changes (e.g., it needs a database connection), you have to change the code inside newrouter as well.
  2. hard to test: you cannot easily swap this handler for a “mock” or a “fake” handler during unit testing.

the solution: implementing dependency injection

to fix this, we move the responsibility of creating the dependency outside of the router.

implement it

file: router/router.go

type router struct {
	handler *handler.handler
}

// the newrouter constructor now "receives" its dependencies
func newrouter(
	h *handler.handler,
) *router {
	return &router{
		handler: h,
	}
}

instead of create handler instance in the router, we can make the router constructor (newrouter) to receive the handler instance as parameter.

file: main.go (the orchestrator) now, our main function acts as the orchestrator. it knows exactly what every component needs.

func main() {
    // initiliaze the dependecies first
	handler := handler.newhandler()

    // inject the dependecies into the dependents
	router := router.newrouter(handler)
    
	err := http.listenandserve(":8080", router.init())
	fmt.println("running on port :8080")
	if err != nil {
		fmt.println("gagal start server:", err)
	}
}

by doing this, the router no longer cares how the handler is created. it just knows it needs one to function.

summary

by implementing dependency injection, we achieved three main goals:

  1. flexibility: we can easily change how our dependencies are configured without touching the logic of our structs.
  2. testability: we can pass “mocks” into our constructors during testing to isolate our code.
  3. clean architecture: our main.go becomes a clear map of how the entire application is wired together.

closing / conclusion:

while manual dependency injection works perfectly for small to medium-sized projects, you might find that as your project grows, your main.go becomes quite large. in the next article, we will explore how to automate this “wiring” process using a di container like uber-dig.