Dependency Injection (DI) is a design pattern that promotes loose coupling and testability in software applications. It allows you to inject dependencies (e.g., services, configurations, or databases) into a component rather than having the component create or manage them directly. This makes your code more modular, maintainable, and easier to test.
In this post, we’ll explore the Dependency Injection pattern using a practical Golang example. We’ll break down the code and explain how DI is implemented in a real-world scenario.
The Example: A Container for Managing Dependencies
The example consists of three Go files that work together to create a "container" for managing dependencies like a logger, database connection, and configuration. Let’s dive into the code and see how DI is applied.
1. Logger Setup
The first file sets up a logger using the zap logging library. The logger is initialized using a configuration file, and the NewLogger function is responsible for creating the logger instance.
func NewLogger(zapConfig string) (*zap.Logger, error) {
file, err := os.Open(zapConfig)
if err != nil {
return nil, fmt.Errorf("failed to open logger config file")
}
defer file.Close()
var cfg zap.Config
if err := json.NewDecoder(file).Decode(&cfg); err != nil {
return nil, fmt.Errorf("failed to parse logger config json")
}
logger, err := cfg.Build()
if err != nil {
return nil, err
}
defer logger.Sync()
logger.Debug("logger construction succeeded")
return logger, nil
}
Here, the NewLogger function takes a configuration file path (zapConfig) as input and returns a zap.Logger instance. This is an example of constructor injection, where the dependency (logger configuration) is injected into the function.
2. Database Connection Setup
The second file handles database connections using the gorm library. It defines an interface Db and two implementations (PostgresAdapter and MySQLAdapter) for connecting to PostgreSQL and MySQL databases.
type Db interface {
MakeConnection() (*gorm.DB, error)
}
func NewDBConnectionAdapter(dbName, url string, dbMaxIdle, dbMaxOpen, dbMaxLifeTime, dbMaxIdleTime int, gormConf string) Db {
switch dbName {
case Postgres:
return &PostgresAdapter{dbUrl: url, dbMaxIdle: dbMaxIdle, dbMaxOpen: dbMaxOpen, dbMaxLifeTime: dbMaxLifeTime, dbMaxIdleTime: dbMaxIdleTime, gormConf: gormConf}
case Mysql:
return &MySQLAdapter{dbUrl: url, dbMaxIdle: dbMaxIdle, dbMaxOpen: dbMaxOpen, dbMaxLifeTime: dbMaxLifeTime, dbMaxIdleTime: dbMaxIdleTime, gormConf: gormConf}
}
return &PostgresAdapter{dbUrl: url, dbMaxIdle: dbMaxIdle, dbMaxOpen: dbMaxOpen, dbMaxLifeTime: dbMaxLifeTime, dbMaxIdleTime: dbMaxIdleTime, gormConf: gormConf}
}
The NewDBConnectionAdapter function acts as a factory, creating the appropriate database adapter based on the dbName parameter. This is an example of factory injection, where the factory decides which implementation to inject.
3. Container for Managing Dependencies
The third file defines a Container interface and its implementation. The container is responsible for managing all dependencies (logger, database, etc.) and injecting them where needed.
type Container interface {
Logger() *zap.Logger
Db() *gorm.DB
Port() string
PprofEnable() string
}
type container struct {
logger *zap.Logger
db *gorm.DB
port string
pprofEnable string
environmentVariables map[string]string
}
func New(envVars map[string]string) (Container, error) {
c := &container{environmentVariables: envVars}
var err error
c.db, err = c.dbSetup()
if err != nil {
return c, err
}
c.logger, err = c.loggerSetup()
if err != nil {
return c, err
}
c.port, err = c.portSetup()
if err != nil {
return c, err
}
c.pprofEnable, err = c.pprofEnableSetup()
if err != nil {
return c, err
}
return c, nil
}
The New function initializes the container by setting up all dependencies. It uses constructor injection to pass environment variables and configuration to the container. Each dependency (logger, database, etc.) is initialized separately, making the code modular and easy to test.
Key Benefits of Dependency Injection in This Example
Loose Coupling:
The Container does not directly create its dependencies. Instead, it relies on external configurations and factories to provide them. This makes the code more flexible and easier to modify.
Testability:
Since dependencies are injected, you can easily mock them during testing. For example, you can replace the real database connection with a mock database for unit tests.
Single Responsibility Principle:
Each component (logger, database adapter, etc.) has a single responsibility. The Container is only responsible for managing dependencies, not for creating them.
Reusability:
The Db interface and its implementations can be reused across different parts of the application. You can switch between PostgreSQL and MySQL without changing the core logic.
How to Use the Container
Here’s how you can use the Container in your application:
func main() {
c, err := container.New(map[string]string{
container.LogLevelEnvVar: os.Getenv(container.LogLevelEnvVar),
container.DatabaseURLEnvVar: os.Getenv(container.DatabaseURLEnvVar),
container.PortEnvVar: os.Getenv(container.PortEnvVar),
container.DBMaxIdleEnvVar: os.Getenv(container.DBMaxIdleEnvVar),
container.DBMaxOpenEnvVar: os.Getenv(container.DBMaxOpenEnvVar),
container.DBMaxLifeTimeEnvVar: os.Getenv(container.DBMaxLifeTimeEnvVar),
container.DBMaxIdleTimeEnvVar: os.Getenv(container.DBMaxIdleTimeEnvVar),
container.ZapConf: os.Getenv(container.ZapConf),
container.GormConf: os.Getenv(container.GormConf),
container.PprofEnable: os.Getenv(container.PprofEnable),
})
if err != nil {
defer func() {
fmt.Println("server initialization failed error: %w", err)
}()
panic("server initialization failed")
}
logger := c.Logger()
db := c.Db()
logger.Info("Application started", zap.String("port", c.Port()))
// Use db and logger in your application...
}
Conclusion
The Dependency Injection pattern is a powerful tool for building modular, testable, and maintainable applications. In this example, we saw how DI can be implemented in Go using interfaces, factories, and a container to manage dependencies.
By adopting DI, you can:
- Decouple your application’s components.
- Improve testability.
- Make your code more reusable and maintainable.
If you’re new to Dependency Injection, I encourage you to try implementing it in your own projects. Start small, and gradually refactor your code to use DI where it makes sense. Happy coding!
Top comments (1)
Excellent Post