Right, so you want to do dependency injection but you don’t want to pull in some 50-megabyte framework that requires an XML configuration file written in ancient Sumerian. Good. Neither do I. Dependency injection isn’t a framework feature; it’s a design pattern, a way of structuring your code so it doesn’t turn into an un-testable, un-maintainable hairball.

The core idea is painfully simple: don’t create your dependencies inside a struct; accept them as parameters. That’s it. You’ve just understood the secret. The rest is just us applying that one rule with varying levels of elegance and ceremony.

Think of it like this: if your Service struct hard-codes a new Database() inside its constructor, it’s married to that specific database. You can’t test Service without a real database running, and you can’t swap the database for a different one without rewriting code. It’s a hostage situation. Instead, your Service should demand to see the Database’s credentials upfront. “No Database, no date,” it says. This is called constructor injection, and it’s your new best friend.

The Naked, Honest Way: Constructor Injection

This is the most straightforward, “Go” way to do it. You simply require dependencies as parameters to your constructor function (typically NewXyz).

// Let's define a simple interface. This is key, as it lets us inject different implementations.
type MailClient interface {
    SendWelcomeEmail(email string) error
}

// Here's the real deal, the production implementation that sends actual emails.
type SMTPMailClient struct {
    Host     string
    Port     int
    Username string
    Password string
}

func (c *SMTPMailClient) SendWelcomeEmail(email string) error {
    // Imagine real SMTP logic here. You get the idea.
    fmt.Printf("(For real) Sending welcome email to %s via %s:%d\n", email, c.Host, c.Port)
    return nil
}

// And here's our user service, which needs to send emails.
type UserService struct {
    mailer MailClient // Depends on the interface, not the concrete type!
}

// The constructor *requires* a MailClient. No ifs, no buts.
func NewUserService(mailer MailClient) *UserService {
    return &UserService{mailer: mailer}
}

func (s *UserService) RegisterUser(email, password string) error {
    // ... create user logic ...
    return s.mailer.SendWelcomeEmail(email)
}

Now, in main, you wire it all up like a sane person.

func main() {
    // Build your concrete dependency first.
    mailClient := &SMTPMailClient{
        Host:     "smtp.example.com",
        Port:     587,
        Username: "user",
        Password: "pass",
    }

    // Then inject it into the service that needs it.
    userService := NewUserService(mailClient)

    userService.RegisterUser("you@example.com", "secret")
    // Output: (For real) Sending welcome email to you@example.com via smtp.example.com:587
}

The magic here is in the test. Look how stupidly simple it is to test RegisterUser without ever talking to an SMTP server.

// A test implementation. Also known as a "mock" or "stub".
type MockMailClient struct {
    LastEmailSent string
}

func (m *MockMailClient) SendWelcomeEmail(email string) error {
    m.LastEmailSent = email // Just record it for later assertion.
    return nil
}

func TestUserService_RegisterUser(t *testing.T) {
    // 1. Create the mock
    mockMailer := &MockMailClient{}

    // 2. Inject the mock
    service := NewUserService(mockMailer)

    // 3. Run the method
    err := service.RegisterUser("test@example.com", "password")

    // 4. Make assertions on the mock's state
    if err != nil {
        t.Fatalf("Expected no error, got %v", err)
    }
    if mockMailer.LastEmailSent != "test@example.com" {
        t.Fatalf("Expected email to be sent to 'test@example.com', got %s", mockMailer.LastEmailSent)
    }
    // Test passed. No SMTP servers were harmed in the making of this test.
}

This isn’t just “testing,” it’s a design feedback mechanism. If your code is hard to wire up like this for a test, your design is probably too coupled.

The Config Struct Tango

Sometimes, a constructor with six parameters starts to look like a function from a language you swore you’d never use again. When your NewService signature starts to resemble a novel, it’s time for the Config struct. This isn’t a special “DI config”; it’s just a plain old struct that holds the parameters for your constructor.

type ServiceConfig struct {
    MailClient   MailClient
    Logger       *zap.Logger
    RetryCount   int
    Timeout      time.Duration
    EnableCache  bool
    // ... you get the idea
}

func NewService(config ServiceConfig) (*Service, error) {
    if config.MailClient == nil {
        return nil, errors.New("mail client is required")
    }
    // ... validation and setup ...
    return &Service{mailer: config.MailClient, logger: config.Logger}, nil
}

This is vastly superior to a long parameter list because a) it’s more readable, b) you can add new parameters without breaking every call to NewService, and c) it makes optional parameters obvious (e.g., if Logger is nil, maybe you use a default no-op logger).

The Pit of Success (And The Pitfalls)

The biggest pitfall is over-engineering it. You don’t need a framework. You especially don’t need a framework that uses reflection to magically inject things based on struct tags. That introduces a whole new class of runtime panics that are a nightmare to debug and is about as idiomatic to Go as a mayonnaise sandwich. The pattern shown above—using interfaces and constructor arguments—is compile-time safe. If you forget to provide a required dependency, your code won’t even compile. That’s a feature, not a limitation.

Another common mistake is creating interfaces for everything, prematurely. Don’t define a MailClient interface just because you think you might need to mock it someday. Wait until you actually need to write a test for something that uses it. This is the pragmatic Go approach. Write your concrete SMTPMailClient first. The moment you need to test a consumer, that’s when you extract the interface. The consumer defines the interface it needs, not the provider. This is why our example has the MailClient interface defined alongside the UserService, not the SMTPMailClient.

So there you have it. No magic, no reflection, no framework. Just clear, testable, and maintainable code. You pass things in. That’s the whole trick. Any framework you see is just someone’s elaborate, overly-generic way of doing exactly that.