>_
GolangStepByStep
Software Engineer

Dependency Injection

DI patterns, testability, avoiding over-frameworking

# The Bakery Analogy

Imagine you want to start a bakery.

Without Dependency Injection (Hard dependencies): You buy land, plant wheat, grow the wheat, grind it into flour, build a brick oven from scratch, chop down wood, and start baking. It takes 3 years. If the oven breaks, the bakery is dead.

With Dependency Injection (Modular): You rent an empty building. When you arrive, someone hands you flour and an oven. You just bake. If the oven breaks, someone swaps in a new oven. Your job (the business logic) never changes.

In code, if your Payment Service creates its own Database connections, it is rigidly tied to that database. If we inject the database into the Payment Service, we can swap databases easily (especially useful during testing!).

# Level 1: The Problem with Tight Coupling (Beginner)

Developers new to Go often write code like this inside their packages. They think "I need a database, so I'll create one right here."

// BAD PATTERN: Hardcoded dependencies

type AuthHandler struct {
    // Uses a global config or builds it internally
}

func NewAuthHandler() *AuthHandler {
    // ❌ DANGER: We are establishing a live external connection inside setup
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/prod")
    if err != nil {
        panic(err)
    }
    
    return &AuthHandler{} // State is trapped inside
}

func (a *AuthHandler) Login(user string) {
    // Performs live DB query...
}

Why is this a disaster?

If you want to write a Unit Test for Login(), calling NewAuthHandler() will literally try to connect to your production PostgreSQL database. If your internet is off, the test fails. You have firmly glued your business logic to a specific infrastructure.

# Level 2: Manual Constructor Injection (Intermediate)

We fix this by applying Constructor Injection. We demand that Whoever calls our creation function must hand us the required tools.

// GOOD PATTERN: Constructor Injection

type AuthHandler struct {
    db *sql.DB // We hold a reference to the injected dependency
}

// ✅ We REQUEST the dependency as an argument
func NewAuthHandler(database *sql.DB) *AuthHandler {
    return &AuthHandler{
        db: database,
    }
}

// ======== IN main.go (The "Wiring" phase) ========
func main() {
    // 1. main() creates the infrastructure
    dbConn, _ := sql.Open("postgres", "...")
    
    // 2. main() INJECTS the infrastructure into the logic
    handler := NewAuthHandler(dbConn)
}

This is standard, idiomatic Go. There is no magic "Spring framework" or "@Autowire" tags. It's just simple argument passing. This forces all the messy setup code strictly into main.go, leaving your logic clean.

# Level 3: Interface Injection for Testability (Advanced)

Constructor Injection is great, but asking for a *sql.DB is still asking for a concrete struct. What if we want to pass a Fake Database during testing? A Fake database is not a *sql.DB.

To achieve ultimate modularity, we inject an Interface.

// 1. Define the behaviors you need (The Contract)
type UserRepository interface {
    GetPassword(username string) string
}

// 2. The Handler only cares about the Contract!
type AuthHandler struct {
    repo UserRepository
}

func NewAuthHandler(r UserRepository) *AuthHandler {
    return &AuthHandler{repo: r}
}

// ======== PROD vs TEST ========

// Production (main.go): 
// Pass the struct that talks to PostgreSQL!
realRepo := PostgresRepo{} 
prodHandler := NewAuthHandler(realRepo) 

// Testing (auth_test.go): 
// Pass a fake struct with hardcoded fast data!
fakeRepo := MockRepo{} 
testHandler := NewAuthHandler(fakeRepo) 

By programming strictly against Interfaces, your business logic is completely isolated from network protocols, databases, and third-party APIs.

# Level 4: When your main.go gets too big (Expert)

Manual Dependency Injection is glorious. However, in an enterprise microservice, your main.go might look out of control.

// The "Dependency Hell" in main.go
db := NewDB()
logger := NewLogger()
metrics := NewMetrics()
userRepo := NewUserRepo(db, logger)
emailClient := NewEmailClient(logger, metrics)
paymentService := NewPaymentService(db, emailClient)
userService := NewUserService(userRepo, paymentService, logger)
// ... and 80 more lines exactly like this ...

In Go, we don't jump to frameworks instantly. But if "Wire Fatigue" seriously slows down your team, experts turn to Dependency Injection tools like Google Wire.

With Wire, you define "Providers" (the New functions). Wire analyzes your code during the build process, figures out the massive web of arguments required (e.g., "UserService needs UserRepo, which needs DB..."), and it writes that 80-line main.go wiring block for you automatically!

Rule of thumb: Write Manual DI until it literally hurts. Then, and only then, evaluate a code-generation DI tool. Keep Go simple!

practice & review