Clean Architecture Boundaries in Go: Organizing Dependencies for Testability

Introduction

Clean Architecture, popularized by Robert C. Martin, emphasizes separating business logic from infrastructure concerns through dependency inversion. In Go, where simplicity is valued, implementing clean architecture boundaries requires balancing purity with pragmatism.

This article focuses on the Dependency Rule: source code dependencies must point inward, toward higher-level policies.

The Concentric Circles

┌─────────────────────────────────────┐
│  Frameworks & Drivers (outer)       │
│  ┌─────────────────────────────┐    │
│  │  Interface Adapters          │    │
│  │  ┌─────────────────────┐    │    │
│  │  │  Use Cases           │    │    │
│  │  │  ┌─────────────┐    │    │    │
│  │  │  │  Entities    │    │    │    │
│  │  │  └─────────────┘    │    │    │
│  │  └─────────────────────┘    │    │
│  └─────────────────────────────┘    │
└─────────────────────────────────────┘

Go Implementation

Project Structure

myservice/
├── cmd/
│   └── api/
│       └── main.go              # Entry point, wiring
├── internal/
│   ├── domain/                  # Entities (innermost)
│   │   ├── user.go
│   │   └── errors.go
│   ├── usecase/                 # Use cases / Application logic
│   │   ├── user_service.go
│   │   └── interfaces.go        # Repository interfaces defined here
│   ├── adapter/                 # Interface adapters
│   │   ├── repository/
│   │   │   └── postgres_user.go # Implements usecase interfaces
│   │   └── handler/
│   │       └── http_user.go
│   └── infrastructure/          # Frameworks & drivers
│       ├── database/
│       └── server/
└── pkg/                         # Shared libraries

Defining Boundaries with Interfaces

Domain Layer (internal/domain/user.go):

package domain

import "time"

type User struct {
    ID        string
    Email     string
    Name      string
    CreatedAt time.Time
}

type UserID string

func NewUser(email, name string) (*User, error) {
    if email == "" {
        return nil, ErrInvalidEmail
    }
    return &User{
        Email: email,
        Name:  name,
    }, nil
}

Use Case Layer - Define interfaces HERE, not in the adapter:

package usecase

import (
    "context"
    "myservice/internal/domain"
)

// UserRepository is defined in usecase layer, not adapter layer
type UserRepository interface {
    Save(ctx context.Context, user *domain.User) error
    FindByID(ctx context.Context, id string) (*domain.User, error)
    FindByEmail(ctx context.Context, email string) (*domain.User, error)
}

type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) CreateUser(ctx context.Context, email, name string) (*domain.User, error) {
    // Business logic here
    existing, err := s.repo.FindByEmail(ctx, email)
    if err != nil && err != domain.ErrNotFound {
        return nil, err
    }
    if existing != nil {
        return nil, domain.ErrUserExists
    }
    
    user, err := domain.NewUser(email, name)
    if err != nil {
        return nil, err
    }
    
    return user, s.repo.Save(ctx, user)
}

Adapter Layer - Implements the interface:

package repository

import (
    "context"
    "database/sql"
    "myservice/internal/domain"
    "myservice/internal/usecase"
)

// Compile-time check that PostgresUserRepo implements UserRepository
var _ usecase.UserRepository = (*PostgresUserRepo)(nil)

type PostgresUserRepo struct {
    db *sql.DB
}

func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
    return &PostgresUserRepo{db: db}
}

func (r *PostgresUserRepo) Save(ctx context.Context, user *domain.User) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO users (id, email, name) VALUES ($1, $2, $3)",
        user.ID, user.Email, user.Name,
    )
    return err
}

The Key Insight: Interface Ownership

The critical pattern is defining interfaces in the consuming package, not the implementing package. This is idiomatic Go and enforces the dependency rule:

When to Use This Pattern

Good Fit:

Poor Fit:

Trade-offs

Benefits:

Costs:

Testing Advantage

The primary benefit is testability. Use case tests need only mock interfaces:

func TestCreateUser(t *testing.T) {
    mockRepo := &MockUserRepository{
        FindByEmailFn: func(ctx context.Context, email string) (*domain.User, error) {
            return nil, domain.ErrNotFound
        },
        SaveFn: func(ctx context.Context, user *domain.User) error {
            return nil
        },
    }
    
    service := usecase.NewUserService(mockRepo)
    user, err := service.CreateUser(context.Background(), "test@example.com", "Test")
    
    assert.NoError(t, err)
    assert.Equal(t, "test@example.com", user.Email)
}

Conclusion

Clean architecture in Go works well when you respect Go’s interface philosophy: define small interfaces where they’re used, not where they’re implemented. This maintains the dependency rule while keeping code idiomatic. Start with the structure, but don’t over-abstract—add boundaries as complexity demands them.