Dependency Injection
GOE is built entirely on Uber's Fx dependency injection framework. Understanding how to use Fx effectively is crucial for building maintainable and testable GOE applications.
Overview
Dependency injection in GOE provides:
- Automatic Dependency Resolution: Fx automatically wires dependencies
- Lifecycle Management: Proper startup and shutdown ordering
- Testability: Easy mocking and testing of components
- Modularity: Clear separation of concerns
Basic Concepts
Providers
Providers are constructor functions that tell Fx how to create dependencies:
go
// Provider function
func NewUserService(db contract.DB, logger contract.Logger) *UserService {
return &UserService{db: db, logger: logger}
}
// Register provider
goe.New(goe.Options{
Providers: []any{NewUserService},
})
Invokers
Invokers are functions that are called at startup with their dependencies injected:
go
// Invoker function
func SetupRoutes(httpKernel contract.HTTPKernel, userService *UserService) {
app := httpKernel.App()
app.Get("/users", userService.GetUsers)
}
// Register invoker
goe.New(goe.Options{
Invokers: []any{SetupRoutes},
})
Dependency Patterns
Simple Dependency
go
type UserService struct {
logger contract.Logger
}
func NewUserService(logger contract.Logger) *UserService {
return &UserService{logger: logger}
}
func (s *UserService) GetUsers() []User {
s.logger.Info("Getting users")
return []User{}
}
Multiple Dependencies
go
type UserService struct {
db contract.DB
logger contract.Logger
cache contract.Cache
}
func NewUserService(db contract.DB, logger contract.Logger, cache contract.Cache) *UserService {
return &UserService{
db: db,
logger: logger,
cache: cache,
}
}
Interface Dependencies
go
type EmailService interface {
SendEmail(to, subject, body string) error
}
type UserService struct {
emailService EmailService
logger contract.Logger
}
func NewUserService(emailService EmailService, logger contract.Logger) *UserService {
return &UserService{
emailService: emailService,
logger: logger,
}
}
Lifecycle Hooks
OnStart Hook
go
func NewDatabaseService(config contract.Config, logger contract.Logger) *DatabaseService {
return &DatabaseService{config: config, logger: logger}
}
func (s *DatabaseService) OnStart(ctx context.Context) error {
s.logger.Info("Connecting to database")
// Connect to database
conn, err := sql.Open("postgres", s.config.GetString("DATABASE_URL"))
if err != nil {
return err
}
s.connection = conn
return nil
}
// Register with lifecycle
goe.New(goe.Options{
Providers: []any{NewDatabaseService},
Invokers: []any{
func(lc fx.Lifecycle, dbService *DatabaseService) {
lc.Append(fx.Hook{
OnStart: dbService.OnStart,
OnStop: dbService.OnStop,
})
},
},
})
OnStop Hook
go
func (s *DatabaseService) OnStop(ctx context.Context) error {
s.logger.Info("Closing database connection")
if s.connection != nil {
return s.connection.Close()
}
return nil
}
Advanced Patterns
Optional Dependencies
go
type UserService struct {
db contract.DB
logger contract.Logger
cache contract.Cache // Optional
}
func NewUserService(db contract.DB, logger contract.Logger, cache contract.Cache) *UserService {
return &UserService{
db: db,
logger: logger,
cache: cache,
}
}
// Make cache optional
type UserServiceParams struct {
fx.In
DB contract.DB
Logger contract.Logger
Cache contract.Cache `optional:"true"`
}
func NewUserService(params UserServiceParams) *UserService {
return &UserService{
db: params.DB,
logger: params.Logger,
cache: params.Cache,
}
}
Named Dependencies
go
type DatabaseConfig struct {
Primary string `name:"primary"`
Secondary string `name:"secondary"`
}
func NewPrimaryDatabase(config contract.Config) *sql.DB {
// Connect to primary database
conn, _ := sql.Open("postgres", config.GetString("PRIMARY_DB_URL"))
return conn
}
func NewSecondaryDatabase(config contract.Config) *sql.DB {
// Connect to secondary database
conn, _ := sql.Open("postgres", config.GetString("SECONDARY_DB_URL"))
return conn
}
type UserService struct {
primaryDB *sql.DB `name:"primary"`
secondaryDB *sql.DB `name:"secondary"`
}
func NewUserService(primaryDB *sql.DB, secondaryDB *sql.DB) *UserService {
return &UserService{
primaryDB: primaryDB,
secondaryDB: secondaryDB,
}
}
// Register named providers
goe.New(goe.Options{
Providers: []any{
fx.Annotate(NewPrimaryDatabase, fx.ResultTags(`name:"primary"`)),
fx.Annotate(NewSecondaryDatabase, fx.ResultTags(`name:"secondary"`)),
fx.Annotate(NewUserService, fx.ParamTags(`name:"primary"`, `name:"secondary"`)),
},
})
Group Dependencies
go
type Handler interface {
Pattern() string
Handle(c fiber.Ctx) error
}
type UserHandler struct{}
func (h *UserHandler) Pattern() string { return "/users" }
func (h *UserHandler) Handle(c fiber.Ctx) error { return c.SendString("Users") }
type PostHandler struct{}
func (h *PostHandler) Pattern() string { return "/posts" }
func (h *PostHandler) Handle(c fiber.Ctx) error { return c.SendString("Posts") }
func NewUserHandler() Handler {
return &UserHandler{}
}
func NewPostHandler() Handler {
return &PostHandler{}
}
type RouterParams struct {
fx.In
Handlers []Handler `group:"handlers"`
}
func SetupRoutes(httpKernel contract.HTTPKernel, params RouterParams) {
app := httpKernel.App()
for _, handler := range params.Handlers {
app.Get(handler.Pattern(), handler.Handle)
}
}
// Register with groups
goe.New(goe.Options{
Providers: []any{
fx.Annotate(NewUserHandler, fx.As(new(Handler)), fx.ResultTags(`group:"handlers"`)),
fx.Annotate(NewPostHandler, fx.As(new(Handler)), fx.ResultTags(`group:"handlers"`)),
},
Invokers: []any{SetupRoutes},
})
Testing with Dependency Injection
Unit Testing
go
func TestUserService(t *testing.T) {
// Create mocks
mockDB := &MockDB{}
mockLogger := &MockLogger{}
// Create service with mocked dependencies
service := NewUserService(mockDB, mockLogger)
// Test the service
users := service.GetUsers()
assert.NotNil(t, users)
// Verify mock calls
mockLogger.AssertCalled(t, "Info", "Getting users")
}
Integration Testing
go
func TestUserService_Integration(t *testing.T) {
app := fx.New(
fx.Provide(
NewUserService,
NewMockDB,
NewMockLogger,
),
fx.Invoke(func(service *UserService) {
// Test service with real dependencies
users := service.GetUsers()
assert.NotNil(t, users)
}),
)
ctx := context.Background()
err := app.Start(ctx)
assert.NoError(t, err)
err = app.Stop(ctx)
assert.NoError(t, err)
}
Test Doubles
go
type MockDB struct {
users []User
}
func (m *MockDB) Instance() *gorm.DB {
// Return mock GORM instance
return nil
}
func NewMockDB() contract.DB {
return &MockDB{
users: []User{
{ID: 1, Name: "John Doe"},
{ID: 2, Name: "Jane Smith"},
},
}
}
type MockLogger struct {
logs []string
}
func (m *MockLogger) Info(msg string, keysAndValues ...interface{}) {
m.logs = append(m.logs, msg)
}
func NewMockLogger() contract.Logger {
return &MockLogger{}
}
Best Practices
1. Use Interfaces
go
// Good - depend on interfaces
type UserService struct {
repository UserRepository
logger contract.Logger
}
func NewUserService(repository UserRepository, logger contract.Logger) *UserService {
return &UserService{repository: repository, logger: logger}
}
// Avoid - depending on concrete types
type UserService struct {
db *sql.DB
logger *zap.Logger
}
2. Keep Constructors Simple
go
// Good - simple constructor
func NewUserService(db contract.DB, logger contract.Logger) *UserService {
return &UserService{db: db, logger: logger}
}
// Avoid - complex initialization in constructor
func NewUserService(config contract.Config, logger contract.Logger) *UserService {
// Complex initialization logic
db, err := sql.Open("postgres", config.GetString("DATABASE_URL"))
if err != nil {
logger.Fatal("Failed to connect to database", "error", err)
}
return &UserService{db: db, logger: logger}
}
3. Use Lifecycle Hooks for Setup
go
func NewDatabaseService(config contract.Config, logger contract.Logger) *DatabaseService {
return &DatabaseService{config: config, logger: logger}
}
func (s *DatabaseService) OnStart(ctx context.Context) error {
// Initialize database connection
conn, err := sql.Open("postgres", s.config.GetString("DATABASE_URL"))
if err != nil {
return err
}
s.connection = conn
return nil
}
4. Use Groups for Similar Dependencies
go
type Middleware func(fiber.Handler) fiber.Handler
func NewAuthMiddleware() Middleware {
return func(next fiber.Handler) fiber.Handler {
return func(c fiber.Ctx) error {
// Auth logic
return next(c)
}
}
}
func NewLoggingMiddleware() Middleware {
return func(next fiber.Handler) fiber.Handler {
return func(c fiber.Ctx) error {
// Logging logic
return next(c)
}
}
}
type MiddlewareParams struct {
fx.In
Middlewares []Middleware `group:"middlewares"`
}
func SetupMiddleware(httpKernel contract.HTTPKernel, params MiddlewareParams) {
app := httpKernel.App()
for _, middleware := range params.Middlewares {
app.Use(middleware)
}
}
Error Handling
Constructor Errors
go
func NewUserService(db contract.DB, logger contract.Logger) (*UserService, error) {
// Validate dependencies
if db == nil {
return nil, errors.New("database is required")
}
if logger == nil {
return nil, errors.New("logger is required")
}
return &UserService{db: db, logger: logger}, nil
}
Lifecycle Errors
go
func (s *DatabaseService) OnStart(ctx context.Context) error {
conn, err := sql.Open("postgres", s.config.GetString("DATABASE_URL"))
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
if err := conn.PingContext(ctx); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
s.connection = conn
return nil
}
Common Patterns
Service Layer
go
type UserService struct {
repository UserRepository
logger contract.Logger
}
func NewUserService(repository UserRepository, logger contract.Logger) *UserService {
return &UserService{repository: repository, logger: logger}
}
func (s *UserService) GetUser(id int) (*User, error) {
s.logger.Info("Getting user", "id", id)
user, err := s.repository.FindByID(id)
if err != nil {
s.logger.Error("Failed to get user", "id", id, "error", err)
return nil, err
}
return user, nil
}
Repository Layer
go
type UserRepository interface {
FindByID(id int) (*User, error)
Create(user *User) error
Update(user *User) error
Delete(id int) error
}
type userRepository struct {
db contract.DB
}
func NewUserRepository(db contract.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) FindByID(id int) (*User, error) {
var user User
err := r.db.Instance().First(&user, id).Error
return &user, err
}
Handler Layer
go
type UserHandler struct {
userService *UserService
logger contract.Logger
}
func NewUserHandler(userService *UserService, logger contract.Logger) *UserHandler {
return &UserHandler{userService: userService, logger: logger}
}
func (h *UserHandler) GetUser(c fiber.Ctx) error {
id, err := c.ParamsInt("id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
user, err := h.userService.GetUser(id)
if err != nil {
h.logger.Error("Failed to get user", "id", id, "error", err)
return c.Status(500).JSON(fiber.Map{"error": "Internal server error"})
}
return c.JSON(user)
}
Complete Example
go
package main
import (
"go.oease.dev/goe/v2"
"go.oease.dev/goe/v2/contract"
)
// Domain
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
// Repository
type UserRepository interface {
FindAll() ([]User, error)
FindByID(id int) (*User, error)
}
type userRepository struct {
db contract.DB
}
func NewUserRepository(db contract.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) FindAll() ([]User, error) {
var users []User
err := r.db.Instance().Find(&users).Error
return users, err
}
func (r *userRepository) FindByID(id int) (*User, error) {
var user User
err := r.db.Instance().First(&user, id).Error
return &user, err
}
// Service
type UserService struct {
repository UserRepository
logger contract.Logger
}
func NewUserService(repository UserRepository, logger contract.Logger) *UserService {
return &UserService{repository: repository, logger: logger}
}
func (s *UserService) GetAllUsers() ([]User, error) {
s.logger.Info("Getting all users")
return s.repository.FindAll()
}
func (s *UserService) GetUser(id int) (*User, error) {
s.logger.Info("Getting user", "id", id)
return s.repository.FindByID(id)
}
// Handler
type UserHandler struct {
userService *UserService
}
func NewUserHandler(userService *UserService) *UserHandler {
return &UserHandler{userService: userService}
}
func (h *UserHandler) GetUsers(c fiber.Ctx) error {
users, err := h.userService.GetAllUsers()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Internal server error"})
}
return c.JSON(users)
}
func (h *UserHandler) GetUser(c fiber.Ctx) error {
id, err := c.ParamsInt("id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
user, err := h.userService.GetUser(id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Internal server error"})
}
return c.JSON(user)
}
// Routes
func RegisterRoutes(httpKernel contract.HTTPKernel, userHandler *UserHandler) {
app := httpKernel.App()
api := app.Group("/api")
api.Get("/users", userHandler.GetUsers)
api.Get("/users/:id", userHandler.GetUser)
}
// Main
func main() {
goe.New(goe.Options{
WithHTTP: true,
WithDB: true,
Providers: []any{
NewUserRepository,
NewUserService,
NewUserHandler,
},
Invokers: []any{
RegisterRoutes,
},
})
goe.Run()
}
Next Steps
- Testing - Test your dependency-injected components
- Modules - Create custom modules with dependency injection
- Best Practices - Learn development best practices