How to Implement Clean Architecture in Go

Implement Clean Architecture in Go by defining domain interfaces and injecting concrete implementations to decouple business logic from external dependencies.

The tangled script problem

You wrote a Go program that fetches user data, applies a discount, and saves the result. It works perfectly. Then you need to swap SQLite for Postgres. You change three files. Then you need to add Redis caching. You change five more files. Then you try to write a unit test for the discount logic, but you cannot run the test without spinning up a real database. The code works, but it is glued together. Every change ripples through the whole program.

Clean architecture solves this by forcing your code to admit what depends on what. It separates the business rules from the tools you use to execute them. In Go, you achieve this by defining interfaces in your core logic and injecting concrete implementations at the edges. The core knows nothing about databases, HTTP, or JSON. It only knows about the interfaces it needs.

Dependencies point inward

Think of your application like a restaurant. The menu describes what you sell. The kitchen cooks the food. The waiter takes orders and brings plates to the table. The menu does not care if the kitchen uses a gas stove or an induction burner. The waiter does not cook. They just pass information back and forth.

Clean architecture separates these roles. The innermost layer contains the business rules. The next layer contains use cases that orchestrate the rules. The outermost layer handles delivery mechanisms like HTTP handlers or CLI commands. The rule is simple: dependencies point inward. The delivery layer depends on the use case layer. The use case layer depends on the domain layer. The domain layer depends on nothing.

Go makes this easy because interfaces are implicit. You do not need to declare that a struct implements an interface. The compiler checks the method signatures at compile time. If the methods match, the assignment works. This keeps the domain layer clean. You do not import the database package in your domain package. You only import the interface. The infrastructure depends on the domain, not the other way around.

Minimal dependency injection

Here is the smallest possible dependency inversion. The use case defines what it needs. The main function provides it. The domain package contains only the interface. The infrastructure package contains the concrete implementation.

// domain/user.go
// UserRepository defines the contract for data access.
// The domain layer owns this interface.
type UserRepository interface {
    // FindByID retrieves a user by their unique identifier.
    FindByID(id string) (*User, error)
}

// User represents the core business entity.
type User struct {
    ID   string
    Name string
}

The use case layer consumes the interface. It does not know how the data is stored. It only knows it can call FindByID.

// usecase/user_service.go
// UserService contains the business logic.
// It depends on the UserRepository interface.
type UserService struct {
    repo UserRepository
}

// NewUserService creates a service with the injected repository.
// This constructor pattern avoids global state.
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// GetUser applies business rules and fetches data.
func (s *UserService) GetUser(id string) (*User, error) {
    return s.repo.FindByID(id)
}

The main function wires everything together. This is where the concrete types appear. The main function is the only place that knows about both the service and the repository implementation.

// main.go
func main() {
    // Concrete implementation lives in the infrastructure layer.
    repo := &postgresUserRepository{}
    
    // Inject the repository into the service.
    svc := NewUserService(repo)
    
    // Use the service.
    user, err := svc.GetUser("123")
    if err != nil {
        panic(err)
    }
    println(user.Name)
}

The compiler enforces the boundaries. If you try to import the infrastructure package in the domain package, you create a circular dependency. The build fails with import cycle not allowed. This error is a feature. It stops you from leaking implementation details into the core logic.

Interfaces are contracts. Keep them small and focused on behavior.

Realistic flow with context and errors

Real code needs more than data fetching. It needs context propagation, error handling, and HTTP specifics. Go conventions shape how you write this code. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. Error handling is explicit. The community accepts the if err != nil boilerplate because it makes the unhappy path visible.

Here is the domain interface with context support.

// domain/user.go
import "context"

// UserRepository defines the contract for data access.
// Context is the first parameter to support cancellation.
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
}

The service layer propagates context and handles errors. The receiver name is usually one or two letters matching the type, like s for Service. This is a Go convention. Do not use this or self.

// usecase/user_service.go
import (
    "context"
    "fmt"
)

// UserService contains the business logic.
type UserService struct {
    repo UserRepository
}

// NewUserService creates a service with the injected repository.
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// GetUser fetches a user and applies validation.
// It wraps errors to provide context about the failure.
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    if id == "" {
        return nil, fmt.Errorf("user ID cannot be empty")
    }
    
    // Pass context to the repository.
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        // Wrap the error to preserve the original cause.
        return nil, fmt.Errorf("get user %s: %w", id, err)
    }
    
    return user, nil
}

The delivery layer handles HTTP. It extracts parameters, calls the service, and encodes the response. Public names start with a capital letter. Private names start lowercase. No keywords like public or private.

// delivery/http/handler.go
import (
    "encoding/json"
    "net/http"
)

// Handler wraps the service for HTTP requests.
type Handler struct {
    svc *UserService
}

// NewHandler creates a handler with the injected service.
func NewHandler(svc *UserService) *Handler {
    return &Handler{svc: svc}
}

// GetUser handles the HTTP request.
// It extracts the ID, calls the service, and returns JSON.
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    
    // Use the request context. It cancels if the client disconnects.
    user, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    // Encode the response.
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

The main function wires the handler to the HTTP server. Trust gofmt. Argue logic, not formatting. Most editors run it on save.

// main.go
import (
    "net/http"
)

func main() {
    repo := &postgresUserRepository{}
    svc := NewUserService(repo)
    handler := NewHandler(svc)
    
    mux := http.NewServeMux()
    mux.HandleFunc("/user", handler.GetUser)
    
    http.ListenAndServe(":8080", mux)
}

Context is plumbing. Run it through every long-lived call site.

Pitfalls and compiler errors

Clean architecture adds boilerplate. You write more code to define interfaces and constructors. This cost is worth it when you need testability or flexibility. It is not worth it for a simple script that will be deleted in three months.

The compiler stops you from creating circular imports. If pkgA imports pkgB and pkgB imports pkgA, the build fails with import cycle not allowed. Clean architecture prevents this by keeping interfaces in the inner layer. The inner layer never imports the outer layer.

Another trap is defining too many interfaces. If you create an interface for every single method, you end up with hundreds of tiny types. This is called interface pollution. It makes the code harder to read. Define interfaces where you need flexibility. Do not define them just because you can. If a dependency is stable and never needs mocking, use a direct function call.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. In clean architecture, this means passing context to every function that might block. If the context is cancelled, the function returns immediately. The worst goroutine bug is the one that never logs.

The compiler complains with cannot use x (type *PostgresRepo) as UserRepository in argument if the method signatures do not match. This error saves you from runtime panics. Fix the signature or add the missing method.

When to use clean architecture

Use a monolithic main.go when you are prototyping and will rewrite the code in three months. Use dependency injection when you need to swap implementations for testing or configuration. Use a dedicated repository interface when the data source might change or when you want to mock the database in tests. Use direct function calls when the dependency is stable and never needs mocking. Use a single package when the code is small and the team is small. Split packages when the code grows and multiple teams need to work on different layers independently.

The compiler enforces the boundaries. Trust the import graph.

Where to go next