The Codebase That Nobody Wants to Touch
Every Go developer eventually hits this wall. You join a project, open the repository, and find a main.go that’s 800 lines long. Database queries live next to business logic. HTTP handlers call SQL directly. Tests are nearly impossible to write because everything is tightly coupled.
I’ve been there. A production Go service that started as a “quick internal tool” somehow grew into a critical system — and nobody wanted to touch it because changing one thing broke three others. That experience is why Clean Architecture became non-negotiable for me.
Go encourages simplicity, which is one of its best features. But that same simplicity can tempt developers to skip structure early on. Skip it, and you pay the price later — usually right before a deadline or during a 2am incident.
Why Go Projects Fall Apart: The Root Causes
Most Go projects fall apart the same way:
- God packages: A single
utilsorservicepackage that accumulates everything with no clear ownership. - Leaking infrastructure concerns: Your business logic imports
database/sqlor calls a specific ORM directly, making it impossible to swap implementations. - Untestable code: Functions that open real database connections or make live HTTP calls — meaning you either skip tests or write slow, fragile integration tests for everything.
- Circular dependencies: Package A imports Package B, which imports Package A. Go’s compiler catches this, but you’ll spend hours reorganizing instead of building features.
These aren’t discipline failures — they’re structural failures. Without a deliberate architecture, even experienced developers default to tight coupling because it’s simply faster in the moment.
Clean Architecture vs. Standard Layered Architecture: The Key Difference
Most Go developers know the basic layered approach: handler → service → repository. Better than nothing — but it still lets inner layers depend on outer ones. A service that imports a concrete database struct is a common example of this leak.
Clean Architecture enforces one strict rule: dependencies point inward only. Your business logic at the center knows nothing about databases, HTTP, or any external framework. The outer layers (HTTP handlers, database adapters) depend on the inner ones. Never the reverse.
In practice, the layers map to this in Go:
- Entities / Domain: Pure Go structs and business rules. No imports from external packages.
- Use Cases: Application-specific business logic. Depends only on entities and interface definitions.
- Interface Adapters: Controllers (HTTP handlers), presenters, repository implementations.
- Frameworks & Drivers: The outermost layer — database drivers, HTTP routers, external APIs.
A Practical Go Project Structure
Below is the directory layout I use for Go services built with Clean Architecture:
myapp/
├── cmd/
│ └── api/
│ └── main.go # Entry point, wires everything together
├── internal/
│ ├── domain/
│ │ ├── user.go # Entity: User struct, domain errors
│ │ └── user_repository.go # Interface: UserRepository
│ ├── usecase/
│ │ ├── user_usecase.go # Business logic
│ │ └── user_usecase_test.go
│ ├── adapter/
│ │ ├── http/
│ │ │ └── user_handler.go # HTTP handlers
│ │ └── repository/
│ │ └── postgres_user_repo.go # DB implementation
│ └── infrastructure/
│ └── db.go # Database connection setup
└── go.mod
Step 1: Define the Domain Layer
The domain layer holds your core entities and repository interfaces. No external imports allowed here.
// internal/domain/user.go
package domain
import "errors"
type User struct {
ID int64
Name string
Email string
}
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateEmail = errors.New("email already exists")
)
// internal/domain/user_repository.go
package domain
import "context"
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Save(ctx context.Context, user *User) error
}
Notice that UserRepository is an interface, not a struct. The concrete implementation lives in the adapter layer. That single design choice is what makes the whole thing testable.
Step 2: Write the Use Case Layer
Use cases contain your application’s business rules. They depend on the domain interface — not any concrete implementation.
// internal/usecase/user_usecase.go
package usecase
import (
"context"
"myapp/internal/domain"
)
type UserUseCase struct {
repo domain.UserRepository
}
func NewUserUseCase(repo domain.UserRepository) *UserUseCase {
return &UserUseCase{repo: repo}
}
func (uc *UserUseCase) GetUser(ctx context.Context, id int64) (*domain.User, error) {
user, err := uc.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
return user, nil
}
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email string) (*domain.User, error) {
user := &domain.User{Name: name, Email: email}
if err := uc.repo.Save(ctx, user); err != nil {
return nil, err
}
return user, nil
}
Step 3: Implement the Repository Adapter
The concrete database implementation lives in the adapter layer. It imports the domain interface and satisfies it.
// internal/adapter/repository/postgres_user_repo.go
package repository
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) FindByID(ctx context.Context, id int64) (*domain.User, error) {
user := &domain.User{}
err := r.db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Name, &user.Email)
if err == sql.ErrNoRows {
return nil, domain.ErrUserNotFound
}
return user, err
}
func (r *PostgresUserRepo) Save(ctx context.Context, user *domain.User) error {
return r.db.QueryRowContext(ctx,
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
user.Name, user.Email,
).Scan(&user.ID)
}
Step 4: Wire It Together in main.go
Dependency injection happens at the outermost layer — main.go. Concrete implementations get created here and injected inward.
// cmd/api/main.go
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/lib/pq"
"myapp/internal/adapter/repository"
httpAdapter "myapp/internal/adapter/http"
"myapp/internal/usecase"
"myapp/internal/infrastructure"
)
func main() {
db, err := infrastructure.NewPostgresDB()
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Wire dependencies from inside out
userRepo := repository.NewPostgresUserRepo(db)
userUC := usecase.NewUserUseCase(userRepo)
userHandler := httpAdapter.NewUserHandler(userUC)
mux := http.NewServeMux()
mux.HandleFunc("/users", userHandler.GetUser)
log.Println("Starting server on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
The Real Payoff: Testing Without a Database
This is where the architecture actually proves itself. Because UserUseCase depends on the UserRepository interface, you can swap in a mock for tests — no real database needed.
// internal/usecase/user_usecase_test.go
package usecase_test
import (
"context"
"testing"
"myapp/internal/domain"
"myapp/internal/usecase"
)
type mockUserRepo struct {
users map[int64]*domain.User
}
func (m *mockUserRepo) FindByID(_ context.Context, id int64) (*domain.User, error) {
if u, ok := m.users[id]; ok {
return u, nil
}
return nil, domain.ErrUserNotFound
}
func (m *mockUserRepo) Save(_ context.Context, user *domain.User) error {
user.ID = int64(len(m.users) + 1)
m.users[user.ID] = user
return nil
}
func TestGetUser_NotFound(t *testing.T) {
repo := &mockUserRepo{users: make(map[int64]*domain.User)}
uc := usecase.NewUserUseCase(repo)
_, err := uc.GetUser(context.Background(), 999)
if err != domain.ErrUserNotFound {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
}
These tests run in milliseconds. No Docker. No database setup. No flaky network calls. Just fast, reliable feedback on your business logic.
Common Mistakes to Avoid
After applying this pattern across five production Go services, these are the mistakes that show up most often:
- Putting validation in HTTP handlers: Business rule validation — “email must be unique”, “age must be positive” — belongs in the use case or domain layer. Handlers should only validate input format, not enforce business rules.
- Returning database models from use cases: If your use case returns a raw
*sql.Rowor an ORM model, you’ve leaked infrastructure concerns upward. Map to domain structs at the repository boundary. - Over-engineering small services: Full layering adds real overhead. For a 3-endpoint microservice that simply proxies another API, this structure is probably overkill. Apply it where the codebase will grow.
- Skipping context propagation: Always pass
context.Contextthrough every layer. It enables request cancellation and deadline propagation — both critical in production.
When This Structure Pays Off Most
Three scenarios make this structure worth the setup cost:
- Switching databases: Migrating from PostgreSQL to MySQL — or adding a Redis cache layer — requires changes only in the adapter layer. Use case code is untouched.
- Adding a second delivery mechanism: Need a gRPC endpoint alongside your REST API? The use case layer reuses as-is. You only write a new gRPC handler.
- Onboarding new engineers: Clear boundaries make it obvious where new code belongs. New team members contribute faster with less risk of accidentally breaking unrelated parts of the system.
Day one, it feels like overhead. Six months in, when you’re shipping a major feature and your test suite still finishes in under 10 seconds — that initial friction will make complete sense.

