Building a Simple Inversion of Control Container in Go
Inversion of Control (IoC) is a design principle that plays a key role in modern software architecture. IoC containers help manage dependencies by decoupling object creation from object usage, resulting in cleaner, more modular, and testable code.
While Go provides excellent support for dependency injection through libraries like Dig, Wire, and Fx, these libraries can be complex and require a steep learning curve. For simpler use cases, a lightweight IoC container might be a better fit.
This post demonstrates how to create a straightforward IoC container in Go. The implementation is designed to be thread-safe, easy to use, and minimalistic.
Implementing the IoC Container
Step 1: Define the Container Type and Container Variable
package ioc
import (
"fmt"
"sync"
)
type container struct {
services map[string]interface{}
mu sync.RWMutex
}
var globalContainer = &container{
services: make(map[string]interface{}),
}
The container
struct stores registered services in a map and ensures thread-safe access using a read-write mutex. The globalContainer
variable is a singleton instance of the container
struct.
Step 2: Define the Register Function
func Register(name string, service interface{}) {
globalContainer.mu.Lock()
defer globalContainer.mu.Unlock()
globalContainer.services[name] = service
}
The Register
function associates a name with a service and locks the container for writing to ensure thread safety.
Step 3: Define the Resolve Function
func Resolve[T any](name string) (T, error) {
globalContainer.mu.RLock()
defer globalContainer.mu.RUnlock()
service, exists := globalContainer.services[name]
if !exists {
var zero T
return zero, fmt.Errorf("service not found: %s", name)
}
result, ok := service.(T)
if !ok {
var zero T
return zero, fmt.Errorf("service type mismatch: expected %T", zero)
}
return result, nil
}
The Resolve
function retrieves a registered service by name and casts it to the specified type. It locks the container for reading, ensuring that concurrent reads are safe.
Using the IoC Container
Here is an example of how to use the IoC container to register and resolve a service:
package main
import (
"fmt"
"ioc"
)
const myServiceName = "myService"
type MyService struct {
Name string
}
func main() {
// Register a service
ioc.Register(myServiceName, &MyService{Name: "Test Service"})
// Resolve the service
service, err := ioc.Resolve[*MyService](myServiceName)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Service Name:", service.Name)
}
This example registers a service with the container and then resolves it by name, demonstrating both the flexibility and simplicity of the implementation.
Enhancements and Extensions
This basic IoC container can serve as a foundation for more advanced features, such as:
- Lifecycle Management: Adding support for transient, singleton, or scoped lifecycles.
- Dependency Graph Resolution: Automatically resolving dependencies for complex services.
- Configuration Support: Integrating with configuration files or environment variables for dynamic service registration.
Conclusion
Building a simple IoC container in Go is a great exercise to understand dependency injection and its benefits. While lightweight implementations like this are sufficient for many use cases, consider established libraries for larger or more complex projects.
For a deeper understanding of IoC and dependency injection, check out Martin Fowler’s article.
You can explore the full source code here:
I hope this guide helps you in your Go projects. If you have questions or suggestions, feel free to share them in the comments!