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)
}
}