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
- cmd/server/main.go: Entry point of your application where you initialize Echo and start the server
- 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
- pkg/: Shared packages that can be imported by other applications
- config/: Configuration files and configuration loading logic
- 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)
// 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
// 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
// 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
// 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
// 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
// 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:
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:
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
// 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
// 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
// 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
// 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
// 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
-
Separation of Concerns: Keep your code organized in layers (handlers, services, repositories).
-
Use Interfaces: Define interfaces for repositories and services to make testing easier.
-
Dependency Injection: Pass dependencies through constructors rather than using global variables.
-
Error Handling: Standardize error handling across your application.
-
Configuration: Store configuration separately from code.
-
Middleware Organization: Keep custom middleware in its own package.
-
Database Connection: Initialize database connections in the main function and pass them to repositories.
-
Proper HTTP Status Codes: Use appropriate status codes in your handler responses.
-
Validation: Validate input at the handler level before passing to the service layer.
-
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
- Echo Framework Documentation
- Standard Go Project Layout
- Clean Architecture by Robert C. Martin
- Effective Go
Exercises
-
Basic Structure: Create a new Echo project with the recommended directory structure.
-
Todo API: Implement a simple Todo API using the layered architecture described in this guide.
-
Database Integration: Modify the Todo API to use a real database (like PostgreSQL or MongoDB) instead of in-memory storage.
-
Authentication: Add JWT-based authentication to your Todo API.
-
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! :)