Skip to main content

Echo Code Organization

When building web applications with Echo, a proper code organization strategy is crucial. As your project grows, having a well-structured codebase makes it easier to maintain, collaborate with others, and scale your application. This guide will help you understand the best practices for organizing your Echo applications.

Introduction

Echo is a high-performance, minimalist Go web framework that provides a solid foundation for building web applications and APIs. However, Echo itself doesn't enforce any specific directory structure or code organization pattern. This flexibility is powerful but can lead to disorganized code if not approached carefully.

In this guide, we'll explore a common and effective way to organize Echo applications using a layered architecture approach, which separates concerns and makes your code more maintainable.

Basic Project Structure

Let's begin with a basic project structure that works well for medium to large-sized Echo applications:

myapp/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── handler/
│ ├── middleware/
│ ├── model/
│ ├── repository/
│ └── service/
├── pkg/
├── config/
├── scripts/
├── go.mod
└── go.sum

Understanding Each Component

  1. cmd/server/main.go: Entry point of your application where you initialize Echo and start the server
  2. internal/: Contains packages that are not meant to be imported by other applications
    • handler/: HTTP handlers that process requests and return responses
    • middleware/: Custom middleware functions
    • model/: Data structures and domain models
    • repository/: Data access layer for interacting with databases
    • service/: Business logic implementation
  3. pkg/: Shared packages that can be imported by other applications
  4. config/: Configuration files and configuration loading logic
  5. scripts/: Utility scripts for development, testing, or deployment

Implementing the Structure

Let's look at how to implement this structure with a simple user management system:

1. Entry Point (main.go)

go
// cmd/server/main.go
package main

import (
"log"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"

"myapp/internal/handler"
customMiddleware "myapp/internal/middleware"
"myapp/internal/repository"
"myapp/internal/service"
)

func main() {
// Initialize Echo
e := echo.New()

// Add middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(customMiddleware.Auth)

// Initialize repositories
userRepo := repository.NewUserRepository()

// Initialize services
userService := service.NewUserService(userRepo)

// Initialize handlers
userHandler := handler.NewUserHandler(userService)

// Register routes
e.GET("/users", userHandler.GetAll)
e.GET("/users/:id", userHandler.GetByID)
e.POST("/users", userHandler.Create)
e.PUT("/users/:id", userHandler.Update)
e.DELETE("/users/:id", userHandler.Delete)

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

2. Models

go
// internal/model/user.go
package model

type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"-"` // Don't include in JSON responses
}

3. Repositories

go
// internal/repository/user_repository.go
package repository

import (
"errors"
"myapp/internal/model"
)

type UserRepository interface {
FindAll() ([]model.User, error)
FindByID(id string) (model.User, error)
Create(user model.User) error
Update(user model.User) error
Delete(id string) error
}

type userRepository struct {
users map[string]model.User
}

func NewUserRepository() UserRepository {
return &userRepository{
users: make(map[string]model.User),
}
}

// Implementation of interface methods
func (r *userRepository) FindAll() ([]model.User, error) {
users := make([]model.User, 0, len(r.users))
for _, user := range r.users {
users = append(users, user)
}
return users, nil
}

func (r *userRepository) FindByID(id string) (model.User, error) {
user, exists := r.users[id]
if !exists {
return model.User{}, errors.New("user not found")
}
return user, nil
}

// Other methods implementation...

4. Services

go
// internal/service/user_service.go
package service

import (
"myapp/internal/model"
"myapp/internal/repository"
)

type UserService interface {
GetAllUsers() ([]model.User, error)
GetUserByID(id string) (model.User, error)
CreateUser(user model.User) error
UpdateUser(user model.User) error
DeleteUser(id string) error
}

type userService struct {
repo repository.UserRepository
}

func NewUserService(repo repository.UserRepository) UserService {
return &userService{
repo: repo,
}
}

// Implementation of business logic
func (s *userService) GetAllUsers() ([]model.User, error) {
return s.repo.FindAll()
}

func (s *userService) GetUserByID(id string) (model.User, error) {
return s.repo.FindByID(id)
}

// Other methods implementation...

5. Handlers

go
// internal/handler/user_handler.go
package handler

import (
"net/http"

"github.com/labstack/echo/v4"

"myapp/internal/model"
"myapp/internal/service"
)

type UserHandler struct {
userService service.UserService
}

func NewUserHandler(userService service.UserService) *UserHandler {
return &UserHandler{
userService: userService,
}
}

// Handler methods
func (h *UserHandler) GetAll(c echo.Context) error {
users, err := h.userService.GetAllUsers()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
})
}

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

func (h *UserHandler) GetByID(c echo.Context) error {
id := c.Param("id")

user, err := h.userService.GetUserByID(id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "User not found",
})
}

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

// Other handler methods...

6. Custom Middleware

go
// internal/middleware/auth.go
package middleware

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

func Auth(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// In a real application, you'd verify tokens, check permissions, etc.
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
// For demonstration purposes, we're allowing requests without auth
// In a real app, you might return an error for unauthenticated requests
}

return next(c)
}
}

Design Patterns

Dependency Injection

Notice how we're passing dependencies through constructors:

go
userRepo := repository.NewUserRepository()
userService := service.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService)

This pattern makes your code:

  • More testable (you can mock dependencies)
  • Decoupled and flexible
  • Easier to understand and maintain

Interface-Based Design

Define interfaces for your repositories and services:

go
type UserRepository interface {
FindAll() ([]model.User, error)
// Other methods...
}

This allows you to:

  • Mock dependencies in tests
  • Swap implementations (e.g., switch from memory storage to database)
  • Define clear contracts between layers

Real-world Example: Todo API

Let's demonstrate a more complete example of a todo API:

Model

go
// internal/model/todo.go
package model

import "time"

type Todo struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

Repository

go
// internal/repository/todo_repository.go
package repository

import (
"myapp/internal/model"
"time"
"errors"
"github.com/google/uuid"
)

type TodoRepository interface {
FindAll() ([]model.Todo, error)
FindByID(id string) (model.Todo, error)
Create(todo model.Todo) (model.Todo, error)
Update(todo model.Todo) (model.Todo, error)
Delete(id string) error
}

type memoryTodoRepository struct {
todos map[string]model.Todo
}

func NewTodoRepository() TodoRepository {
return &memoryTodoRepository{
todos: make(map[string]model.Todo),
}
}

func (r *memoryTodoRepository) FindAll() ([]model.Todo, error) {
todos := make([]model.Todo, 0, len(r.todos))
for _, todo := range r.todos {
todos = append(todos, todo)
}
return todos, nil
}

func (r *memoryTodoRepository) FindByID(id string) (model.Todo, error) {
todo, exists := r.todos[id]
if !exists {
return model.Todo{}, errors.New("todo not found")
}
return todo, nil
}

func (r *memoryTodoRepository) Create(todo model.Todo) (model.Todo, error) {
if todo.ID == "" {
todo.ID = uuid.New().String()
}
todo.CreatedAt = time.Now()
todo.UpdatedAt = time.Now()

r.todos[todo.ID] = todo
return todo, nil
}

func (r *memoryTodoRepository) Update(todo model.Todo) (model.Todo, error) {
existing, exists := r.todos[todo.ID]
if !exists {
return model.Todo{}, errors.New("todo not found")
}

todo.CreatedAt = existing.CreatedAt
todo.UpdatedAt = time.Now()

r.todos[todo.ID] = todo
return todo, nil
}

func (r *memoryTodoRepository) Delete(id string) error {
if _, exists := r.todos[id]; !exists {
return errors.New("todo not found")
}

delete(r.todos, id)
return nil
}

Service

go
// internal/service/todo_service.go
package service

import (
"myapp/internal/model"
"myapp/internal/repository"
)

type TodoService interface {
GetAllTodos() ([]model.Todo, error)
GetTodoByID(id string) (model.Todo, error)
CreateTodo(title string) (model.Todo, error)
UpdateTodo(id string, title string, completed bool) (model.Todo, error)
DeleteTodo(id string) error
}

type todoService struct {
repo repository.TodoRepository
}

func NewTodoService(repo repository.TodoRepository) TodoService {
return &todoService{
repo: repo,
}
}

func (s *todoService) GetAllTodos() ([]model.Todo, error) {
return s.repo.FindAll()
}

func (s *todoService) GetTodoByID(id string) (model.Todo, error) {
return s.repo.FindByID(id)
}

func (s *todoService) CreateTodo(title string) (model.Todo, error) {
todo := model.Todo{
Title: title,
Completed: false,
}
return s.repo.Create(todo)
}

func (s *todoService) UpdateTodo(id string, title string, completed bool) (model.Todo, error) {
todo, err := s.repo.FindByID(id)
if err != nil {
return model.Todo{}, err
}

todo.Title = title
todo.Completed = completed

return s.repo.Update(todo)
}

func (s *todoService) DeleteTodo(id string) error {
return s.repo.Delete(id)
}

Handler

go
// internal/handler/todo_handler.go
package handler

import (
"net/http"

"github.com/labstack/echo/v4"

"myapp/internal/service"
)

type TodoHandler struct {
todoService service.TodoService
}

func NewTodoHandler(todoService service.TodoService) *TodoHandler {
return &TodoHandler{
todoService: todoService,
}
}

func (h *TodoHandler) GetAll(c echo.Context) error {
todos, err := h.todoService.GetAllTodos()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
})
}

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

func (h *TodoHandler) GetByID(c echo.Context) error {
id := c.Param("id")

todo, err := h.todoService.GetTodoByID(id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "Todo not found",
})
}

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

func (h *TodoHandler) Create(c echo.Context) error {
type CreateRequest struct {
Title string `json:"title"`
}

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

if req.Title == "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Title is required",
})
}

todo, err := h.todoService.CreateTodo(req.Title)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
})
}

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

func (h *TodoHandler) Update(c echo.Context) error {
id := c.Param("id")

type UpdateRequest struct {
Title string `json:"title"`
Completed bool `json:"completed"`
}

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

todo, err := h.todoService.UpdateTodo(id, req.Title, req.Completed)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "Todo not found",
})
}

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

func (h *TodoHandler) Delete(c echo.Context) error {
id := c.Param("id")

err := h.todoService.DeleteTodo(id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "Todo not found",
})
}

return c.NoContent(http.StatusNoContent)
}

Main Application

go
// cmd/server/main.go
package main

import (
"log"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"

"myapp/internal/handler"
"myapp/internal/repository"
"myapp/internal/service"
)

func main() {
// Initialize Echo
e := echo.New()

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

// Initialize repositories
todoRepo := repository.NewTodoRepository()

// Initialize services
todoService := service.NewTodoService(todoRepo)

// Initialize handlers
todoHandler := handler.NewTodoHandler(todoService)

// Routes
e.GET("/todos", todoHandler.GetAll)
e.GET("/todos/:id", todoHandler.GetByID)
e.POST("/todos", todoHandler.Create)
e.PUT("/todos/:id", todoHandler.Update)
e.DELETE("/todos/:id", todoHandler.Delete)

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

Best Practices

  1. Separation of Concerns: Keep your code organized in layers (handlers, services, repositories).

  2. Use Interfaces: Define interfaces for repositories and services to make testing easier.

  3. Dependency Injection: Pass dependencies through constructors rather than using global variables.

  4. Error Handling: Standardize error handling across your application.

  5. Configuration: Store configuration separately from code.

  6. Middleware Organization: Keep custom middleware in its own package.

  7. Database Connection: Initialize database connections in the main function and pass them to repositories.

  8. Proper HTTP Status Codes: Use appropriate status codes in your handler responses.

  9. Validation: Validate input at the handler level before passing to the service layer.

  10. Logging: Implement consistent logging throughout your application.

Summary

Organizing your Echo application properly is essential for building maintainable and scalable web services. By following the layered architecture approach described in this guide, you can:

  • Separate concerns into different layers
  • Make your code more testable
  • Improve code readability and maintainability
  • Make it easier to collaborate with team members
  • Create a foundation that scales with your application's growth

Remember that the structure suggested here is a guideline. Feel free to adapt it to your specific project needs, but always strive for clarity and separation of concerns.

Additional Resources

Exercises

  1. Basic Structure: Create a new Echo project with the recommended directory structure.

  2. Todo API: Implement a simple Todo API using the layered architecture described in this guide.

  3. Database Integration: Modify the Todo API to use a real database (like PostgreSQL or MongoDB) instead of in-memory storage.

  4. Authentication: Add JWT-based authentication to your Todo API.

  5. Testing: Write unit tests for your services and repositories, and integration tests for your handlers.



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