Implementing Clean Architecture in Go: A Practical Guide
Introduction
Clean Architecture, popularized by Robert C. Martin, emphasizes separation of concerns through concentric layers. In Go, this pattern helps build applications that are independent of frameworks, databases, and external agencies. This post walks through a practical implementation with a simple domain: user management.
Core Principles
Clean Architecture divides code into layers:
- Entities (innermost): Business rules and domain objects.
- Use Cases: Application-specific business logic.
- Interface Adapters: Convert data between use cases and external layers.
- Frameworks & Drivers (outermost): Databases, web frameworks, etc.
Dependencies point inward: outer layers depend on inner layers, never the reverse.
Project Structure
myapp/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── entity/
│ │ └── user.go
│ ├── usecase/
│ │ ├── user.go
│ │ └── user_test.go
│ ├── repository/
│ │ └── user.go
│ ├── handler/
│ │ └── user.go
│ └── database/
│ └── postgres.go
├── go.mod
└── go.sum
Entities Layer
Entities are pure Go structs with business rules. They have no external dependencies.
// internal/entity/user.go
package entity
import "errors"
var (
ErrInvalidName = errors.New("name cannot be empty")
ErrInvalidEmail = errors.New("email must contain @")
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func NewUser(name, email string) (*User, error) {
if name == "" {
return nil, ErrInvalidName
}
if !contains(email, "@") {
return nil, ErrInvalidEmail
}
return &User{Name: name, Email: email}, nil
}
func contains(s, substr string) bool {
for i := 0; i < len(s)-len(substr)+1; i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
Use Case Layer
Use cases orchestrate business logic. They depend on entity and repository interfaces (defined here).
// internal/usecase/user.go
package usecase
import (
"context"
"myapp/internal/entity"
)
// UserRepository defines the interface for data access.
type UserRepository interface {
Save(ctx context.Context, user *entity.User) (int64, error)
FindByID(ctx context.Context, id int64) (*entity.User, error)
}
type UserUseCase struct {
repo UserRepository
}
func NewUserUseCase(repo UserRepository) *UserUseCase {
return &UserUseCase{repo: repo}
}
func (uc *UserUseCase) CreateUser(ctx context.Context, name, email string) (*entity.User, error) {
user, err := entity.NewUser(name, email)
if err != nil {
return nil, err
}
id, err := uc.repo.Save(ctx, user)
if err != nil {
return nil, err
}
user.ID = id
return user, nil
}
func (uc *UserUseCase) GetUser(ctx context.Context, id int64) (*entity.User, error) {
return uc.repo.FindByID(ctx, id)
}
Repository Interface (Inner Layer Contract)
The repository interface is defined in the use case layer, not in the outer layer. This keeps dependencies inward.
Database Implementation (Outer Layer)
// internal/database/postgres.go
package database
import (
"context"
"database/sql"
"myapp/internal/entity"
)
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Save(ctx context.Context, user *entity.User) (int64, error) {
query := `INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id`
var id int64
err := r.db.QueryRowContext(ctx, query, user.Name, user.Email).Scan(&id)
return id, err
}
func (r *UserRepository) FindByID(ctx context.Context, id int64) (*entity.User, error) {
query := `SELECT id, name, email FROM users WHERE id = $1`
user := &entity.User{}
err := r.db.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, err
}
return user, nil
}
HTTP Handler (Interface Adapter)
// internal/handler/user.go
package handler
import (
"encoding/json"
"net/http"
"strconv"
"myapp/internal/usecase"
)
type UserHandler struct {
uc *usecase.UserUseCase
}
func NewUserHandler(uc *usecase.UserUseCase) *UserHandler {
return &UserHandler{uc: uc}
}
type createUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user, err := h.uc.CreateUser(r.Context(), req.Name, req.Email)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
user, err := h.uc.GetUser(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
Main Wiring
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"net/http"
"myapp/internal/database"
"myapp/internal/handler"
"myapp/internal/usecase"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost/myapp?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
userRepo := database.NewUserRepository(db)
userUC := usecase.NewUserUseCase(userRepo)
userHandler := handler.NewUserHandler(userUC)
http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
userHandler.CreateUser(w, r)
case http.MethodGet:
userHandler.GetUser(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Testing the Use Case
Because the use case depends on an interface, we can mock the repository for unit tests.
// internal/usecase/user_test.go
package usecase
import (
"context"
"errors"
"testing"
"myapp/internal/entity"
)
type mockUserRepo struct {
saveFunc func(ctx context.Context, user *entity.User) (int64, error)
}
func (m *mockUserRepo) Save(ctx context.Context, user *entity.User) (int64, error) {
return m.saveFunc(ctx, user)
}
func (m *mockUserRepo) FindByID(ctx context.Context, id int64) (*entity.User, error) {
return nil, errors.New("not implemented")
}
func TestCreateUser_Success(t *testing.T) {
repo := &mockUserRepo{
saveFunc: func(ctx context.Context, user *entity.User) (int64, error) {
return 1, nil
},
}
uc := NewUserUseCase(repo)
user, err := uc.CreateUser(context.Background(), "John Doe", "john@example.com")
if err != nil {
t.Fatal(err)
}
if user.ID != 1 {
t.Errorf("expected ID 1, got %d", user.ID)
}
}
Benefits of This Approach
- Testability: Business logic can be tested without databases or HTTP servers.
- Flexibility: Swap databases, frameworks, or delivery mechanisms with minimal changes.
- Maintainability: Clear boundaries make the code easier to understand and modify.
- Independence: Core logic is not coupled to external libraries.
Conclusion
Clean Architecture in Go is achievable with interfaces and careful package organization. By keeping entities pure and using dependency inversion, you create a codebase that is robust and adaptable. Start small and let the architecture evolve naturally as your application grows.