Dependency Injection Container
This package provides a dependency injection (DI) container for assembling and managing application components following Clean Architecture principles.
Overview
The DI container centralizes the creation and wiring of all application dependencies, including:
- Infrastructure components (database, logger)
- Repositories (data access layer)
- Use cases (business logic layer)
- HTTP servers and handlers
- Background workers
Key Features
1. Lazy Initialization
Components are only created when first accessed, improving startup time and memory usage.
container := app.NewContainer(cfg)
// Nothing is initialized yet
server, err := container.HTTPServer()
// Now database, repositories, use cases, and server are initialized
2. Singleton Pattern
Each component is initialized only once and reused for subsequent calls.
logger1 := container.Logger()
logger2 := container.Logger()
// logger1 == logger2 (same instance)
3. Error Handling
Initialization errors are captured and returned consistently:
server, err := container.HTTPServer()
if err != nil {
// Handle initialization error
}
4. Clean Shutdown
The container provides a unified shutdown method to clean up all resources:
defer container.Shutdown(ctx)
Architecture
Dependency Graph
Container
├── Config (provided)
├── Logger
│ └── depends on: Config.LogLevel
├── Database
│ └── depends on: Config.DB*
├── TxManager
│ └── depends on: Database
├── Repositories
│ ├── UserRepository (interface from usecase package)
│ │ ├── MySQLUserRepository (concrete implementation)
│ │ ├── PostgreSQLUserRepository (concrete implementation)
│ │ └── depends on: Database
│ └── OutboxRepository (interface from usecase package)
│ ├── MySQLOutboxEventRepository (concrete implementation)
│ ├── PostgreSQLOutboxEventRepository (concrete implementation)
│ └── depends on: Database
├── Use Cases
│ └── UserUseCase
│ ├── depends on: TxManager
│ ├── depends on: UserRepository
│ └── depends on: OutboxRepository
├── HTTP Server
│ ├── depends on: Logger
│ └── depends on: UserUseCase
└── Event Worker
├── depends on: Logger
├── depends on: TxManager
└── depends on: OutboxRepository
Layer Separation
The container enforces clean architecture by managing dependencies at each layer:
- Infrastructure Layer: Database connections, logger, transaction manager
- Data Layer: Repositories for data access
- Business Layer: Use cases with business logic
- Presentation Layer: HTTP handlers and workers
Usage Examples
Starting the HTTP Server
func runServer(ctx context.Context) error {
// Load configuration
cfg := config.Load()
// Create DI container
container := app.NewContainer(cfg)
// Get logger
logger := container.Logger()
logger.Info("starting server")
// Ensure cleanup on exit
defer closeContainer(container, logger)
// Get HTTP server (initializes all dependencies)
server, err := container.HTTPServer()
if err != nil {
return fmt.Errorf("failed to initialize HTTP server: %w", err)
}
// Start server
return server.Start(ctx)
}
Starting the Worker
func runWorker(ctx context.Context) error {
cfg := config.Load()
container := app.NewContainer(cfg)
logger := container.Logger()
defer closeContainer(container, logger)
// Get event worker (initializes required dependencies)
eventWorker, err := container.EventWorker()
if err != nil {
return fmt.Errorf("failed to initialize event worker: %w", err)
}
return eventWorker.Start(ctx)
}
Testing
The container is designed to be easily testable:
Unit Testing the Container
func TestContainer(t *testing.T) {
cfg := &config.Config{
LogLevel: "info",
// ... other config
}
container := app.NewContainer(cfg)
logger := container.Logger()
if logger == nil {
t.Fatal("expected non-nil logger")
}
}
Integration Testing with Container
For integration tests, you can create a container with test configuration:
func setupTestContainer(t *testing.T) *app.Container {
cfg := &config.Config{
DBDriver: "postgres",
DBConnectionString: "postgres://test:test@localhost:5432/test_db",
LogLevel: "debug",
}
container := app.NewContainer(cfg)
t.Cleanup(func() {
container.Shutdown(context.Background())
})
return container
}
Adding New Components
To add a new component to the container:
1. Add field to Container struct
type Container struct {
// ... existing fields
// New component
orderUseCase *orderUsecase.OrderUseCase
orderUseCaseInit sync.Once
}
2. Add getter method
func (c *Container) OrderUseCase() (*orderUsecase.OrderUseCase, error) {
var err error
c.orderUseCaseInit.Do(func() {
c.orderUseCase, err = c.initOrderUseCase()
if err != nil {
c.initErrors["orderUseCase"] = err
}
})
if err != nil {
return nil, err
}
if storedErr, exists := c.initErrors["orderUseCase"]; exists {
return nil, storedErr
}
return c.orderUseCase, nil
}
3. Add initialization method
func (c *Container) initProductRepository() (productUsecase.ProductRepository, error) {
db, err := c.DB()
if err != nil {
return nil, fmt.Errorf("failed to get database: %w", err)
}
// Select the appropriate repository based on the database driver
switch c.config.DBDriver {
case "mysql":
return productRepository.NewMySQLProductRepository(db), nil
case "postgres":
return productRepository.NewPostgreSQLProductRepository(db), nil
default:
return nil, fmt.Errorf("unsupported database driver: %s", c.config.DBDriver)
}
}
Benefits of This Approach
1. Centralized Dependency Management
All component wiring is in one place (internal/app/di.go), making it easy to understand and maintain the application structure.
2. Clean main.go
The main.go file is significantly simpler and focused on application flow rather than dependency wiring.
Before:
// 60+ lines of manual dependency wiring
db, err := database.Connect(...)
txManager := database.NewTxManager(db)
// Determine which repository to use
var userRepo userUsecase.UserRepository
switch cfg.DBDriver {
case "mysql":
userRepo = userRepository.NewMySQLUserRepository(db)
case "postgres":
userRepo = userRepository.NewPostgreSQLUserRepository(db)
}
var outboxRepo userUsecase.OutboxEventRepository
switch cfg.DBDriver {
case "mysql":
outboxRepo = outboxRepository.NewMySQLOutboxEventRepository(db)
case "postgres":
outboxRepo = outboxRepository.NewPostgreSQLOutboxEventRepository(db)
}
userUseCase, err := userUsecase.NewUserUseCase(txManager, userRepo, outboxRepo)
server := http.NewServer(cfg.ServerHost, cfg.ServerPort, logger, userUseCase)
After:
// Clean and simple
container := app.NewContainer(cfg)
server, err := container.HTTPServer()
3. Testability
The container can be easily tested and mocked for integration tests.
4. Consistency
All parts of the application (server, worker, migrations) use the same dependency initialization logic.
5. Scalability
Adding new domains (orders, products, etc.) is straightforward - just add methods to the container.
6. Type Safety
All dependencies are type-checked at compile time, unlike reflection-based DI frameworks.
Alternative Approaches
This implementation uses manual dependency injection with a container pattern. Other approaches include:
- Google Wire: Code generation for compile-time DI
- Uber Fx: Runtime reflection-based DI framework
- Pure Manual DI: Direct construction in main.go (previous approach)
The current approach provides a good balance between:
- Simplicity (no external DI framework)
- Maintainability (centralized wiring)
- Performance (no reflection)
- Type safety (compile-time checking)
Best Practices
- Always use defer for cleanup:
defer closeContainer(container, logger)
- Check initialization errors: Always check errors returned by container methods
- Use lazy initialization: Don't initialize components you don't need
- Keep interfaces: Continue using interfaces for all dependencies
- Test the container: Write tests for container initialization logic
- Document dependencies: Keep the dependency graph documentation updated
Thread Safety
The container uses sync.Once to ensure thread-safe lazy initialization. Multiple goroutines can safely call container methods concurrently.
- Lazy initialization reduces startup time for commands that don't need all components
- Singleton pattern prevents creating duplicate instances
- No reflection ensures fast performance compared to reflection-based DI
- Compile-time safety catches dependency errors at build time
Future Enhancements
Potential improvements for the container:
- Component lifecycle hooks: Add
OnStart and OnStop hooks
- Health checks: Integrate health checking into the container
- Metrics: Add metrics for component initialization time
- Configuration validation: Validate configuration before initializing components
- Graceful degradation: Support optional dependencies that can fail gracefully