Implementing Clean Architecture in Go: A Practical Guide

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.