Clean Architecture in Golang: Building Scalable APIs

by Didin J. on Aug 19, 2025 Clean Architecture in Golang: Building Scalable APIs

Learn how to implement unit testing in a Go Clean Architecture app with mock repositories, handlers, and usecases for reliable test coverage.

When building APIs in Go, it’s easy to start with a simple structure—routes, handlers, and database calls all mixed together. But as your project grows, this “quick start” approach can lead to messy, tightly coupled code that is hard to test, scale, and maintain.

This is where Clean Architecture comes in. Popularized by Robert C. Martin (Uncle Bob), Clean Architecture emphasizes separation of concerns and independence from frameworks, databases, or external services. The goal is to keep your business logic at the core, while everything else—such as web frameworks, databases, or third-party APIs—remains replaceable.

In this tutorial, you’ll learn how to build a scalable REST API in Go using Clean Architecture principles. We’ll structure the project into layers for Entities, Use Cases, Interfaces (Repositories), and Infrastructure, and we’ll use the Gin framework for HTTP handling. By the end, you’ll have a well-structured, testable, and maintainable Go API that you can easily extend as your application grows.

Prerequisites

Before starting, make sure you have the following installed and set up:

  • Go 1.23+ is installed on your system

  • Basic knowledge of Go modules, structs, and interfaces

  • Familiarity with building simple REST APIs in Go

  • A running PostgreSQL or MySQL database (for the persistence layer)

  • A tool to test APIs, such as Postman or curl

We’ll keep the example simple (a basic User API), but the structure can be applied to larger and more complex applications.


Project Setup

The first step is to set up a clean and organized project structure that reflects the principles of Clean Architecture.

1. Initialize Go Module

In your project directory, initialize a new Go module:

mkdir go-clean-architecture
cd go-clean-architecture
go mod init github.com/yourusername/go-clean-architecture

This creates a go.mod file, which will manage dependencies for your project.

2. Project Structure

We’ll organize the project into layers:

go-clean-architecture/
│
├── cmd/                # Application entry point
│   └── main.go
│
├── internal/           # Internal application code
│   ├── entity/         # Core business models (Entities)
│   ├── usecase/        # Application business logic (Use Cases)
│   ├── repository/     # Repository interfaces (contracts)
│   ├── infrastructure/ # Implementations (DB, external services)
│   └── delivery/       # Delivery layer (HTTP handlers/controllers)
│
└── go.mod

Why this structure?

  • entity → Represents core business objects (e.g., User).

  • usecase → Contains application logic (e.g., CreateUser, GetUser).

  • repository → Interfaces defining how the app interacts with persistence/storage.

  • infrastructure → Actual implementations of repositories (e.g., PostgreSQL).

  • delivery → Handles API requests/responses, using frameworks like Gin.

  • cmd → Entry point to run the application.

3. Install Dependencies

We’ll use Gin for HTTP handling and GORM for database interactions (you could also use database/sql if you prefer). Install them with:

go get github.com/gin-gonic/gin
go get gorm.io/gorm
go get gorm.io/driver/postgres

✅ At this point, your project is initialized and structured to follow Clean Architecture principles.


Define Entities (Core Business Models)

In Clean Architecture, Entities are the core business objects of your application. They should be framework-agnostic and contain only the essential fields and business rules.

For this tutorial, we’ll build a simple User API, so our main entity will be User.

1. Create the entity package

Inside your project, create the folder:

mkdir -p internal/entity

Then create a new file user.go:

package entity

import "time"

// User represents the core business model for our app.
type User struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

Key Points

  • The User struct is independent of any framework (Gin, GORM, etc.).

  • We only define the business fields needed for the domain.

  • If later we add other entities (e.g., Product, Order), they will live in this same entity package.

This entity layer is the innermost circle of Clean Architecture — it should not depend on anything external.

✅ Now we have our core entity defined.


Define Use Cases (Business Logic)

In Clean Architecture, Use Cases represent the application-specific business rules. They orchestrate the flow of data between the entities and the repositories, without depending on external frameworks.

For our User entity, we’ll define basic use cases:

  • CreateUser

  • GetUserByID

  • ListUsers

1. Create the usecase package

Inside your project, create the folder:

mkdir -p internal/usecase

Then create a new file user_usecase.go:

package usecase

import (
	"github.com/yourusername/go-clean-architecture/internal/entity"
)

// UserRepository defines the expected behavior from any User storage.
type UserRepository interface {
	Create(user *entity.User) error
	GetByID(id int64) (*entity.User, error)
	GetAll() ([]entity.User, error)
}

// UserUsecase contains the business logic for users.
type UserUsecase struct {
	repo UserRepository
}

// NewUserUsecase creates a new UserUsecase instance.
func NewUserUsecase(repo UserRepository) *UserUsecase {
	return &UserUsecase{repo: repo}
}

// CreateUser handles the business logic of creating a user.
func (uc *UserUsecase) CreateUser(user *entity.User) error {
	// Business rule example: email must not be empty
	if user.Email == "" {
		return ErrInvalidEmail
	}
	return uc.repo.Create(user)
}

// GetUserByID retrieves a user by ID.
func (uc *UserUsecase) GetUserByID(id int64) (*entity.User, error) {
	return uc.repo.GetByID(id)
}

// ListUsers retrieves all users.
func (uc *UserUsecase) ListUsers() ([]entity.User, error) {
	return uc.repo.GetAll()
}

2. Define Use Case Errors

In the same usecase folder, create a errors.go file for domain-specific errors:

package usecase

import "errors"

var (
	ErrInvalidEmail = errors.New("invalid email address")
)

Key Points

  • UserRepository interface → defines contracts for persistence (but doesn’t care if it’s Postgres, MySQL, or in-memory).

  • UserUsecase struct → implements application business rules using the repository interface.

  • Validation/business rules (like checking if an email is empty) live here, not in the controller or repository.

This keeps the business logic framework-agnostic and testable.

✅ Now we’ve defined our use cases.


Define Repository Interfaces (Abstractions)

In Clean Architecture, repositories act as the boundary between the use cases and the infrastructure layer (database, external APIs, etc.).

  • Use Cases should depend only on interfaces, not on concrete implementations.

  • This allows us to switch databases (Postgres, MySQL, MongoDB, or even in-memory) without changing our business logic.

Since we already defined a UserRepository interface inside the usecase package, let’s create a dedicated repository package to keep things clean.

1. Create the repository package

mkdir -p internal/repository

Inside it, create a new file user_repository.go:

package repository

import "github.com/yourusername/go-clean-architecture/internal/entity"

// UserRepository defines the contract for persisting users.
// Any implementation (Postgres, MySQL, in-memory) must satisfy this interface.
type UserRepository interface {
	Create(user *entity.User) error
	GetByID(id int64) (*entity.User, error)
	GetAll() ([]entity.User, error)
}

2. Refactor Use Case to Use This Interface

Update internal/usecase/user_usecase.go so it imports from the repository package instead of redefining the interface:

package usecase

import (
	"github.com/yourusername/go-clean-architecture/internal/entity"
	"github.com/yourusername/go-clean-architecture/internal/repository"
)

// UserUsecase contains the business logic for users.
type UserUsecase struct {
	repo repository.UserRepository
}

// NewUserUsecase creates a new UserUsecase instance.
func NewUserUsecase(repo repository.UserRepository) *UserUsecase {
	return &UserUsecase{repo: repo}
}

// CreateUser handles the business logic of creating a user.
func (uc *UserUsecase) CreateUser(user *entity.User) error {
	if user.Email == "" {
		return ErrInvalidEmail
	}
	return uc.repo.Create(user)
}

// GetUserByID retrieves a user by ID.
func (uc *UserUsecase) GetUserByID(id int64) (*entity.User, error) {
	return uc.repo.GetByID(id)
}

// ListUsers retrieves all users.
func (uc *UserUsecase) ListUsers() ([]entity.User, error) {
	return uc.repo.GetAll()
}

Key Points

  • The repository package holds abstract interfaces.

  • The infrastructure layer (we’ll implement soon) will satisfy these interfaces with actual DB logic.

  • The usecase layer now depends only on the repository abstraction, keeping it isolated from frameworks and databases.

✅ At this stage, we’ve clearly separated our contracts (repositories) from their implementations.


Implement Repository (Infrastructure Layer)

The infrastructure layer provides the actual implementation of our UserRepository interface. This is where we connect to the database (e.g., PostgreSQL) using a library such as database/sql or gorm.

For this tutorial, we’ll keep it simple and idiomatic by using database/sql with pgx as the PostgreSQL driver.

1. Install Dependencies

Run the following command inside your project:

go get github.com/jackc/pgx/v5/stdlib

This will give us a modern and efficient PostgreSQL driver.

2. Setup Database Connection

Create a new file internal/infrastructure/db.go:

package infrastructure

import (
	"database/sql"
	"log"

	_ "github.com/jackc/pgx/v5/stdlib"
)

// NewPostgresDB creates a new PostgreSQL connection pool.
func NewPostgresDB(dsn string) *sql.DB {
	db, err := sql.Open("pgx", dsn)
	if err != nil {
		log.Fatalf("failed to open database: %v", err)
	}

	// Verify the connection
	if err := db.Ping(); err != nil {
		log.Fatalf("failed to ping database: %v", err)
	}

	return db
}

3. Implement User Repository

Inside the internal/infrastructure package, create user_repository_postgres.go:

package infrastructure

import (
	"database/sql"

	"github.com/yourusername/go-clean-architecture/internal/entity"
	"github.com/yourusername/go-clean-architecture/internal/repository"
)

// UserRepositoryPostgres is a Postgres implementation of UserRepository.
type UserRepositoryPostgres struct {
	db *sql.DB
}

// NewUserRepositoryPostgres creates a new repository instance.
func NewUserRepositoryPostgres(db *sql.DB) repository.UserRepository {
	return &UserRepositoryPostgres{db: db}
}

func (r *UserRepositoryPostgres) Create(user *entity.User) error {
	query := "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id"
	return r.db.QueryRow(query, user.Name, user.Email).Scan(&user.ID)
}

func (r *UserRepositoryPostgres) GetByID(id int64) (*entity.User, error) {
	var user entity.User
	query := "SELECT id, name, email FROM users WHERE id = $1"
	err := r.db.QueryRow(query, id).Scan(&user.ID, &user.Name, &user.Email)
	if err != nil {
		return nil, err
	}
	return &user, nil
}

func (r *UserRepositoryPostgres) GetAll() ([]entity.User, error) {
	query := "SELECT id, name, email FROM users"
	rows, err := r.db.Query(query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var users []entity.User
	for rows.Next() {
		var user entity.User
		if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil {
			return nil, err
		}
		users = append(users, user)
	}

	return users, nil
}

4. Database Schema

Before running the app, ensure you have a users table in PostgreSQL:

psql postgres -U djamware
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL
);
\q

Key Points

  • The repository implementation (UserRepositoryPostgres) satisfies the UserRepository interface.

  • The infrastructure layer depends on Postgres, but the use case layer remains database-agnostic.

  • We can swap PostgreSQL with MySQL, MongoDB, or even an in-memory repo for testing without changing business logic.

✅ At this point, our repository layer is ready.


Delivery Layer (HTTP Handlers with Gin)

The delivery layer (sometimes called interface adapters) is responsible for:

  • Accepting HTTP requests

  • Mapping requests to use case methods

  • Returning appropriate responses (JSON)

1. Install Gin

Run the following command:

go get github.com/gin-gonic/gin

2. Create HTTP Handlers

Inside internal/handler, create a file user_handler.go:

package handler

import (
	"net/http"
	"strconv"

	"github.com/yourusername/go-clean-architecture/internal/entity"
	"github.com/yourusername/go-clean-architecture/internal/usecase"
	"github.com/gin-gonic/gin"
)

type UserHandler struct {
	userUsecase *usecase.UserUsecase
}

func NewUserHandler(u *usecase.UserUsecase) *UserHandler {
	return &UserHandler{userUsecase: u}
}

// POST /users
func (h *UserHandler) CreateUser(c *gin.Context) {
	var user entity.User
	if err := c.ShouldBindJSON(&user); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	err := h.userUsecase.CreateUser(&user)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusCreated, user)
}

// GET /users/:id
func (h *UserHandler) GetUserByID(c *gin.Context) {
	idParam := c.Param("id")
	id, err := strconv.ParseInt(idParam, 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
		return
	}

	user, err := h.userUsecase.GetUserByID(id)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
		return
	}

	c.JSON(http.StatusOK, user)
}

3. Wire Everything Together in main.go

Now we need an entry point to initialize:

  • DB connection

  • Repository

  • Usecase

  • HTTP Handler

Create a cmd/app/main.go:

package main

import (
	"log"

	"github.com/gin-gonic/gin"
	"github.com/yourusername/go-clean-architecture/internal/handler"
	"github.com/yourusername/go-clean-architecture/internal/infrastructure"
	"github.com/yourusername/go-clean-architecture/internal/usecase"
)

func main() {
	// Setup PostgreSQL connection
	dsn := "postgres://username:password@localhost:5432/clean_arch_db?sslmode=disable"
	db := infrastructure.NewPostgresDB(dsn)

	// Setup repository
	userRepo := infrastructure.NewUserRepositoryPostgres(db)

	// Setup usecase
	userUC := usecase.NewUserUsecase(userRepo)

	// Initialize handler
	userHandler := handler.NewUserHandler(userUC)

	// Setup Gin router
	r := gin.Default()

	// Routes
	r.POST("/users", userHandler.CreateUser)
	r.GET("/users/:id", userHandler.GetUserByID)

	// Run server
	log.Println("Server running at http://localhost:8080")
	if err := r.Run(":8080"); err != nil {
		log.Fatal(err)
	}
}

4. Run the API

go run cmd/app/main.go

Your API is now live 🎉

5. Test Endpoints

Using cURL or Postman:

  • Create User

    curl -X POST http://localhost:8080/users \
    -H "Content-Type: application/json" \
    -d '{"name":"Alice","email":"[email protected]"}'
  • Get User by ID

    curl http://localhost:8080/users/1
  • Get All Users

    curl http://localhost:8080/users

✅ At this stage, we’ve connected all layers:

  • main.go wires everything together

  • Gin delivers the API

  • Use cases contain business logic

  • Repository persists data in Postgres


Adding Persistent Storage (Switching from In-Memory to a Real Database)

Because so far, our repository only stores users in memory, which means data disappears once the app stops. For a scalable API with Clean Architecture, we need a proper persistence layer (e.g., PostgreSQL).

Adding Persistent Storage with PostgreSQL

1. Install PostgreSQL

Make sure you have PostgreSQL installed locally or running in Docker.
For Docker, you can spin it up with:

docker run --name clean-arch-postgres -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=cleanarch -p 5432:5432 -d postgres:16

2. Install Dependencies

We’ll use gorm (popular ORM in Go):

go get gorm.io/gorm
go get gorm.io/driver/postgres

3. Create a Database Repository Implementation

Inside internal/infrastructure/postgres_user_repository.go:

package infrastructure

import (
	"database/sql"

	"github.com/yourusername/go-clean-architecture/internal/entity"
)

type PostgresUserRepository struct {
	db *sql.DB
}

func NewPostgresUserRepository(db *sql.DB) *PostgresUserRepository {
	return &PostgresUserRepository{db: db}
}

func (r *PostgresUserRepository) Create(user *entity.User) error {
	_, err := r.db.Exec("INSERT INTO users(name, email) VALUES($1, $2)", user.Name, user.Email)
	return err
}

func (r *PostgresUserRepository) GetByID(id int64) (*entity.User, error) {
	query := `SELECT id, name, email FROM users WHERE id = $1`
	row := r.db.QueryRow(query, id)

	var user entity.User
	err := row.Scan(&user.ID, &user.Name, &user.Email)
	if err != nil {
		return nil, err
	}
	return &user, nil
}

func (r *PostgresUserRepository) GetAll() ([]entity.User, error) {
	query := "SELECT id, name, email FROM users"
	rows, err := r.db.Query(query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var users []entity.User
	for rows.Next() {
		var user entity.User
		if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil {
			return nil, err
		}
		users = append(users, user)
	}

	return users, nil
}

4. Update main.go to Use PostgreSQL

Modify main.go:

package main

import (
	"log"

	"github.com/gin-gonic/gin"
	"your_project/internal/handler"
	"your_project/internal/infrastructure"
	"your_project/internal/usecase"
)

func main() {
	// DSN for PostgreSQL
	dsn := "host=localhost user=admin password=secret dbname=cleanarch port=5432 sslmode=disable"

	// Initialize repository (Postgres)
	userRepo, err := infrastructure.NewPostgresUserRepository(dsn)
	if err != nil {
		log.Fatalf("failed to connect database: %v", err)
	}

	// Initialize usecase
	userUC := usecase.NewUserUsecase(userRepo)

	// Initialize handler
	userHandler := handler.NewUserHandler(userUC)

	// Setup Gin router
	r := gin.Default()
	r.POST("/users", userHandler.CreateUser)
	r.GET("/users/:id", userHandler.GetUserByID)

	// Run server
	log.Println("Server running at http://localhost:8080")
	if err := r.Run(":8080"); err != nil {
		log.Fatal(err)
	}
}

✅ With this, your API now uses PostgreSQL instead of memory.
Users will persist in the database across server restarts.


Structuring the Project for Scalability (Clean Architecture)

So far, we’ve kept everything inside internal/. That works for small apps, but for a real scalable project, we should enforce layer separation and make the app easy to extend (e.g., new features, multiple databases, additional delivery mechanisms like gRPC).

Here’s a recommended folder structure for Clean Architecture in Go:

your_project/
│── cmd/
│   └── api/
│       └── main.go          # Entry point
│
│── config/                  # Config management (YAML/ENV loader)
│
│── internal/
│   ├── entity/              # Enterprise business rules (core models)
│   │   └── user.go
│   │
│   ├── usecase/             # Application business rules (interactors)
│   │   └── user_usecase.go
│   │
│   ├── repository/          # Interfaces (ports)
│   │   └── user_repository.go
│   │
│   ├── infrastructure/      # External implementations (adapters)
│   │   ├── postgres_user_repository.go
│   │   └── memory_user_repository.go
│   │
│   ├── delivery/            # Delivery mechanisms
│   │   └── http/            # HTTP handler with Gin
│   │       └── user_handler.go
│   │
│   └── bootstrap/           # App wiring (dependency injection)
│       └── app.go
│
│── pkg/                     # Reusable packages (logging, utils, middlewares)
│── go.mod
│── go.sum

🔹 1. cmd/api/main.go (Entry Point)

Keep the main.go as thin as possible. Only bootstrapping logic:

package main

import (
	"log"
	"your_project/internal/bootstrap"
)

func main() {
	app := bootstrap.InitializeApp()
	if err := app.Run(":8080"); err != nil {
		log.Fatal(err)
	}
}

🔹 2. internal/bootstrap/app.go (Dependency Injection)

This layer wires everything together:

package bootstrap

import (
	"database/sql"
	"log"

	"github.com/yourusername/go-clean-architecture/internal/handler"
	"github.com/yourusername/go-clean-architecture/internal/infrastructure"
	"github.com/yourusername/go-clean-architecture/internal/usecase"
	"github.com/gin-gonic/gin"
)

type App struct {
	engine *gin.Engine
}

func InitializeApp() *App {
	dsn := "host=localhost port=5432 user=postgres password=yourpassword dbname=yourdb sslmode=disable"
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Initialize repository (Postgres)
	userRepo := infrastructure.NewPostgresUserRepository(db)
	if err != nil {
		log.Fatalf("failed to connect database: %v", err)
	}

	// Initialize usecase
	userUC := usecase.NewUserUsecase(userRepo)

	// Initialize handler
	userHandler := handler.NewUserHandler(userUC)

	// Setup Gin router
	r := gin.Default()
	r.POST("/users", userHandler.CreateUser)
	r.GET("/users/:id", userHandler.GetUserByID)

	return &App{engine: r}
}

func (a *App) Run(addr string) error {
	return a.engine.Run(addr)
}

🔹 3. Config Management (config/)

Instead of hardcoding DSN in code, load configs from environment variables or YAML.

Example: config/config.yaml

server:
  port: 8080

database:
  host: localhost
  port: 5432
  user: admin
  password: secret
  name: cleanarch

Loader in config/config.go:

package config

import (
	"log"

	"github.com/spf13/viper"
)

type Config struct {
	Server struct {
		Port string
	}
	Database struct {
		Host     string
		Port     int
		User     string
		Password string
		Name     string
	}
}

func LoadConfig() *Config {
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath("./config")

	if err := viper.ReadInConfig(); err != nil {
		log.Fatalf("Error loading config: %v", err)
	}

	var cfg Config
	if err := viper.Unmarshal(&cfg); err != nil {
		log.Fatalf("Error unmarshalling config: %v", err)
	}
	return &cfg
}

Install. this dependency.

go get github.com/spf13/viper

Then use LoadConfig() inside bootstrap/app.go to configure the DB and server dynamically.

🔹 4. Why This Structure Scales

  • Loose coupling: Entities never depend on frameworks (Gin, Gorm).

  • Replaceable layers: Want to switch to MySQL? Just add mysql_user_repository.go.

  • Multiple delivery mechanisms: Easily add gRPC, CLI, or GraphQL under delivery/.

  • Clear boundaries: Each layer has a single responsibility.

✅ With this structure, your project can grow into a large-scale system while remaining maintainable and testable.


Unit Testing Strategy for Clean Architecture in Go

One of the biggest benefits of using Clean Architecture is testability. Since the layers are independent and communicate only through abstractions (interfaces), we can easily isolate and test business logic without depending on frameworks, databases, or external services.

🎯 Testing Goals

In this architecture, unit testing should focus on three primary layers:

  1. Entity Layer (Core Models)

    • Usually very light, so testing here is minimal.

    • You may only need tests for entity validation methods (if any).

  2. Use Case Layer (Business Logic)

    • The most important layer to test.

    • Should be tested in isolation by mocking the Repository interfaces.

    • Verify that business rules (validation, error handling, data flow) work as expected.

  3. Repository Layer (Infrastructure)

    • Should be tested separately with integration tests (not pure unit tests).

    • Example: using a temporary PostgreSQL or in-memory SQLite to validate SQL queries.

  4. Delivery Layer (HTTP Handlers)

    • Can be tested with HTTP request simulations (httptest in Go).

    • These are closer to integration tests, but you can still mock the Usecase layer.

🏗 Example: Unit Test for Use Case Layer

Let’s test the RegisterUser use case.
We’ll mock the repository so no database is needed.

usecase/user_usecase_test.go

package usecase_test

import (
	"errors"
	"testing"

	"github.com/didinj/go-clean-architecture/internal/entity"
	"github.com/didinj/go-clean-architecture/internal/usecase"
	"github.com/didinj/go-clean-architecture/internal/repository"
	"github.com/stretchr/testify/assert"
)

// --- Mock Repository ---
type mockUserRepository struct {
	users map[int]*entity.User
	err   error
}

func (m *mockUserRepository) Create(user *entity.User) error {
	if m.err != nil {
		return m.err
	}
	user.ID = len(m.users) + 1
	m.users[user.ID] = user
	return nil
}

func (m *mockUserRepository) GetByID(id int) (*entity.User, error) {
	if user, ok := m.users[id]; ok {
		return user, nil
	}
	return nil, errors.New("not found")
}

// --- Tests ---
func TestRegisterUser_Success(t *testing.T) {
	repo := &mockUserRepository{users: make(map[int]*entity.User)}
	uc := usecase.NewUserUsecase(repo)

	user := &entity.User{Name: "Alice", Email: "[email protected]"}
	err := uc.RegisterUser(user)

	assert.NoError(t, err)
	assert.Equal(t, 1, user.ID)
}

func TestRegisterUser_Failure(t *testing.T) {
	repo := &mockUserRepository{err: errors.New("db error"), users: make(map[int]*entity.User)}
	uc := usecase.NewUserUsecase(repo)

	user := &entity.User{Name: "Bob", Email: "[email protected]"}
	err := uc.RegisterUser(user)

	assert.Error(t, err)
	assert.Equal(t, "db error", err.Error())
}

✅ This way, we test the use case logic only, without touching the real database.

⚡ Testing HTTP Handlers with httptest

We can also test delivery logic by mocking the usecase layer.

delivery/http/user_handler_test.go

package http_test

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/didinj/go-clean-architecture/internal/entity"
	"github.com/didinj/go-clean-architecture/internal/delivery/http"
	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
)

// Mock Usecase
type mockUserUsecase struct{}

func (m *mockUserUsecase) RegisterUser(user *entity.User) error {
	user.ID = 99
	return nil
}
func (m *mockUserUsecase) GetUser(id int) (*entity.User, error) {
	return &entity.User{ID: id, Name: "Test", Email: "[email protected]"}, nil
}

func TestRegisterUserHandler(t *testing.T) {
	gin.SetMode(gin.TestMode)

	r := gin.Default()
	h := http.NewUserHandler(&mockUserUsecase{})
	h.RegisterRoutes(r)

	body := `{"name":"Charlie","email":"[email protected]"}`
	req, _ := http.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")

	w := httptest.NewRecorder()
	r.ServeHTTP(w, req)

	assert.Equal(t, http.StatusCreated, w.Code)
	assert.Contains(t, w.Body.String(), `"id":99`)
}

📌 Key Takeaways

  • Mock repositories to isolate use cases.

  • Use httptest for HTTP handler testing.

  • Integration tests should verify repository behavior with a real DB.

  • Entities often need little testing unless they contain logic.


Conclusion

In this guide, you learned how to unit test a Gin handler in a Clean Architecture project by using a mock implementation of the UserUsecase interface.

The key takeaways are:

  • Always depend on interfaces (like usecase.UserUsecase) rather than concrete structs in your handler.

  • Create a mock struct that implements the same interface methods to isolate your handler logic from business logic.

  • Use httptest.NewRecorder and gin.TestMode to simulate HTTP requests in your tests.

  • Use testify/assert to simplify response validation.

  • Add a compile-time assertion (var _ usecase.UserUsecase = (*mockUserUsecase)(nil)) to guarantee your mock matches the interface contract.

With this approach, you ensure that your handlers are tested independently of your database or real use case logic, leading to faster and more reliable tests.

Next steps:

  • Add more tests for other endpoints (GetUserHandler, UpdateUserHandler, etc.).

  • Consider table-driven tests for covering multiple scenarios.

  • Integrate with CI/CD to automatically run your tests on every commit.

You can get the full source code on our GitHub.

That is just the basics. If you need more deep learning about the Golang (Go) language and frameworks, you can take the following cheap course:

Thanks!