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:
usecase.UserRepositoryis defined inusecase/repository.PostgresUserRepoimplements it inadapter/- Dependencies point inward: adapter → usecase → domain
When to Use This Pattern
Good Fit:
- Services with complex business logic
- Systems requiring multiple storage backends
- Projects needing high test coverage
- Long-lived applications with evolving requirements
Poor Fit:
- Simple CRUD applications
- Prototypes or MVPs
- Scripts and CLI tools
- Performance-critical hot paths (interface indirection has cost)
Trade-offs
Benefits:
- Business logic testable without infrastructure
- Easy to swap implementations (Postgres → DynamoDB)
- Clear separation of concerns
- Enforced boundaries prevent spaghetti dependencies
Costs:
- More files and packages
- Indirection can obscure code flow
- Interface definitions require maintenance
- Over-engineering risk for simple systems
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.