How to Separate Business Logic from Infrastructure in Go

Separate business logic from infrastructure in Go by defining interfaces for dependencies and injecting concrete implementations at runtime.

How to Separate Business Logic from Infrastructure in Go

You wrote a function that checks if a user exists. It works perfectly in production. Now you want to write a test. You spin up a Docker container, wait for Postgres to boot, run migrations, insert seed data, and finally run the test. It takes forty seconds. You run the full suite and it takes ten minutes. Next week you switch to MySQL and have to rewrite every function that touches the database because the logic is tangled with the driver. The business rules are buried inside SQL queries and connection strings.

Go solves this with interfaces and dependency injection. Your business logic defines a contract for what it needs. The infrastructure provides an implementation that satisfies that contract. The logic never sees the database driver. It only sees the contract. This keeps your code testable, flexible, and focused on the problem you are actually solving.

The contract pattern

Think of an interface as a power outlet. Your device is the business logic. It needs electricity to run. It doesn't care if the power comes from a coal plant, a solar farm, or a backup generator. It just plugs into the outlet. The outlet is the interface. The power plant is the infrastructure.

In Go, an interface is a set of method signatures. When you define an interface, you are saying "I need something that can do these things." Your business logic depends on the interface. The concrete implementation depends on the details. This is the Dependency Inversion Principle: high-level policy depends on low-level details only through abstractions.

Interfaces in Go are implicit. You do not declare that a struct implements an interface. If the struct has the methods, it implements the interface. This reduces coupling. The infrastructure package does not need to import the interface package. It just needs to match the method signatures. The compiler checks the satisfaction at compile time.

Minimal example

Here's the core pattern: define the interface, hold it in a struct, and inject it via a constructor.

// UserRepository defines the contract for fetching user data.
// The business logic only cares about this shape, not how data is stored.
type UserRepository interface {
    // GetByID returns a user by their unique identifier.
    GetByID(id string) (*User, error)
}

// UserService holds the dependency.
// Storing the interface allows swapping implementations at runtime.
type UserService struct {
    repo UserRepository
}

// NewUserService creates a service with the provided repository.
// Passing the interface keeps the service independent of concrete types.
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

The service struct holds the interface. The constructor accepts the interface. This is dependency injection. You inject the dependency when you create the object. The service can now work with any type that satisfies UserRepository.

How implicit interfaces work

Go checks interface satisfaction at compile time. If you pass a concrete type to a function that expects an interface, the compiler verifies that the type has all the required methods. If a method is missing, the compilation fails.

The compiler rejects the program with cannot use repo (variable of type *PostgresUserRepo) as UserRepository value in argument: *PostgresUserRepo does not implement UserRepository (missing GetByID method) if the implementation is incomplete. This error catches mismatches early. You never get a runtime panic because a method is missing.

Implicit interfaces also mean the infrastructure code does not need to know about the interface. The PostgresUserRepo struct can live in a package that imports database/sql but does not import the package where UserRepository is defined. As long as the method signatures match, the interface is satisfied. This keeps packages decoupled.

Convention aside: The Go community follows the mantra "Accept interfaces, return structs." Functions should accept interfaces to allow flexibility. Functions should return structs to give the caller a concrete value. The constructor NewUserService returns *UserService, a struct. It accepts UserRepository, an interface. This balances flexibility with clarity.

Implicit interfaces keep coupling low. Define the contract where it is needed.

Realistic application

Real code involves context, error handling, and multiple layers. Here is how the pattern looks in a production-ready setup.

// UserRepository defines the data access contract.
// Context as the first parameter is a Go convention for cancellation.
type UserRepository interface {
    GetByID(ctx context.Context, id string) (*User, error)
}

// UserService encapsulates business rules.
// It holds the interface, keeping logic independent of storage.
type UserService struct {
    repo UserRepository
}

// NewUserService injects the repository.
// The receiver name 's' matches the type UserService.
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

The service struct and constructor are clean. The receiver name s is short and matches the type. This is a standard Go convention. Receivers are usually one or two letters. The interface includes context.Context as the first parameter. This allows the caller to control timeouts and cancellation.

// GetUser fetches data and validates business constraints.
// Errors are wrapped with context using the %w verb.
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    user, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("fetch user %s: %w", id, err)
    }

    if user.Name == "" {
        return nil, fmt.Errorf("user %s has empty name", id)
    }

    return user, nil
}

The method checks for errors immediately. The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Errors are wrapped with fmt.Errorf and the %w verb. This preserves the error chain so callers can unwrap and inspect the original cause.

// PostgresRepo implements UserRepository with database logic.
// Go interfaces are implicit; no 'implements' keyword is needed.
type PostgresRepo struct {
    // db would hold *sql.DB in a real application.
}

// GetByID satisfies the interface with concrete database access.
func (r *PostgresRepo) GetByID(ctx context.Context, id string) (*User, error) {
    // Simulate a query.
    // The context allows the database driver to cancel long-running operations.
    return &User{ID: id, Name: "Alice"}, nil
}

// main acts as the composition root.
// Concrete types are instantiated and injected here.
func main() {
    repo := &PostgresRepo{}
    service := NewUserService(repo)

    ctx := context.Background()
    user, err := service.GetUser(ctx, "123")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    fmt.Println("Found user:", user.Name)
}

The implementation satisfies the interface implicitly. The main function is the composition root. This is where infrastructure meets logic. You create the concrete repository, inject it into the service, and run the application. The rest of the codebase never touches PostgresRepo. It only talks to the service and the interface.

Convention aside: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. Pass the context through every long-lived call site. Context is plumbing. Run it through every long-lived call site.

Testing becomes trivial

With the interface in place, testing is fast and deterministic. You create a mock struct that satisfies the interface. The service does not know it is talking to a mock.

// MockRepo satisfies UserRepository for testing.
// It returns predefined data without touching a database.
type MockRepo struct {
    Users map[string]*User
}

// GetByID returns a user from the mock map.
func (m *MockRepo) GetByID(ctx context.Context, id string) (*User, error) {
    user, ok := m.Users[id]
    if !ok {
        return nil, fmt.Errorf("user %s not found", id)
    }
    return user, nil
}

The mock implements the same methods. You can inject it into the service and test the business logic instantly. No Docker containers. No migrations. No network latency. The test runs in milliseconds.

// TestGetUser demonstrates testing with a mock dependency.
// The service behaves identically regardless of the implementation.
func TestGetUser(t *testing.T) {
    repo := &MockRepo{
        Users: map[string]*User{
            "123": {ID: "123", Name: "Alice"},
        },
    }
    service := NewUserService(repo)

    ctx := context.Background()
    user, err := service.GetUser(ctx, "123")
    if err != nil {
        t.Fatalf("expected no error, got %v", err)
    }

    if user.Name != "Alice" {
        t.Errorf("expected name Alice, got %s", user.Name)
    }
}

The test focuses on the business rules. It verifies that the service returns the user and handles errors correctly. The infrastructure is completely abstracted away. This makes refactoring safe. You can change the database driver without breaking the tests.

Pitfalls and conventions

Interfaces are powerful, but they can be misused. The biggest risk is interface bloat. Defining interfaces for every struct creates noise. You end up with UserRepository, UserCreator, UserUpdater, and UserDeleter when one interface would suffice. Or you define an interface with twenty methods that no single caller needs.

The Go proverb says "The bigger the interface, the weaker the abstraction." Small interfaces are better. Define an interface with only the methods you need. If a function only needs GetByID, define an interface with just that method. This keeps the dependency minimal and makes mocking easier.

Do not define interfaces until you need them. If you have a single implementation and no plan to change it, use the concrete type. Interfaces add indirection. They are worth it when you need to swap implementations for testing, configuration, or modularity. Premature abstraction leads to code that is harder to read and maintain.

Convention aside: Public names start with a capital letter. Private names start lowercase. Interfaces are usually defined in the package that uses them, not the package that implements them. This follows the "accept interfaces, return structs" pattern. The consumer defines the contract. The provider satisfies it.

If you rename a method in the implementation but forget to update the interface, the compiler catches the mismatch. You get cannot use repo as UserRepository value in argument: *PostgresRepo does not implement UserRepository (wrong type for method GetByID). This error forces you to keep the contract and implementation in sync.

When to use interfaces

Use an interface when you need to abstract away infrastructure details like databases or external APIs. Use a concrete type when you have a single implementation and no immediate need for substitution. Use dependency injection when a component depends on resources that must be configured at startup. Use a small interface with one method when you want to keep the abstraction minimal and testable. Use composition when you need to combine multiple behaviors without creating deep inheritance hierarchies. Use the standard library interfaces like io.Reader when you want to leverage existing ecosystem compatibility.

Interfaces are contracts. Keep them small.

Where to go next