Dependency Injection in Go: The Simple Way to More Testable Code

In this article, I’m going to explain what dependency injection is and how it solves a critical problem when building applications in Go. To make it concrete, I’ll walk you through a problem I faced while building a backend for a tracker application us…


This content originally appeared on DEV Community and was authored by Ashu Kumar

In this article, I'm going to explain what dependency injection is and how it solves a critical problem when building applications in Go. To make it concrete, I'll walk you through a problem I faced while building a backend for a tracker application using Test-Driven Development (TDD).

The problem arose when I started writing my authentication services, specifically functions like GetUserByID and GetUserByEmail. To test my service logic, I wrote some initial tests using a mock database and a mock logger. However, I made a common mistake: I tightly coupled my UserService to those mocks.

This meant that to move the application to production, I would have to go back and manually change the UserService and all its functions to use a real database and logger. This process is tiring, error-prone, and just not fun. The solution is a pattern called Dependency Injection (DI).

The Problem: Tightly Coupled Code

Let's look at what that tightly coupled code might look like. Imagine a UserService that needs to talk to a database and log messages.

Without DI, you might write it like this, where the service creates its own dependencies.

package main

import (
    "fmt"
    "log"
)

// A hard-coded database connection (for demonstration)
type PostgresDB struct {
    // connection fields would go here
}

func (db *PostgresDB) GetUser(id int) (string, error) {
    // In a real app, this would query the database
    return fmt.Sprintf("user_%d", id), nil
}

// Our service that needs a database
type UserService struct {
    db *PostgresDB // A direct dependency on a concrete type
}

// The constructor creates its own dependency
func NewUserService() *UserService {
    // The problem is right here!
    // We are creating the dependency inside the service.
    // This makes it impossible to replace `PostgresDB` for tests or other environments.
    return &UserService{
        db: &PostgresDB{},
    }
}

func (s *UserService) GetUser(id int) (string, error) {
    log.Printf("Getting user %d", id)
    return s.db.GetUser(id)
}

func main() {
    userService := NewUserService()
    user, err := userService.GetUser(101)
    if err != nil {
        log.Fatalf("Failed to get user: %v", err)
    }
    fmt.Println(user)
}

The issues with this approach are clear:

Hard to Test: How can you unit test UserService without a running PostgreSQL database? You can't easily swap PostgresDB with a mock.

Inflexible: What if you want to switch to MySQL or use a different logging library? You have to modify the UserService code directly.

The Solution: Introducing Dependency Injection

Dependency Injection means we will "inject" (pass in) the dependencies our service needs. Instead of the service creating its dependencies, we give them to it.

Step 1: Define Interfaces (The "Contracts")
First, we define interfaces that describe what our service needs, not how it's done. This is the contract.

// UserStorer is an interface that defines the operations we need for user data.
type UserStorer interface {
    GetUser(id int) (string, error)
}

// Logger is an interface for logging messages.
type Logger interface {
    Log(message string)
}

Step 2: Depend on Interfaces, Not Concrete Types

Next, we refactor UserService to depend on these interfaces.

// UserService now depends on the interfaces, not the concrete structs.
type UserService struct {
    store  UserStorer
    logger Logger
}

// The dependencies are "injected" as arguments to the constructor.
func NewUserService(s UserStorer, l Logger) *UserService {
    return &UserService{
        store:  s,
        logger: l,
    }
}

func (s *UserService) GetUser(id int) (string, error) {
    s.logger.Log(fmt.Sprintf("Getting user %d", id))
    return s.store.GetUser(id)
}

Our UserService is now completely decoupled from any specific database or logger implementation!

Wiring It All Together in main()
The main function (or wherever you initialize your app) becomes the "composition root." This is where you choose and create the concrete implementations and inject them into your services.

package main

import (
    "fmt"
    "log"
)

/*
--- Interfaces (Contracts) ---
*/
type UserStorer interface {
    GetUser(id int) (string, error)
}
type Logger interface {
    Log(message string)
}

/*
--- Production (Concrete) Implementations ---
*/
// PostgresDB is our real database implementation.
type PostgresDB struct { /* ... */ }

func (db *PostgresDB) GetUser(id int) (string, error) {
    return fmt.Sprintf("user_%d from postgres", id), nil
}
func NewPostgresDB() *PostgresDB { return &PostgresDB{} }


// StandardLogger is our real logger implementation.
type StandardLogger struct{}

func (l *StandardLogger) Log(message string) {
    log.Println(message)
}
func NewStandardLogger() *StandardLogger { return &StandardLogger{} }


/*
--- Service ---
*/
// UserService depends only on interfaces.
type UserService struct {
    store  UserStorer
    logger Logger
}

func NewUserService(s UserStorer, l Logger) *UserService {
    return &UserService{store: s, logger: l}
}

func (s *UserService) GetUser(id int) (string, error) {
    s.logger.Log(fmt.Sprintf("Getting user %d", id))
    return s.store.GetUser(id)
}

/*
--- Main (Composition Root) ---
*/
func main() {
    // 1. Create our concrete dependencies for PRODUCTION.
    db := NewPostgresDB()
    logger := NewStandardLogger()

    // 2. Inject them into our service.
    userService := NewUserService(db, logger)

    // 3. Use the service.
    user, err := userService.GetUser(101)
    if err != nil {
        log.Fatalf("Failed to get user: %v", err)
    }
    fmt.Println(user) // Output: user_101 from postgres
}

The Payoff: Simple & Effective Testing ✅

Now for the best part. Testing our UserService logic is incredibly simple. We can create mock implementations of our interfaces that behave exactly how we want for our test cases.

Here's what a test file (main_test.go) could look like:

package main

import (
    "testing"
    "fmt"
)

// MockUserStore is a mock implementation of UserStorer for testing.
type MockUserStore struct {
    users map[int]string
}

func (m *MockUserStore) GetUser(id int) (string, error) {
    user, ok := m.users[id]
    if !ok {
        return "", fmt.Errorf("user not found")
    }
    return user, nil
}

// MockLogger does nothing, perfect for silent testing.
type MockLogger struct {}
func (m *MockLogger) Log(message string) { /* do nothing */ }


// Test the UserService in isolation.
func TestUserService_GetUser(t *testing.T) {
    // 1. Setup our MOCK dependencies.
    mockStore := &MockUserStore{
        users: map[int]string{
            1: "Alice",
        },
    }
    mockLogger := &MockLogger{}

    // 2. Inject the mocks into our service.
    userService := NewUserService(mockStore, mockLogger)

    // 3. Run the test.
    user, err := userService.GetUser(1)
    if err != nil {
        t.Errorf("Expected no error, got %v", err)
    }

    if user != "Alice" {
        t.Errorf("Expected user 'Alice', got '%s'", user)
    }
}

We just tested our UserService logic without needing a database or writing to standard output. It's clean, isolated, and fast.

Conclusion: DI is a Core Go Pattern

Dependency Injection isn't about adding a complex framework. It's a simple pattern of giving an object its dependencies from the outside rather than letting it create them on the inside.

By depending on interfaces and injecting them, you gain:

Testability: Easily swap real implementations for mocks.

Flexibility: Change database or logger implementations without touching your core service logic.

Clarity: The function signature of NewUserService clearly states its dependencies.

For most Go projects, this manual approach to DI is all you need. It's idiomatic, easy to understand, and powerfully effective.


This content originally appeared on DEV Community and was authored by Ashu Kumar


Print Share Comment Cite Upload Translate Updates
APA

Ashu Kumar | Sciencx (2025-07-22T19:06:08+00:00) Dependency Injection in Go: The Simple Way to More Testable Code. Retrieved from https://www.scien.cx/2025/07/22/dependency-injection-in-go-the-simple-way-to-more-testable-code/

MLA
" » Dependency Injection in Go: The Simple Way to More Testable Code." Ashu Kumar | Sciencx - Tuesday July 22, 2025, https://www.scien.cx/2025/07/22/dependency-injection-in-go-the-simple-way-to-more-testable-code/
HARVARD
Ashu Kumar | Sciencx Tuesday July 22, 2025 » Dependency Injection in Go: The Simple Way to More Testable Code., viewed ,<https://www.scien.cx/2025/07/22/dependency-injection-in-go-the-simple-way-to-more-testable-code/>
VANCOUVER
Ashu Kumar | Sciencx - » Dependency Injection in Go: The Simple Way to More Testable Code. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/07/22/dependency-injection-in-go-the-simple-way-to-more-testable-code/
CHICAGO
" » Dependency Injection in Go: The Simple Way to More Testable Code." Ashu Kumar | Sciencx - Accessed . https://www.scien.cx/2025/07/22/dependency-injection-in-go-the-simple-way-to-more-testable-code/
IEEE
" » Dependency Injection in Go: The Simple Way to More Testable Code." Ashu Kumar | Sciencx [Online]. Available: https://www.scien.cx/2025/07/22/dependency-injection-in-go-the-simple-way-to-more-testable-code/. [Accessed: ]
rf:citation
» Dependency Injection in Go: The Simple Way to More Testable Code | Ashu Kumar | Sciencx | https://www.scien.cx/2025/07/22/dependency-injection-in-go-the-simple-way-to-more-testable-code/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.