Skip to main content
Go: Dependency Injection
  1. Posts/

Go: Dependency Injection

·713 words·4 mins·
Roman
Author
Roman
Photographer with MSci in Computer Science and a Home Lab obsession
Table of Contents

Following the Learn Go with Tests guide, exploring dependency injection in Go.

Hard-to-Test
#

Consider this greeting function:

func Greet(name string) {
    fmt.Printf("Hello, %s", name)
}

How to test this?: It writes directly to stdout, which is hard to capture in tests.

Inject Dependencies
#

Instead of hardcoding where output goes, let’s inject that dependency:

func TestGreet(t *testing.T) {
    // Create a buffer to capture output instead of printing to stdout
    buffer := bytes.Buffer{}
    
    // Call our function, injecting the buffer as the writer dependency
    Greet(&buffer, "Chris")
    
    // Extract what was written to the buffer
    got := buffer.String()
    want := "Hello, Chris"
    
    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}

bytes.Buffer implements the io.Writer interface, so we can use it to capture output in tests.

io.Writer Interface
#

Go’s io.Writer interface:

type Writer interface {
    Write(p []byte) (n int, err error)
}

The Write method takes the data from the byte slice p, writes it into the data stream, and returns the number of bytes written n and an error err if there was any.

What implements io.Writer?

  • bytes.Buffer (for testing)
  • os.Stdout (for console output)
  • http.ResponseWriter (for web responses)
  • Files, network connections, and more!

Implementation Evolution
#

Start with concrete type:

func Greet(writer *bytes.Buffer, name string) {
    fmt.Fprintf(writer, "Hello, %s", name)
}

Can’t use with os.Stdout:

Greet(os.Stdout, "Elodie") 
// Error: cannot use os.Stdout (type *os.File) as type *bytes.Buffer

Use the interface:

func Greet(writer io.Writer, name string) {
    fmt.Fprintf(writer, "Hello, %s", name)
}

Now it works with any io.Writer!

Real-World Examples
#

Console application:

func main() {
    Greet(os.Stdout, "Elodie")
}

Web server:

func MyGreeterHandler(w http.ResponseWriter, r *http.Request) {
    Greet(w, "world") 
    // http.ResponseWriter implements io.Writer!
}

func main() {
    log.Fatal(http.ListenAndServe(":5001", http.HandlerFunc(MyGreeterHandler)))
}

fmt.Fprintf
#

Let’s examine how Go’s standard library uses this pattern:

func Printf(format string, a ...any) (n int, err error) {
    return Fprintf(os.Stdout, format, a...) 
    // Delegates to Fprintf with os.Stdout
}

// fmt.Fprintf accepts any io.Writer:
func Fprintf(w io.Writer, format string, a ...any) (n int, err error) {
    // ... implementation
}

Pattern: Printf is a convenience function; Fprintf is the flexible, injectable version.

Benefits of DI
#

1. Testability
#

Before (hard to test):

func ProcessData() {
    data := fetchFromDatabase() 
    // Hard-wired dependency
    fmt.Println(data)          
    // Hard-wired output
}

After (easy to test):

func ProcessData(db DataFetcher, output io.Writer) {
    data := db.Fetch()
    fmt.Fprintf(output, "%s\n", data)
}

// Test with mocks:
func TestProcessData(t *testing.T) {
    mockDB := &MockDataFetcher{data: "test data"}
    buffer := &bytes.Buffer{}
    
    ProcessData(mockDB, buffer)
    
    got := buffer.String()
    want := "test data\n"
    
    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}

2. Separation of Concerns
#

// Bad: Mixed concerns
func GenerateReport(userID int) {
    user := db.GetUser(userID)     
    // Data access
    report := buildReport(user)    
    // Business logic
    sendEmail(report)              
    // External communication
}

// Good: Separated concerns
func GenerateReport(userID int, db UserRepository, notifier Notifier) string {
    user := db.GetUser(userID)     
    // Data access via injected dependency
    report := buildReport(user)    
    // Pure business logic
    notifier.Send(report)          
    // External communication via injected dependency
    return report
}

3. Reusability
#

// One function, multiple contexts:
func FormatUser(user User, writer io.Writer) {
    fmt.Fprintf(writer, "Name: %s, Email: %s", user.Name, user.Email)
}

// Usage scenarios:
FormatUser(user, os.Stdout)           // Console
FormatUser(user, httpResponseWriter)  // Web API
FormatUser(user, &buffer)             // Testing
FormatUser(user, file)                // Logging

DI Test Example
#

// Define behavior through interfaces
type UserRepository interface {
    GetByID(id int) (*User, error) 
    // *User means "pointer to User"
}

type User struct {
    ID   int
    Name string
}

// Here would also be a proper Postgresql implementation for example
// but we don't want to use it for testing

// Test implementation
type InMemoryUserRepository struct {
    users map[int]*User
}

func (r *InMemoryUserRepository) GetByID(id int) (*User, error) {
    user, ok := r.users[id]  // ok is true if key exists
    if !ok {
        return nil, fmt.Errorf("user not found")
    }
    return user, nil
}

// Service using the interface
type UserService struct {
    repo UserRepository
}

func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.GetByID(id)
}

// Easy testing inject mock dependencies
func TestUserService_GetUser(t *testing.T) {
    repo := &InMemoryUserRepository{
        users: map[int]*User{1: {ID: 1, Name: "John"}},
    }
    service := &UserService{repo: repo}
    
    user, err := service.GetUser(1)
    if err != nil {
        t.Fatalf("expected no error, got %v", err)
    }
    if user.Name != "John" {
        t.Errorf("expected name 'John', got '%s'", user.Name)
    }
}