Skip to main content

Echo Clean Architecture

In this guide, we'll explore how to implement clean architecture principles in your Echo applications. Clean Architecture helps create maintainable, testable, and scalable web applications by enforcing a clear separation of concerns.

Introduction to Clean Architecture

Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a software design philosophy that separates the concerns of an application into distinct layers. Each layer has a specific responsibility and depends only on inner layers, not outer ones.

The primary goals of Clean Architecture are:

  1. Independence from frameworks - The architecture doesn't depend on the existence of some library or framework
  2. Testability - Business rules can be tested without UI, database, web server, or any external element
  3. Independence from UI - The UI can change easily, without changing the rest of the system
  4. Independence from database - Your business rules aren't bound to a specific database
  5. Independence from any external agency - Business rules don't know anything about outside interfaces

Clean Architecture Layers

A typical Clean Architecture implementation includes these layers:

  1. Entities - Enterprise business rules and data structures
  2. Use Cases - Application-specific business rules
  3. Interface Adapters - Converts data between use cases, entities, and external agencies
  4. Frameworks & Drivers - External frameworks and tools like Echo, databases, etc.

Implementing Clean Architecture in Echo

Let's implement a simple blog API using Echo with Clean Architecture principles.

Project Structure

/blog-api
/cmd
/api
main.go
/internal
/domain
post.go
user.go
/usecase
post_usecase.go
user_usecase.go
/repository
post_repository.go
user_repository.go
/delivery
/http
/handler
post_handler.go
user_handler.go
route.go
/infrastructure
/persistence
/mysql
mysql.go
post_repository.go
user_repository.go
go.mod
go.sum

Domain Layer

Let's start with the domain layer, which contains our core business entities:

go
// internal/domain/post.go
package domain

import "time"

type Post struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
AuthorID uint `json:"author_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type PostRepository interface {
Fetch(limit, offset int) ([]Post, error)
GetByID(id uint) (Post, error)
Create(post *Post) error
Update(post *Post) error
Delete(id uint) error
}
go
// internal/domain/user.go
package domain

import "time"

type User struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"-"` // Not exposed in JSON
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type UserRepository interface {
GetByID(id uint) (User, error)
GetByEmail(email string) (User, error)
Create(user *User) error
Update(user *User) error
Delete(id uint) error
}

Use Case Layer

The use case layer contains the application business rules:

go
// internal/usecase/post_usecase.go
package usecase

import (
"time"

"github.com/yourusername/blog-api/internal/domain"
)

type postUsecase struct {
postRepo domain.PostRepository
}

func NewPostUsecase(pr domain.PostRepository) *postUsecase {
return &postUsecase{
postRepo: pr,
}
}

func (pu *postUsecase) GetPosts(limit, offset int) ([]domain.Post, error) {
return pu.postRepo.Fetch(limit, offset)
}

func (pu *postUsecase) GetPostByID(id uint) (domain.Post, error) {
return pu.postRepo.GetByID(id)
}

func (pu *postUsecase) CreatePost(post *domain.Post) error {
post.CreatedAt = time.Now()
post.UpdatedAt = time.Now()
return pu.postRepo.Create(post)
}

func (pu *postUsecase) UpdatePost(post *domain.Post) error {
post.UpdatedAt = time.Now()
return pu.postRepo.Update(post)
}

func (pu *postUsecase) DeletePost(id uint) error {
return pu.postRepo.Delete(id)
}

type PostUsecase interface {
GetPosts(limit, offset int) ([]domain.Post, error)
GetPostByID(id uint) (domain.Post, error)
CreatePost(post *domain.Post) error
UpdatePost(post *domain.Post) error
DeletePost(id uint) error
}

Repository Implementation

Now, let's create a MySQL implementation of our repository:

go
// internal/infrastructure/persistence/mysql/post_repository.go
package mysql

import (
"database/sql"
"errors"

"github.com/yourusername/blog-api/internal/domain"
)

type mysqlPostRepository struct {
db *sql.DB
}

func NewMysqlPostRepository(db *sql.DB) domain.PostRepository {
return &mysqlPostRepository{
db: db,
}
}

func (m *mysqlPostRepository) Fetch(limit, offset int) ([]domain.Post, error) {
query := `SELECT id, title, content, author_id, created_at, updated_at
FROM posts LIMIT ? OFFSET ?`

rows, err := m.db.Query(query, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()

var posts []domain.Post
for rows.Next() {
var p domain.Post
err = rows.Scan(
&p.ID,
&p.Title,
&p.Content,
&p.AuthorID,
&p.CreatedAt,
&p.UpdatedAt,
)
if err != nil {
return nil, err
}
posts = append(posts, p)
}

return posts, nil
}

func (m *mysqlPostRepository) GetByID(id uint) (domain.Post, error) {
query := `SELECT id, title, content, author_id, created_at, updated_at
FROM posts WHERE id = ?`

var post domain.Post
err := m.db.QueryRow(query, id).Scan(
&post.ID,
&post.Title,
&post.Content,
&post.AuthorID,
&post.CreatedAt,
&post.UpdatedAt,
)

if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Post{}, errors.New("post not found")
}
return domain.Post{}, err
}

return post, nil
}

// Implementations for Create, Update, and Delete methods...

HTTP Handler Layer

Now, let's create our Echo HTTP handlers:

go
// internal/delivery/http/handler/post_handler.go
package handler

import (
"net/http"
"strconv"

"github.com/labstack/echo/v4"
"github.com/yourusername/blog-api/internal/domain"
"github.com/yourusername/blog-api/internal/usecase"
)

type PostHandler struct {
PUsecase usecase.PostUsecase
}

func NewPostHandler(e *echo.Echo, pu usecase.PostUsecase) {
handler := &PostHandler{
PUsecase: pu,
}

// Register routes
e.GET("/posts", handler.FetchPosts)
e.GET("/posts/:id", handler.GetPost)
e.POST("/posts", handler.CreatePost)
e.PUT("/posts/:id", handler.UpdatePost)
e.DELETE("/posts/:id", handler.DeletePost)
}

func (h *PostHandler) FetchPosts(c echo.Context) error {
limitStr := c.QueryParam("limit")
offsetStr := c.QueryParam("offset")

limit, _ := strconv.Atoi(limitStr)
if limit == 0 {
limit = 10
}

offset, _ := strconv.Atoi(offsetStr)

posts, err := h.PUsecase.GetPosts(limit, offset)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
})
}

return c.JSON(http.StatusOK, posts)
}

func (h *PostHandler) GetPost(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid ID",
})
}

post, err := h.PUsecase.GetPostByID(uint(id))
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": err.Error(),
})
}

return c.JSON(http.StatusOK, post)
}

func (h *PostHandler) CreatePost(c echo.Context) error {
post := new(domain.Post)
if err := c.Bind(post); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
}

if err := h.PUsecase.CreatePost(post); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
})
}

return c.JSON(http.StatusCreated, post)
}

// Implementations for UpdatePost and DeletePost...

Wiring it All Together

Finally, let's set up the main application:

go
// cmd/api/main.go
package main

import (
"database/sql"
"log"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
_ "github.com/go-sql-driver/mysql"

"github.com/yourusername/blog-api/internal/delivery/http/handler"
"github.com/yourusername/blog-api/internal/infrastructure/persistence/mysql"
"github.com/yourusername/blog-api/internal/usecase"
)

func main() {
// Initialize database connection
db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/blog_db?parseTime=true")
if err != nil {
log.Fatal(err)
}
defer db.Close()

// Check database connection
if err := db.Ping(); err != nil {
log.Fatal(err)
}

// Initialize Echo
e := echo.New()

// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.CORS())

// Initialize repositories
postRepo := mysql.NewMysqlPostRepository(db)

// Initialize use cases
postUsecase := usecase.NewPostUsecase(postRepo)

// Initialize handlers
handler.NewPostHandler(e, postUsecase)

// Start server
e.Logger.Fatal(e.Start(":8080"))
}

Benefits of Using Clean Architecture

Testability

With Clean Architecture, testing becomes much easier because components are decoupled:

go
// Example of a use case test
func TestGetPost(t *testing.T) {
// Create a mock repository
mockRepo := new(mocks.MockPostRepository)

// Set expectations
expectedPost := domain.Post{
ID: 1,
Title: "Test Post",
Content: "This is a test",
AuthorID: 1,
}

mockRepo.On("GetByID", uint(1)).Return(expectedPost, nil)

// Initialize the use case with the mock
useCase := usecase.NewPostUsecase(mockRepo)

// Call the method
post, err := useCase.GetPostByID(1)

// Assertions
assert.NoError(t, err)
assert.Equal(t, expectedPost.ID, post.ID)
assert.Equal(t, expectedPost.Title, post.Title)

// Verify expectations were met
mockRepo.AssertExpectations(t)
}

Maintainability

When requirements change, you can update specific layers without affecting others. For example, changing from MySQL to PostgreSQL would only require changes in the repository layer.

Scalability

As your application grows, Clean Architecture helps maintain code quality by enforcing boundaries between components.

Real-World Example: User Authentication

Let's implement a simple user authentication system using our Clean Architecture:

  1. Domain layer - Define the User entity and UserRepository interface (already done)

  2. Use case layer - Implement authentication logic:

go
// internal/usecase/auth_usecase.go
package usecase

import (
"errors"
"time"

"github.com/dgrijalva/jwt-go"
"golang.org/x/crypto/bcrypt"

"github.com/yourusername/blog-api/internal/domain"
)

type authUsecase struct {
userRepo domain.UserRepository
jwtSecret string
}

type AuthUsecase interface {
Login(email, password string) (string, error)
Register(user *domain.User) error
}

func NewAuthUsecase(ur domain.UserRepository, jwtSecret string) AuthUsecase {
return &authUsecase{
userRepo: ur,
jwtSecret: jwtSecret,
}
}

func (au *authUsecase) Login(email, password string) (string, error) {
// Get user by email
user, err := au.userRepo.GetByEmail(email)
if err != nil {
return "", errors.New("invalid email or password")
}

// Check password
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
if err != nil {
return "", errors.New("invalid email or password")
}

// Generate JWT token
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
claims["user_id"] = user.ID
claims["exp"] = time.Now().Add(time.Hour * 72).Unix()

// Sign the token
tokenString, err := token.SignedString([]byte(au.jwtSecret))
if err != nil {
return "", err
}

return tokenString, nil
}

func (au *authUsecase) Register(user *domain.User) error {
// Check if user already exists
_, err := au.userRepo.GetByEmail(user.Email)
if err == nil {
return errors.New("email already registered")
}

// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
user.Password = string(hashedPassword)

// Set timestamps
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()

// Save user
return au.userRepo.Create(user)
}
  1. HTTP handler layer - Create authentication handlers:
go
// internal/delivery/http/handler/auth_handler.go
package handler

import (
"net/http"

"github.com/labstack/echo/v4"

"github.com/yourusername/blog-api/internal/domain"
"github.com/yourusername/blog-api/internal/usecase"
)

type AuthHandler struct {
AUsecase usecase.AuthUsecase
}

func NewAuthHandler(e *echo.Echo, au usecase.AuthUsecase) {
handler := &AuthHandler{
AUsecase: au,
}

// Register routes
e.POST("/login", handler.Login)
e.POST("/register", handler.Register)
}

func (h *AuthHandler) Login(c echo.Context) error {
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}

req := new(LoginRequest)
if err := c.Bind(req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request",
})
}

token, err := h.AUsecase.Login(req.Email, req.Password)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": err.Error(),
})
}

return c.JSON(http.StatusOK, map[string]string{
"token": token,
})
}

func (h *AuthHandler) Register(c echo.Context) error {
user := new(domain.User)
if err := c.Bind(user); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request",
})
}

if err := h.AUsecase.Register(user); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
}

// Don't return the user object with password
return c.JSON(http.StatusCreated, map[string]interface{}{
"id": user.ID,
"username": user.Username,
"email": user.Email,
})
}
  1. Add auth middleware with Echo:
go
// internal/delivery/http/middleware/jwt_middleware.go
package middleware

import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func JWTMiddleware(secret string) echo.MiddlewareFunc {
config := middleware.JWTConfig{
SigningKey: []byte(secret),
TokenLookup: "header:Authorization",
AuthScheme: "Bearer",
}
return middleware.JWTWithConfig(config)
}

Summary

In this guide, we've explored how to implement Clean Architecture in an Echo application:

  1. Domain Layer - Core business entities and repository interfaces
  2. Use Case Layer - Application-specific business rules
  3. Repository Implementation - Interface adapters for databases
  4. HTTP Handler Layer - Echo handlers that deliver our API

By following Clean Architecture principles, we create applications that are:

  • Maintainable - Easy to change and adapt over time
  • Testable - Business logic can be tested independently
  • Flexible - External dependencies can be replaced without affecting the core logic

Remember that Clean Architecture is a guideline, not a strict set of rules. Adapt it to your specific project needs and team preferences.

Additional Resources

Exercises

  1. Basic Implementation: Create a simple Todo API using Clean Architecture principles with Echo.

  2. Database Switch: Take the blog API from this guide and modify it to work with both MySQL and PostgreSQL by implementing a second repository.

  3. Authentication Extension: Extend the authentication system to include token refresh and user roles/permissions.

  4. Testing Practice: Write comprehensive tests for the use case layer of your application.

  5. Advanced Challenge: Implement a caching layer in the Clean Architecture pattern. Where would it fit best?



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)