In my early days of Go development, I fell into the common trap of putting everything in a main package or a giant utils folder. As soon as I needed to switch from a MySQL database to PostgreSQL, or from a REST API to gRPC, I found myself rewriting nearly 60% of my codebase. That’s when I discovered Clean Architecture.
This golang clean architecture tutorial is designed to help you avoid that pain. Clean Architecture isn’t about following a strict set of rules for the sake of it; it’s about dependency inversion. The goal is to ensure your core business logic doesn’t know or care whether you’re using Gin, Fiber, Gorm, or a raw SQL driver.
Prerequisites
Before we dive into the code, make sure you have the following installed and configured:
- Go 1.21+ installed on your machine.
- A basic understanding of Go interfaces (this is the ‘secret sauce’ of clean architecture).
- A code editor (I recommend VS Code with the Go extension).
- A basic grasp of golang project structure best practices to understand where this fits in.
Step-by-Step Implementation
Step 1: Defining the Domain (Entities)
The center of the onion is the Domain layer. This contains your business objects. These are plain Go structs that have no dependencies on any other layer.
package domain
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
Step 2: Creating the Repository Interface
Still within the domain or a dedicated ‘repository’ package, we define how we want to interact with data, without specifying where that data comes from. This is key to the golang testing best practices I always advocate for, as it allows for easy mocking.
package domain
import "context"
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, user *User) error
}
Step 3: The Use Case Layer (Business Logic)
The Use Case layer coordinates the flow of data. It depends on the interface we just created, not a concrete database implementation.
package usecase
import (
"context"
"myproject/domain"
)
type UserUseCase struct {
repo domain.UserRepository
}
func NewUserUseCase(r domain.UserRepository) *UserUseCase {
return &UserUseCase{repo: r}
}
func (uc *UserUseCase) GetUser(ctx context.Context, id int64) (*domain.User, error) {
return uc.repo.GetByID(ctx, id)
}
As shown in the architecture diagram above, the UseCase layer only points inward toward the Domain. It has no idea if the repository is using MongoDB or a CSV file.
Step 4: The Infrastructure Layer (Implementation)
Now we implement the repository. This is where we use Gorm, sqlx, or any other driver. If we decide to change databases later, this is the only place we change code.
package repository
import (
"context"
"database/sql"
"myproject/domain"
)
type mysqlUserRepository struct {
db *sql.DB
}
func NewMySQLUserRepository(db *sql.DB) domain.UserRepository {
return &mysqlUserRepository{db: db}
}
func (r *mysqlUserRepository) GetByID(ctx context.Context, id int64) (*domain.User, error) {
// Actual SQL logic here
return &domain.User{ID: id, Name: "John Doe"}, nil
}
Step 5: The Delivery Layer (HTTP/Handlers)
Finally, we create the entry point. I usually use the Gin framework here. The handler calls the use case, and the use case calls the repository.
package delivery
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"myproject/usecase"
)
type UserHandler struct {
useCase *usecase.UserUseCase
}
func (h *UserHandler) GetUser(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
user, err := h.useCase.GetUser(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, user)
}
Pro Tips for Go Architecture
- Avoid Package Cycles: This is the most common Go error. If
usecaseimportsrepositoryandrepositoryimportsusecase, your app won’t compile. Always ensure dependencies flow in one direction. - Keep Entities Lean: Don’t put JSON tags in your core domain entities if you want absolute purity, though in my experience, keeping them there is a pragmatic trade-off for smaller projects.
- Use Dependency Injection: Always pass dependencies into constructors (e.g.,
NewUserUseCase(repo)). Never useinit()functions to set up global database connections.
Troubleshooting Common Issues
Issue: “Import cycle not allowed”
This happens when you try to reference a handler inside a usecase. Remember: Handler → UseCase → Repository. Never the other way around.
Issue: Interface Bloat
If your UserRepository interface has 50 methods, it’s too big. Break it down into smaller, focused interfaces (e.g., UserReader and UserWriter). This follows the Interface Segregation Principle.
What’s Next?
Now that you’ve mastered the layout, the next step is ensuring your logic is bulletproof. I highly recommend reading up on golang testing best practices to learn how to mock those repository interfaces using tools like mockgen.
Ready to scale your productivity? Check out my other guides on organizing large Go projects to ensure your team can collaborate without stepping on each other’s toes.