Dependency Injection

Understand Forge's powerful dependency injection system

Dependency Injection

Forge provides a powerful dependency injection system that promotes loose coupling, testability, and maintainability. The DI system supports multiple service lifetimes, scoped containers, and automatic lifecycle management.

Container Interface

The dependency injection container is the central component for managing services:

type Container interface {
    // Service registration
    Register(name string, factory Factory, opts ...RegisterOption) error
    
    // Service resolution
    Get(name string) (interface{}, error)
    Must[T any](name string) T
    
    // Scoped containers
    Scope() Scope
    
    // Service information
    Services() []ServiceInfo
    IsRegistered(name string) bool
}

Service Lifetimes

Forge supports three service lifetimes:

Singleton

Single instance per container. Created once and reused for all requests.

// Register singleton service
app.RegisterService("config", func(container Container) (interface{}, error) {
    return &ConfigManager{}, nil
}, forge.WithSingleton())

// Usage
config := forge.Must[ConfigManager](app.Container(), "config")

Use cases:

  • Configuration managers
  • Database connections
  • Cache clients
  • Loggers
  • Metrics collectors

Example:

type DatabaseService struct {
    conn *sql.DB
}

func (d *DatabaseService) Query(ctx context.Context, query string) (*sql.Rows, error) {
    return d.conn.QueryContext(ctx, query)
}

// Register as singleton
app.RegisterService("database", func(container Container) (interface{}, error) {
    db, err := sql.Open("postgres", "connection_string")
    if err != nil {
        return nil, err
    }
    return &DatabaseService{conn: db}, nil
}, forge.WithSingleton())

Scoped

Instance per scope (typically per HTTP request). Created once per scope and reused within that scope.

// Register scoped service
app.RegisterService("userContext", func(container Container) (interface{}, error) {
    return &UserContext{}, nil
}, forge.WithScoped())

// Usage in handler
func userHandler(ctx forge.Context) error {
    scopedContainer := ctx.Scope()
    userCtx := forge.Must[UserContext](scopedContainer, "userContext")
    return ctx.JSON(200, userCtx.GetUser())
}

Use cases:

  • User context
  • Request-specific data
  • Transaction contexts
  • Per-request caches

Example:

type UserContext struct {
    userID string
    user   *User
}

func (uc *UserContext) GetUser() *User {
    if uc.user == nil {
        uc.user = userService.GetUser(uc.userID)
    }
    return uc.user
}

// Register as scoped
app.RegisterService("userContext", func(container Container) (interface{}, error) {
    return &UserContext{}, nil
}, forge.WithScoped())

Transient

New instance every time. Created fresh for each resolution.

// Register transient service
app.RegisterService("validator", func(container Container) (interface{}, error) {
    return &Validator{}, nil
}, forge.WithTransient())

// Usage
validator := forge.Must[Validator](app.Container(), "validator")

Use cases:

  • Validators
  • Mappers
  • Temporary services
  • Stateless utilities

Example:

type Validator struct {
    rules map[string]ValidationRule
}

func (v *Validator) Validate(obj interface{}) error {
    // Validation logic
    return nil
}

// Register as transient
app.RegisterService("validator", func(container Container) (interface{}, error) {
    return &Validator{rules: loadValidationRules()}, nil
}, forge.WithTransient())

Service Registration

Basic Registration

// Register a simple service
app.RegisterService("userService", func(container Container) (interface{}, error) {
    return &UserService{}, nil
})

// Register with dependencies
app.RegisterService("userService", func(container Container) (interface{}, error) {
    db := forge.Must[DatabaseService](container, "database")
    cache := forge.Must[CacheService](container, "cache")
    logger := forge.Must[Logger](container, "logger")
    
    return &UserService{
        db:     db,
        cache:  cache,
        logger: logger,
    }, nil
})

Registration Options

// Register with options
app.RegisterService("userService", factory,
    forge.WithSingleton(),           // Service lifetime
    forge.WithHealthCheck(),         // Enable health checks
    forge.WithStartup(),             // Start during app startup
    forge.WithShutdown(),            // Stop during app shutdown
    forge.WithDependencies("database", "cache"), // Declare dependencies
)

Interface Registration

// Register interface implementation
app.RegisterService("userService", func(container Container) (interface{}, error) {
    return &UserServiceImpl{}, nil
})

// Register multiple implementations
app.RegisterService("userService", func(container Container) (interface{}, error) {
    return &UserServiceImpl{}, nil
})

app.RegisterService("adminUserService", func(container Container) (interface{}, error) {
    return &AdminUserServiceImpl{}, nil
})

Service Resolution

Basic Resolution

// Get service by name
service, err := app.Container().Get("userService")
if err != nil {
    return err
}

// Type assertion
userService := service.(*UserService)

Type-Safe Resolution

// Type-safe resolution with Must
userService := forge.Must[UserService](app.Container(), "userService")

// Type-safe resolution with error handling
userService, err := forge.Get[UserService](app.Container(), "userService")
if err != nil {
    return err
}

Scoped Resolution

// Create scoped container
scopedContainer := app.Container().Scope()

// Register scoped services
scopedContainer.Register("requestID", func(container Container) (interface{}, error) {
    return generateRequestID(), nil
}, forge.WithScoped())

// Resolve scoped services
requestID := forge.Must[string](scopedContainer, "requestID")

Lifecycle Management

Startup and Shutdown

type DatabaseService struct {
    conn *sql.DB
}

func (d *DatabaseService) Start(ctx context.Context) error {
    // Initialize database connection
    return d.conn.PingContext(ctx)
}

func (d *DatabaseService) Stop(ctx context.Context) error {
    // Close database connection
    return d.conn.Close()
}

// Register with lifecycle management
app.RegisterService("database", func(container Container) (interface{}, error) {
    return &DatabaseService{}, nil
}, forge.WithStartup(), forge.WithShutdown())

Health Checks

type CacheService struct {
    client redis.Client
}

func (c *CacheService) Health(ctx context.Context) error {
    // Check cache health
    return c.client.Ping(ctx).Err()
}

// Register with health checks
app.RegisterService("cache", func(container Container) (interface{}, error) {
    return &CacheService{}, nil
}, forge.WithHealthCheck())

Dependency Injection Patterns

Constructor Injection

type UserService struct {
    db     DatabaseService
    cache  CacheService
    logger Logger
}

func NewUserService(db DatabaseService, cache CacheService, logger Logger) *UserService {
    return &UserService{
        db:     db,
        cache:  cache,
        logger: logger,
    }
}

// Register with constructor
app.RegisterService("userService", func(container Container) (interface{}, error) {
    db := forge.Must[DatabaseService](container, "database")
    cache := forge.Must[CacheService](container, "cache")
    logger := forge.Must[Logger](container, "logger")
    
    return NewUserService(db, cache, logger), nil
})

Property Injection

type UserService struct {
    DB     DatabaseService `inject:"database"`
    Cache  CacheService    `inject:"cache"`
    Logger Logger          `inject:"logger"`
}

// Register with property injection
app.RegisterService("userService", func(container Container) (interface{}, error) {
    service := &UserService{}
    
    // Inject dependencies
    if err := container.Inject(service); err != nil {
        return nil, err
    }
    
    return service, nil
})

Factory Pattern

type ServiceFactory struct {
    container Container
}

func (f *ServiceFactory) CreateUserService() *UserService {
    db := forge.Must[DatabaseService](f.container, "database")
    cache := forge.Must[CacheService](f.container, "cache")
    logger := forge.Must[Logger](f.container, "logger")
    
    return &UserService{
        db:     db,
        cache:  cache,
        logger: logger,
    }
}

// Register factory
app.RegisterService("serviceFactory", func(container Container) (interface{}, error) {
    return &ServiceFactory{container: container}, nil
})

Testing with Dependency Injection

Mock Services

type MockUserService struct{}

func (m *MockUserService) GetUser(id string) (*User, error) {
    return &User{ID: id, Name: "Test User"}, nil
}

func TestUserHandler(t *testing.T) {
    app := forge.NewTestApp(forge.TestAppConfig{
        Name: "test-app",
    })
    
    // Register mock service
    app.RegisterService("userService", func(container Container) (interface{}, error) {
        return &MockUserService{}, nil
    })
    
    // Test handler
    handler := func(ctx forge.Context) error {
        userService := forge.Must[UserService](app.Container(), "userService")
        user, err := userService.GetUser("123")
        if err != nil {
            return err
        }
        return ctx.JSON(200, user)
    }
    
    // Test the handler
    resp := app.Test().GET("/users/123").Expect(t)
    resp.Status(200)
    resp.JSON().Object().Value("id").Equal("123")
}

Test Containers

func TestWithTestContainer(t *testing.T) {
    // Create test container
    container := forge.NewContainer()
    
    // Register test services
    container.Register("database", func(container Container) (interface{}, error) {
        return &MockDatabase{}, nil
    })
    
    container.Register("userService", func(container Container) (interface{}, error) {
        db := forge.Must[DatabaseService](container, "database")
        return &UserService{db: db}, nil
    })
    
    // Test service resolution
    userService := forge.Must[UserService](container, "userService")
    assert.NotNil(t, userService)
}

Advanced Features

Service Dependencies

// Declare service dependencies
app.RegisterService("userService", factory,
    forge.WithDependencies("database", "cache", "logger"),
)

// Automatic dependency resolution
app.RegisterService("userService", func(container Container) (interface{}, error) {
    // Dependencies are automatically resolved
    deps := container.GetDependencies("userService")
    
    db := deps["database"].(DatabaseService)
    cache := deps["cache"].(CacheService)
    logger := deps["logger"].(Logger)
    
    return &UserService{db: db, cache: cache, logger: logger}, nil
})

Service Decorators

// Decorate existing service
app.RegisterService("userService", func(container Container) (interface{}, error) {
    baseService := forge.Must[UserService](container, "baseUserService")
    logger := forge.Must[Logger](container, "logger")
    
    return &LoggingUserServiceDecorator{
        base:   baseService,
        logger: logger,
    }, nil
})

Service Interceptors

// Add interceptors to service calls
app.RegisterService("userService", func(container Container) (interface{}, error) {
    baseService := forge.Must[UserService](container, "baseUserService")
    
    return &InterceptedUserService{
        base: baseService,
        interceptor: func(method string, args []interface{}) {
            // Log method calls
            logger.Info("calling method", forge.F("method", method))
        },
    }, nil
})

Best Practices

  1. Interface-Based Design: Use interfaces for better testability
  2. Single Responsibility: Each service should have a single responsibility
  3. Dependency Declaration: Declare dependencies explicitly
  4. Lifecycle Management: Use startup/shutdown for resources
  5. Health Checks: Implement health checks for critical services
  6. Error Handling: Handle service creation errors properly
  7. Testing: Use mocks and test containers for testing
  8. Performance: Consider service lifetimes for performance

For more information about service lifecycle management, see the Health Checks documentation.

How is this guide?

Last updated on