Skip to Content
Course content

243: Practice Exercise: Building a Simple Dependency Injection Container

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably felt the pain of "constructor hell." It's that moment in a growing project where, to initialize a single UserService, you first have to initialize a UserRepository, which requires a DatabaseConnection, which requires a Config object. Before you've even written a line of business logic, you've spent twenty lines of code just wiring things together in main.go. It's tedious, and it makes your setup code brittle.

Think of a Dependency Injection (DI) container like a professional tool rental shop. If you're a contractor, you don't want to build a drill from scratch every time you need to put a screw in a wall. Instead, you go to the shop manager and say, "I need a drill." You don't care where the drill came from, who assembled it, or what battery is inside; you just want a working drill handed to you so you can get to work. The shop manager handles the inventory and the assembly—you just handle the task.

Mapping the Tool Shop to Go

In our code, the "Tool Shop Manager" is the Container. The "Inventory List" is a map that stores how to create specific types, and the "Request" is a method we'll call to retrieve those types. Here is how that maps out:

  • The Shop Manager: A struct that holds a registry of "providers" (functions that know how to create the dependency).
  • The Inventory List: A map where the key is the type of the service and the value is the function used to instantiate it.
  • The Request: A Resolve method that looks up the type in the map and executes the provider function.

Wiring the Container in Go

Go doesn't have the same kind of heavy reflection-based DI frameworks as Java or C#, and honestly, that's usually a good thing. We can build a lightweight version using reflect.Type as our key. I prefer this approach because it's explicit and avoids using string keys, which are magnets for typos.

package main

import (
	"fmt"
	"reflect"
)

// Provider is a function that returns a dependency
type Provider func() interface{}

// Container holds our mapping of types to their providers
type Container struct {
	providers map[reflect.Type]Provider
}

func NewContainer() *Container {
	return &Container{
		providers: make(map[reflect.Type]Provider),
	}
}

// Register tells the container how to create a specific type
func (c *Container) Register(target interface{}, provider Provider) {
	t := reflect.TypeOf(target)
	c.providers[t] = provider
}

// Resolve retrieves the dependency from the container
func (c *Container) Resolve(target interface{}) interface{} {
	t := reflect.TypeOf(target)
	if provider, ok := c.providers[t]; ok {
		return provider()
	}
	panic(fmt.Sprintf("no provider registered for type: %s", t))
}

Putting the Registry to Work

The real magic happens when we use this to decouple our services. Let's say we have a Database and a UserRepository. Instead of manually passing the DB into the repo in main, we let the container handle the chain.

type Database struct{ ConnectionString string }
type UserRepository struct{ DB *Database }

func main() {
	container := NewContainer()

	// Register the Database
	container.Register((*Database)(nil), func() interface{} {
		return &Database{ConnectionString: "postgres://localhost:5432"}
	})

	// Register the UserRepository, resolving its dependency from the container
	container.Register((*UserRepository)(nil), func() interface{} {
		db := container.Resolve((*Database)(nil)).(*Database)
		return &UserRepository{DB: db}
	})

	// Now we just ask for the Repo
	repo := container.Resolve((*UserRepository)(nil)).(*UserRepository)
	fmt.Println("Resolved repo with DB:", repo.DB.ConnectionString)
}

I'll be honest: the type assertions (the .(*Database) parts) are a bit clunky. That's the trade-off we make in Go for this kind of flexibility. In a production-grade system, you might use generics to wrap the Resolve method and hide those assertions, but for a practice exercise, seeing the raw machinery is more valuable.




📋 Practical Task

Exercise: Building an Email Notification System with DI

Your task is to expand the DI container concept to manage a notification system. You need to implement a system where a NotificationService depends on a MessageSender interface. This will prove that the container can handle interfaces, not just concrete structs.

Requirements:

  • Define a MessageSender interface with a method Send(msg string) error.
  • Create two implementations of MessageSender: EmailSender and SmsSender.
  • Create a NotificationService struct that requires a MessageSender in its constructor.
  • Using the Container logic from the lesson, register the EmailSender as the provider for the MessageSender interface.
  • Resolve the NotificationService and use it to send a message.
  • Bonus: Change a single line in your registration code to swap the EmailSender for the SmsSender without changing any logic inside the NotificationService.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.