do - Dependency Injection for Go

website·Indexed 33 days ago

https://do.samber.dev

A modern, type-safe dependency injection framework for Go focusing on service registration, service invocation, and container management.

Tokens
10K
Snippets
38
Records
51
Agent score
15%

What's inside do

  1. Overview of the do Dependency Injection library

    do is a lightweight, type-safe dependency injection (DI) framework for Go. It uses Go generics to provide a type-safe API, eliminating the runtime errors typically associated with reflection-based DI containers. Key capabilities include:

    • Service Management: Supports registration by type or name, and bulk registration from packages.
    • Invocation Strategies: Offers eager, lazy, and transient loading, as well as tag-based invocation and circular dependency detection.
    • Aliasing: Supports implicit (provide struct, invoke interface) and explicit (bind interface) aliasing.
    • Lifecycle & Health: Includes built-in support for health checks, graceful shutdown (dependency-aware parallel shutdown), and lifecycle hooks.
    • Container & Scoping: Features a scope (module) tree for visibility control, container cloning for testing, and service overrides.
    • Observability: Provides a Web UI and HTTP middleware (supporting std, Gin, Fiber, Echo, and Chi) for dependency graph visualization and introspection.

    Notably, the library requires no code generation and has no external dependencies.

  2. Technical characteristics of the `do` library

    The do library is designed for high performance and security with the following characteristics:

    • Type Safety: Uses compile-time type checking via generics to prevent runtime errors.
    • Security: Avoids reflection and code generation, eliminating common security concerns associated with those techniques.
    • Performance: Features minimal runtime overhead, efficient memory management with smart caching, and full thread safety for concurrent access.
    • Footprint: Minimal memory footprint, typically under 1MB for the library itself.
  3. Design services for dependency injection in Go

    To maintain a scalable and testable architecture when using do, follow these service design principles:

    1. Accept Interfaces, Return Structs: Services should depend on interfaces rather than concrete types to ensure loose coupling and easier mocking.
    2. Single Responsibility: Each service should have one well-defined purpose. For example, separate UserService (user management) from EmailService (communication).
    // GOOD: Accept interfaces, return structs
    type UserService interface {
        GetUser(id string) (*User, error)
    }
    
    type userService struct {
        db    Database // Interface
        cache Cache    // Interface
    }
    
    func NewUserService(db Database, cache Cache) *userService {
        return &userService{db: db, cache: cache}
    }
  4. Apply Repository and Factory patterns with do

    To maintain a scalable architecture, use the Repository pattern to abstract data access and the Factory pattern to handle the creation of complex objects or services based on runtime conditions.
    // Repository Pattern: Abstract data access
    type UserRepository interface {
        FindByID(id string) (*User, error)
        Save(user *User) error
    }
    
    type userRepository struct {
        db Database
    }
    
    func (r *userRepository) FindByID(id string) (*User, error) {
        return r.db.GetUser(id)
    }
    
    // Factory Pattern: Create complex objects
    type ServiceFactory interface {
        CreateUserService() (UserService, error)
    }
    
    type serviceFactory struct {
        config Config
    }
    
    func (f *serviceFactory) CreateUserService() (UserService, error) {
        db, err := f.createDatabase()
        if err != nil {
            return nil, err
        }
        return NewUserService(db), nil
    }
  5. System requirements and compatibility for `do`

    The do library requires Go 1.18 or later to support generics. It is compatible with all platforms and architectures supported by Go (including Linux, macOS, Windows, x86, x64, and ARM). It maintains backward compatibility within major versions and follows semantic versioning (SemVer).
  6. Initialize and use the do DI container

    The do library provides a type-safe dependency injection container for Go. The entry point is do.New(), which creates a container used to register and retrieve services.
    import "github.com/samber/do/v2"
    
    func main() {
        injector := do.New()
        // Register and invoke services using this injector
    }
  7. Manage dependency health propagation

    Services can incorporate the health status of their dependencies into their own health check. By invoking the HealthCheck() method of injected dependencies, a high-level service can ensure its entire dependency chain is functional before reporting itself as healthy.
    type ApplicationService struct {
        db    Database
        cache Cache
    }
    
    func (a *ApplicationService) HealthCheck() error {
        // Propagate health check to dependencies
        if err := a.db.HealthCheck(); err != nil {
            return fmt.Errorf("database health check failed: %w", err)
        }
        if err := a.cache.HealthCheck(); err != nil {
            return fmt.Errorf("cache health check failed: %w", err)
        }
        return nil
    }
  8. Implement service health checks

    The do library supports health checks to detect if a registered service is still operational before use.
    type database struct {
        connected bool
    }
    
    func (d *database) HealthCheck() error {
        if !d.connected {
            return errors.New("database not connected")
        }
        return nil
    }
    
    // Check health before using service
    if err := do.HealthCheck[Database](injector); err != nil {
        log.Printf("Database health check failed: %v", err)
    }
  9. Apply Dependency Inversion and Interface Segregation

    To ensure testability and maintainability in Go applications using do, depend on small, focused interfaces (Interface Segregation) rather than concrete implementations (Dependency Inversion). This allows for easy substitution of mocks during testing.
    // Good: Depend on abstractions and small interfaces
    type UserReader interface {
        GetUser(id string) (*User, error)
    }
    
    type userService struct {
        reader UserReader // Interface, not concrete type
    }
  10. Improve application startup performance

    To reduce startup latency and improve responsiveness, consider these strategies:

    • Eager Loading: Use for critical services that must be available immediately upon application start.
    • Parallel Initialization: Services without mutual dependencies can be initialized concurrently to reduce total startup time.
    • Dependency Optimization: Keep dependency graphs shallow to minimize the time spent on resolution.
    • Conditional Registration: Only register services required for the current environment or configuration to reduce management overhead.
  11. Migrate from Google Wire to do

    Migrating from wire involves replacing code-generated wire.go files with runtime registration using do.Provide and do.MustInvoke. Unlike wire, do supports full interface injection and hierarchical scopes.
    // Migration Example
    func main() {
        injector := do.New()
    
        // Replace concrete provider with do.Provide
        do.Provide(injector, func(i do.Injector) (Database, error) {
            return &PostgreSQLDatabase{}, nil
        })
    
        // Replace wire.go initialization with do.Provide and MustInvoke
        do.Provide(injector, func(i do.Injector) (*API, error) {
            db := do.MustInvoke[Database](i) // Interface injection
            return &API{db: db}, nil
        })
    
        api := do.MustInvoke[*API](injector)
    }
  12. Implement a hierarchical scope for dependency injection in Go

    The do library allows for the creation of a scope hierarchy where child scopes inherit services from their parents. This is useful for managing shared resources (root scope), user-specific services (user scope), and request-specific services (session scope).

    Key behaviors include:

    • Shared Resources: Services registered in the root scope are available to all child scopes.
    • Scope Isolation: Services registered in a specific scope are isolated from sibling scopes at the same level.
    • Dependency Inheritance: Child scopes can access services from parent scopes using do.MustInvoke[T](injector).
    • Lifecycle Management: Scopes can be managed independently for efficient resource cleanup.
    package main
    
    import (
        "fmt"
        "github.com/samber/do/v2"
    )
    
    type Database interface { Connect() error; Close() error }
    type UserRepository interface { FindByID(id string) (*User, error) }
    type SessionManager interface { CreateSession(userID string) (string, error) }
    type User struct { ID string; Name string }
    
    // Concrete implementations
    type database struct{ connectionString string }
    func (d *database) Connect() error { return nil }
    func (d *database) Close() error { return nil }
    
    type userRepository struct{ db Database }
    func (r *userRepository) FindByID(id string) (*User, error) { return &User{ID: id, Name: "User " + id}, nil }
    
    type sessionManager struct{ userRepo UserRepository }
    func (s *sessionManager) CreateSession(userID string) (string, error) { return "session-" + userID, nil }
    
    func main() {
        // 1. Create root scope (top-level container)
        root := do.New()
    
        // Register shared services in root
        do.Provide(root, func(i do.Injector) (Database, error) {
            return &database{connectionString: "postgres://localhost:5432/mydb"}, nil
        })
    
        // 2. Create child scope (e.g., user session)
        userScope := do.Scope(root, "user")
    
        // Register user-specific services
        do.Provide(userScope, func(i do.Injector) (UserRepository, error) {
            // Access Database from root scope
            db := do.MustInvoke[Database](i)
            return &userRepository{db: db}, nil
        })
    
        // 3. Create grandchild scope (e.g., individual session)
        sessionScope := do.Scope(userScope, "session")
    
        // Register session-specific services
        do.Provide(sessionScope, func(i do.Injector) (SessionManager, error) {
            // Access UserRepository from user scope
            userRepo := do.MustInvoke[UserRepository](i)
            return &sessionManager{userRepo: userRepo}, nil
        })
    
        // Accessing services: Child scopes can resolve their own and parent services
        sessionMgr := do.MustInvoke[SessionManager](sessionScope)
        userRepo := do.MustInvoke[UserRepository](sessionScope)
        db := do.MustInvoke[Database](sessionScope)
    
        fmt.Println("All services resolved successfully")
    }