DEV Community

Cover image for Clean Architecture in Go: Structuring Domain, Use Cases, and Repositories
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

Clean Architecture in Go: Structuring Domain, Use Cases, and Repositories

Clean Architecture in Go: Structuring Domain, Use Cases, and Repositories

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:

  1. Zero Framework Coupling: The core business logic knows nothing about Gin, Echo, GORM, or PostgreSQL.
  2. Effortless Unit Testing: Every external dependency is an interface, making 100% mocked testing trivial.
  3. 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)      |    |    |  |
  |    |    |    +---------------------------+    |    |  |
  +-------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)