Go is renowned for its minimalism and pragmatism. However, as Go projects grow from simple CLI scripts into large-scale backend systems, codebases often devolve into spaghetti: HTTP handlers executing raw SQL queries, business logic coupled to third-party SDKs, and circular dependency errors during compilation.
Applying Clean Architecture (and Hexagonal / Ports & Adapters principles) in Go provides:
- Zero Framework Coupling: The core business logic knows nothing about Gin, Echo, GORM, or PostgreSQL.
- Effortless Unit Testing: Every external dependency is an interface, making 100% mocked testing trivial.
- Painless Tech Migrations: Switching from PostgreSQL to DynamoDB requires changing one repository file, without altering a single line of business rules.
Here is how to structure a clean, idiomatic Go backend project.
The 4 Architectural Layers
+-------------------------------------------------------+
| 4. Frameworks & Drivers (HTTP Handlers, SQL, GORM) |
| +-----------------------------------------------+ |
| | 3. Interface Adapters (Controllers, Repos) | |
| | +-------------------------------------+ | |
| | | 2. Use Cases (Application Logic) | | |
| | | +---------------------------+ | | |
| | | | 1. Domain (Entities) | | | |
| | | +---------------------------+ | | |
+-------------------------------------------------------+
The Dependency Rule: Source code dependencies must point strictly inward.
Layer 1: The Domain Entity (internal/domain/user.go)
package domain
import (
"errors"
"time"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidEmail = errors.New("invalid email address")
ErrDuplicateEmail = errors.New("email already registered")
)
type User struct {
ID string
Email string
FullName string
CreatedAt time.Time
}
func (u *User) Validate() error {
if u.Email == "" {
return ErrInvalidEmail
}
return nil
}
Layer 2: Ports and Use Cases (internal/usecase/user_usecase.go)
package usecase
import (
"context"
"time"
"github.com/google/uuid"
"myapp/internal/domain"
)
type UserRepository interface {
GetByID(ctx context.Context, id string) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error)
Save(ctx context.Context, user *domain.User) error
}
type UserUseCase struct {
repo UserRepository
}
func NewUserUseCase(repo UserRepository) *UserUseCase {
return &UserUseCase{repo: repo}
}
func (uc *UserUseCase) RegisterUser(ctx context.Context, email, name string) (*domain.User, error) {
existing, err := uc.repo.GetByEmail(ctx, email)
if err == nil && existing != nil {
return nil, domain.ErrDuplicateEmail
}
user := &domain.User{
ID: uuid.New().String(),
Email: email,
FullName: name,
CreatedAt: time.Now().UTC(),
}
if err := user.Validate(); err != nil {
return nil, err
}
if err := uc.repo.Save(ctx, user); err != nil {
return nil, err
}
return user, nil
}
Layer 3: Secondary Adapter (PostgreSQL Repository)
package postgres
import (
"context"
"database/sql"
"myapp/internal/domain"
)
type PostgresUserRepo struct {
db *sql.DB
}
func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
return &PostgresUserRepo{db: db}
}
func (r *PostgresUserRepo) Save(ctx context.Context, u *domain.User) error {
query := `INSERT INTO users (id, email, full_name, created_at) VALUES ($1, $2, $3, $4)`
_, err := r.db.ExecContext(ctx, query, u.ID, u.Email, u.FullName, u.CreatedAt)
return err
}
Architectural Benefits
- 100% Mockable: Unit tests can pass in-memory mocks without Docker or databases.
- Readable: Business rules are explicit and isolated from transport layers.

Top comments (0)