Simplified Clean Architecture in Go

Simplified Clean Architecture in Go

One of the best things I've learned while building backend services in Golang is how Clean Architecture can make your codebase more modular, testable, and resilient to change — if done right.

Here's a simplified breakdown I wish I had when I started:

Handler Layer (Presentation)

This is the entry point of your application. It handles HTTP/gRPC/GraphQL requests, converts them to domain-friendly inputs, and forwards them to your service layer.

Think of it as a guard — it validates input, deals with framework-specific quirks (like Fiber, Echo, Gin, etc.), and keeps your domain logic untouched.

Example Handler

package handlers

import (
	"net/http"
	"encoding/json"
)

type UserHandler struct {
	userService UserService
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
	// 1. Parse and validate input
	var input CreateUserRequest
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
	 http.Error(w, "Invalid request", http.StatusBadRequest)
	 return
	}

	// 2. Call service layer
	user, err := h.userService.CreateUser(r.Context(), input.Email, input.Name)
	if err != nil {
	 http.Error(w, err.Error(), http.StatusInternalServerError)
	 return
	}

	// 3. Return response
	json.NewEncoder(w).Encode(user)
}

** Never put core logic here.** Otherwise, changing frameworks becomes a nightmare.

Service Layer (Application)

This is where the actual use cases live. It orchestrates logic, talks to repositories, and implements business processes.

This is your brain 🧠. It's okay to have a bit of technical logic here, but try to keep it minimal so business rules remain portable and clear.

Example Service

package services

import (
	"context"
	"errors"
	"strings"
)

type UserService struct {
	userRepo UserRepository
	emailService EmailService
}

func (s *UserService) CreateUser(ctx context.Context, email, name string) (*User, error) {
	// Business validation
	if !isValidEmail(email) {
	 return nil, errors.New("invalid email format")
	}

	// Check if user already exists
	existing, _ := s.userRepo.FindByEmail(ctx, email)
	if existing != nil {
	 return nil, errors.New("user already exists")
	}

	// Create user entity
	user := &User{
	 Email: strings.ToLower(email),
	 Name:  name,
	 Status: "active",
	}

	// Persist
	if err := s.userRepo.Create(ctx, user); err != nil {
	 return nil, err
	}

	// Send welcome email (async)
	go s.emailService.SendWelcome(user.Email)

	return user, nil
}

func isValidEmail(email string) bool {
	return strings.Contains(email, "@")
}

Repository Layer (Data Access)

This layer deals with persistence: databases, message queues, external APIs.

It's your adapter to the outside world. Keep it focused — just CRUD, no decision-making or calculations. That belongs in the service layer.

Example Repository

package repositories

import (
	"context"
	"database/sql"
)

type PostgresUserRepository struct {
	db *sql.DB
}

func (r *PostgresUserRepository) Create(ctx context.Context, user *User) error {
	query := `
	 INSERT INTO users (id, email, name, status, created_at)
	 VALUES ($1, $2, $3, $4, $5)
	`
	_, err := r.db.ExecContext(ctx, query,
	 user.ID,
	 user.Email,
	 user.Name,
	 user.Status,
	 user.CreatedAt,
	)
	return err
}

func (r *PostgresUserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
	query := `SELECT id, email, name, status, created_at FROM users WHERE email = $1`

	var user User
	err := r.db.QueryRowContext(ctx, query, email).Scan(
	 &user.ID,
	 &user.Email,
	 &user.Name,
	 &user.Status,
	 &user.CreatedAt,
	)

	if err == sql.ErrNoRows {
	 return nil, nil
	}

	return &user, err
}

Model Layer (Domain)

At the heart of it all lies the domain — pure, clean Go structs representing your business concepts.

No dependencies. Just business rules.

This layer knows nothing about HTTP, SQL, Redis, or JSON. And that's the point.

Example Domain Model

package domain

import (
	"time"
	"github.com/google/uuid"
)

type User struct {
	ID        string
	Email     string
	Name      string
	Status    string
	CreatedAt time.Time
}

func NewUser(email, name string) *User {
	return &User{
	 ID:        uuid.New().String(),
	 Email:     email,
	 Name:      name,
	 Status:    "active",
	 CreatedAt: time.Now(),
	}
}

// Business logic methods
func (u *User) Deactivate() {
	u.Status = "inactive"
}

func (u *User) IsActive() bool {
	return u.Status == "active"
}

Dependency Rule

Everything points inward:

Handlers → Services → Repositories → Models

Inner layers don't know about outer layers.

This means:

  • Models have zero imports from other layers
  • Services only import models
  • Repositories only import models
  • Handlers import everything

Adapter Roles

  • Handlers = Primary adapter (driving port) - they drive your application
  • Repositories = Secondary adapter (driven port) - they're driven by your application

Project Structure

Here's how I typically organize a Clean Architecture project:

project/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── domain/
│   │   └── user.go
│   ├── services/
│   │   └── user_service.go
│   ├── repositories/
│   │   ├── user_repository.go
│   │   └── postgres/
│   │       └── user_repo_impl.go
│   └── handlers/
│       └── http/
│           └── user_handler.go
├── pkg/
│   └── database/
│       └── postgres.go
└── go.mod

Wiring It All Together

package main

import (
	"database/sql"
	"log"
	"net/http"
)

func main() {
	// Infrastructure
	db, err := sql.Open("postgres", "connection_string")
	if err != nil {
	 log.Fatal(err)
	}

	// Repositories
	userRepo := repositories.NewPostgresUserRepository(db)

	// Services
	emailService := services.NewEmailService()
	userService := services.NewUserService(userRepo, emailService)

	// Handlers
	userHandler := handlers.NewUserHandler(userService)

	// Routes
	http.HandleFunc("/users", userHandler.CreateUser)

	log.Println("Server starting on :8080")
	http.ListenAndServe(":8080", nil)
}

Benefits of This Approach

Testability: Each layer can be tested independently with mocks ✅ Maintainability: Clear separation of concerns ✅ Flexibility: Easy to swap databases or frameworks ✅ Scalability: Add new features without touching existing code

Common Pitfalls to Avoid

Leaking abstractions: Don't let SQL errors bubble up to handlers ❌ Fat services: Keep services focused on orchestration, not data transformation ❌ Anemic models: Add behavior to your domain models, not just data ❌ Over-abstraction: Don't create interfaces for everything "just in case"

Conclusion

💡 Clean Architecture isn't about overengineering — it's about protecting your core business logic from tech churn. Whether you switch frameworks, protocols, or databases, your core stays clean.

If you're building with Go and want to future-proof your codebase, this pattern is worth mastering.

Testing Example

Here's how easy it becomes to test your service:

package services_test

import (
	"context"
	"testing"
)

type MockUserRepository struct {
	users map[string]*User
}

func (m *MockUserRepository) Create(ctx context.Context, user *User) error {
	m.users[user.Email] = user
	return nil
}

func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
	return m.users[email], nil
}

func TestCreateUser(t *testing.T) {
	// Arrange
	mockRepo := &MockUserRepository{users: make(map[string]*User)}
	mockEmail := &MockEmailService{}
	service := NewUserService(mockRepo, mockEmail)

	// Act
	user, err := service.CreateUser(context.Background(), "test@example.com", "Test User")

	// Assert
	if err != nil {
	 t.Fatalf("expected no error, got %v", err)
	}
	if user.Email != "test@example.com" {
	 t.Errorf("expected email test@example.com, got %s", user.Email)
	}
}