How I Keep My Go Code Clean (Without Going Crazy) đ§č
March 02, 2025
âą9 minutes read
I used to be the person who tried to bend âClean Architectureâ into every Go project. It rarely helped. After a few over-engineered APIs and some raised eyebrows, I found a simpler, more Go-friendly way to keep things tidy and testable.
The core idea is: prefer clarity over ceremony. Keep packages straightforward, put interfaces where theyâre actually used, and use small, focused services for business logic. Below is what worked for me in real projects.
Why classic Clean Architecture feels off in Go
Uncle Bobâs patterns shine in languages with heavy DI frameworks and lots of abstractions. Go prefers flatter, simpler code:
- Go likes plain packages and direct wiring; deep layering adds noise.
- Creating interfaces for everything is usually overkill.
- Goâs explicit error handling clashes with exception-heavy examples in most Clean Architecture write-ups.
- Adding extra âinterfaceâ packages often fragments the codebase rather than improving it.
That said, the principles behind Clean Architectureâseparation of concerns, testability, and decouplingâare still valuable. They just need a lighter, Go-centric application.
A practical project layout that works
Hereâs a layout I use that keeps things organized without unnecessary complexity:
project/
âââ cmd/
â âââ server/
â âââ main.go # Entry point
âââ internal/
â âââ domain/ # Your business stuff
â â âââ user.go
â â âââ errors.go
â âââ service/ # Business logic lives here
â â âââ user.go
â âââ repository/ # Data access
â â âââ postgres/
â â âââ memory/
â âââ transport/ # HTTP/gRPC/CLI stuff
â âââ http/
â âââ grpc/
âââ pkg/ # Reusable bits
âââ go.mod
The difference: I favor âservicesâ over abstract âuse casesâ and let Go packages express boundaries naturally.
Keep the domain small and clear
Domain types should be plain and easy to reason about:
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
func (u *User) Validate() error {
if u.Email == "" {
return ErrInvalidEmail
}
if u.Name == "" {
return ErrInvalidName
}
return nil
}
No magic constructors, no unnecessary patternsâjust a clear data shape and straightforward validation.
Services: where business logic lives
Services coordinate domain logic and repositories. They keep complexity out of handlers:
type UserService struct {
repo UserRepository
logger *slog.Logger
}
func (s *UserService) CreateUser(ctx context.Context, email, name string) (*domain.User, error) {
user := &domain.User{
ID: generateID(),
Email: email,
Name: name,
CreatedAt: time.Now(),
}
if err := user.Validate(); err != nil {
return nil, err
}
if existing, _ := s.repo.GetByEmail(ctx, email); existing != nil {
return nil, domain.ErrUserExists
}
return user, s.repo.Create(ctx, user)
}
Services are easy to test and reason about, and they match Goâs straightforward style.
Repositories: interfaces where they make sense
Define repository interfaces close to their consumersâusually in the same package as the service that uses them:
type UserRepository interface {
Create(ctx context.Context, user *domain.User) error
GetByEmail(ctx context.Context, email string) (*domain.User, error)
}
Implementations (Postgres, in-memory for tests, etc.) live where they belong.
Thin transport layer
Keep HTTP or gRPC handlers minimalâjust translate requests to service calls and format responses:
type UserHandler struct {
service *service.UserService
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, "invalid JSON", http.StatusBadRequest)
return
}
user, err := h.service.CreateUser(r.Context(), req.Email, req.Name)
if err != nil {
handleServiceError(w, err)
return
}
writeJSON(w, user, http.StatusCreated)
}
Thin handlers make refactoring easier and keep transport concerns separate from business logic.
Wiring: explicit and simple
I wire dependencies in main with plain functionsâno DI container surprises:
func main() {
db := setupDatabase()
logger := setupLogger()
userRepo := postgres.NewUserRepository(db)
userService := service.NewUserService(userRepo, logger)
userHandler := http.NewUserHandler(userService)
router := setupRoutes(userHandler)
log.Fatal(http.ListenAndServe(":8080", router))
}
Explicit wiring is easy to follow and debug.
Testing is the payoff
One big win from this structure: tests are straightforward. Swap in an in-memory repository and run service tests without mocks:
func TestUserService_CreateUser(t *testing.T) {
repo := memory.NewUserRepository()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
service := service.NewUserService(repo, logger)
user, err := service.CreateUser(context.Background(), "test@example.com", "John")
assert.NoError(t, err)
assert.Equal(t, "test@example.com", user.Email)
}
No heavy mocking frameworks, simple setups, reliable tests.
When this approach fits best
- Medium-sized APIs (5â50 endpoints)
- Team projects where clarity matters
- Projects that may need multiple transports (REST, gRPC, CLI)
- Codebases that evolve frequently
What I avoid
- Over-abstraction and generic repositories
- Moving interfaces into separate âinterfaceâ packages
- Deep layering that hides intent
Final note
Clean Architecture brings good ideas, but in Go the goal is pragmatic clarity. Prefer simple packages, interfaces where theyâre useful, and services that do the orchestration. Your code should be readable and testableâprefer that over purist architectures.
Have you tried different Go structures in your projects? What patterns worked or failed for your team? Iâd love to hear about your experiences.