Echo Service Containers
In modern web applications, organizing your business logic in a structured, maintainable way is crucial. Service containers provide a powerful pattern for managing dependencies and business logic in your Echo applications. This guide will introduce you to the concept of service containers and show you how to implement them effectively in your Echo projects.
What are Service Containers?
A service container is a design pattern that centralizes related functionality into cohesive units called "services." These services encapsulate specific business logic and can be injected into your HTTP handlers. This approach:
- Separates concerns between HTTP handling and business logic
- Makes testing easier by isolating components
- Promotes code reuse across your application
- Simplifies dependency management
Creating Your First Service Container
Let's start by creating a basic service container structure for an Echo application:
package main
import (
"github.com/labstack/echo/v4"
"net/http"
)
// UserService handles user-related business logic
type UserService struct {
// Dependencies would go here (like database connections)
db *Database // This would be your actual database connection
}
// NewUserService creates a new user service
func NewUserService(db *Database) *UserService {
return &UserService{
db: db,
}
}
// FindUserByID retrieves a user by their ID
func (s *UserService) FindUserByID(id string) (*User, error) {
// Business logic for finding a user
return s.db.GetUser(id)
}
// App represents our application and holds services
type App struct {
UserService *UserService
}
func main() {
// Initialize database (simplified)
db := NewDatabase()
// Initialize services
app := &App{
UserService: NewUserService(db),
}
// Create Echo instance
e := echo.New()
// Register handlers with services
e.GET("/users/:id", app.handleGetUser)
// Start server
e.Start(":8080")
}
// Handler that uses the service
func (app *App) handleGetUser(c echo.Context) error {
id := c.Param("id")
user, err := app.UserService.FindUserByID(id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{
"error": "User not found",
})
}
return c.JSON(http.StatusOK, user)
}
// Simplified types for the example
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Database struct {}
func NewDatabase() *Database {
return &Database{}
}
func (db *Database) GetUser(id string) (*User, error) {
// Imagine this connects to a database
return &User{ID: id, Name: "Jane Doe"}, nil
}
Key Components of the Service Container Pattern
1. Service Definitions
Services are typically defined as structs with methods that encapsulate business logic:
type ProductService struct {
db *Database
cache Cache
logger Logger
}
func (s *ProductService) GetFeaturedProducts() ([]Product, error) {
// Business logic here
}
func (s *ProductService) SearchProducts(query string) ([]Product, error) {
// Search logic here
}
2. Service Initialization
Services are initialized with their dependencies:
func NewProductService(db *Database, cache Cache, logger Logger) *ProductService {
return &ProductService{
db: db,
cache: cache,
logger: logger,
}
}
3. Application Container
The application container holds all your services:
type App struct {
UserService *UserService
ProductService *ProductService
OrderService *OrderService
// Other services...
}
4. Handler Methods
Your Echo handlers become methods on your application or controller struct that use the services:
func (app *App) handleGetFeaturedProducts(c echo.Context) error {
products, err := app.ProductService.GetFeaturedProducts()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to fetch products",
})
}
return c.JSON(http.StatusOK, products)
}
Advanced Service Container Structure
For larger applications, you might want a more scalable structure:
package main
import (
"github.com/labstack/echo/v4"
)
// ServiceContainer holds all application services
type ServiceContainer struct {
UserService *UserService
ProductService *ProductService
OrderService *OrderService
// Add more services as needed
}
// NewServiceContainer creates and configures all application services
func NewServiceContainer(config *Config) *ServiceContainer {
// Initialize shared dependencies
db := initDatabase(config.DatabaseURL)
cache := initCache(config.CacheURL)
logger := initLogger(config.LogLevel)
// Create container with all services
return &ServiceContainer{
UserService: NewUserService(db, cache, logger),
ProductService: NewProductService(db, cache, logger),
OrderService: NewOrderService(db, cache, logger),
}
}
// SetupRoutes configures all application routes
func (sc *ServiceContainer) SetupRoutes(e *echo.Echo) {
// User routes
e.GET("/users/:id", sc.UserService.GetUser)
e.POST("/users", sc.UserService.CreateUser)
// Product routes
e.GET("/products", sc.ProductService.ListProducts)
e.GET("/products/:id", sc.ProductService.GetProduct)
// Order routes
e.POST("/orders", sc.OrderService.PlaceOrder)
e.GET("/orders/:id", sc.OrderService.GetOrder)
}
func main() {
// Load configuration
config := loadConfig()
// Create service container
services := NewServiceContainer(config)
// Create and configure Echo
e := echo.New()
// Setup all routes
services.SetupRoutes(e)
// Start server
e.Start(":8080")
}
Practical Example: Todo Application with Services
Let's create a more complete example of a Todo application using the service pattern:
package main
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"net/http"
"strconv"
)
// Todo represents a todo item
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
// TodoRepository handles todo data persistence
type TodoRepository struct {
// In a real app, this would be a database connection
todos map[int]Todo
nextID int
}
// NewTodoRepository creates a new repository
func NewTodoRepository() *TodoRepository {
return &TodoRepository{
todos: make(map[int]Todo),
nextID: 1,
}
}
// FindAll returns all todos
func (r *TodoRepository) FindAll() []Todo {
todos := make([]Todo, 0, len(r.todos))
for _, todo := range r.todos {
todos = append(todos, todo)
}
return todos
}
// FindByID finds a todo by ID
func (r *TodoRepository) FindByID(id int) (Todo, bool) {
todo, found := r.todos[id]
return todo, found
}
// Save creates or updates a todo
func (r *TodoRepository) Save(todo *Todo) {
if todo.ID == 0 {
todo.ID = r.nextID
r.nextID++
}
r.todos[todo.ID] = *todo
}
// Delete removes a todo
func (r *TodoRepository) Delete(id int) bool {
if _, found := r.todos[id]; !found {
return false
}
delete(r.todos, id)
return true
}
// TodoService handles todo business logic
type TodoService struct {
repo *TodoRepository
}
// NewTodoService creates a new todo service
func NewTodoService(repo *TodoRepository) *TodoService {
return &TodoService{repo: repo}
}
// GetAllTodos returns all todos
func (s *TodoService) GetAllTodos() []Todo {
return s.repo.FindAll()
}
// GetTodo finds a todo by ID
func (s *TodoService) GetTodo(id int) (Todo, bool) {
return s.repo.FindByID(id)
}
// CreateTodo creates a new todo
func (s *TodoService) CreateTodo(title string) Todo {
todo := Todo{
Title: title,
Completed: false,
}
s.repo.Save(&todo)
return todo
}
// UpdateTodo updates a todo
func (s *TodoService) UpdateTodo(id int, title string, completed bool) (Todo, bool) {
todo, found := s.repo.FindByID(id)
if !found {
return Todo{}, false
}
todo.Title = title
todo.Completed = completed
s.repo.Save(&todo)
return todo, true
}
// DeleteTodo deletes a todo
func (s *TodoService) DeleteTodo(id int) bool {
return s.repo.Delete(id)
}
// TodoHandler handles todo HTTP routes
type TodoHandler struct {
service *TodoService
}
// NewTodoHandler creates a new todo handler
func NewTodoHandler(service *TodoService) *TodoHandler {
return &TodoHandler{service: service}
}
// GetAllTodos handles GET /todos
func (h *TodoHandler) GetAllTodos(c echo.Context) error {
return c.JSON(http.StatusOK, h.service.GetAllTodos())
}
// GetTodo handles GET /todos/:id
func (h *TodoHandler) GetTodo(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid ID"})
}
todo, found := h.service.GetTodo(id)
if !found {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Todo not found"})
}
return c.JSON(http.StatusOK, todo)
}
// CreateTodo handles POST /todos
func (h *TodoHandler) CreateTodo(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 := h.service.CreateTodo(req.Title)
return c.JSON(http.StatusCreated, todo)
}
// UpdateTodo handles PUT /todos/:id
func (h *TodoHandler) UpdateTodo(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid 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, found := h.service.UpdateTodo(id, req.Title, req.Completed)
if !found {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Todo not found"})
}
return c.JSON(http.StatusOK, todo)
}
// DeleteTodo handles DELETE /todos/:id
func (h *TodoHandler) DeleteTodo(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid ID"})
}
if deleted := h.service.DeleteTodo(id); !deleted {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Todo not found"})
}
return c.NoContent(http.StatusNoContent)
}
func main() {
// Initialize dependencies
repo := NewTodoRepository()
service := NewTodoService(repo)
handler := NewTodoHandler(service)
// Create Echo instance
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Setup routes
e.GET("/todos", handler.GetAllTodos)
e.GET("/todos/:id", handler.GetTodo)
e.POST("/todos", handler.CreateTodo)
e.PUT("/todos/:id", handler.UpdateTodo)
e.DELETE("/todos/:id", handler.DeleteTodo)
// Start server
e.Logger.Fatal(e.Start(":8080"))
}
Testing Services and Handlers
One of the major benefits of the service container pattern is improved testability. Here's an example of how to test our Todo service:
package main
import (
"testing"
)
func TestTodoService_CreateTodo(t *testing.T) {
// Create a repository with test data
repo := NewTodoRepository()
// Create the service with our test repository
service := NewTodoService(repo)
// Test creating a todo
todo := service.CreateTodo("Buy groceries")
// Assert the todo was created correctly
if todo.ID <= 0 {
t.Errorf("Expected ID > 0, got %d", todo.ID)
}
if todo.Title != "Buy groceries" {
t.Errorf("Expected title 'Buy groceries', got '%s'", todo.Title)
}
if todo.Completed {
t.Error("Expected completed to be false")
}
// Verify it was saved in the repository
todos := repo.FindAll()
if len(todos) != 1 {
t.Errorf("Expected 1 todo in repository, got %d", len(todos))
}
}
func TestTodoService_DeleteTodo(t *testing.T) {
// Create a repository with test data
repo := NewTodoRepository()
todo := Todo{ID: 1, Title: "Test todo", Completed: false}
repo.Save(&todo)
// Create the service with our test repository
service := NewTodoService(repo)
// Test deleting the todo
deleted := service.DeleteTodo(1)
// Assert it was deleted
if !deleted {
t.Error("Expected DeleteTodo to return true")
}
// Verify it's gone from the repository
todos := repo.FindAll()
if len(todos) != 0 {
t.Errorf("Expected 0 todos in repository, got %d", len(todos))
}
}
Benefits of Service Containers in Echo
- Separation of Concerns: Your code is organized by functionality, with HTTP handling separate from business logic
- Testability: Services can be tested independently from HTTP layer
- Code Reuse: Services can be used by multiple handlers or even other services
- Maintainability: Easier to understand and modify each piece of functionality
- Dependency Management: Clear structure for managing dependencies between components
Common Service Types in Web Applications
Typically, a web application might include these service types:
- Repository Services: Database access and data manipulation
- Domain Services: Core business logic
- Integration Services: Communication with external APIs or services
- Authentication/Authorization Services: User identity and access control
- Validation Services: Input validation and sanitization
- Notification Services: Email, SMS, push notifications, etc.
Best Practices
- Keep Services Focused: Each service should have a single responsibility
- Use Interfaces: Define interfaces for services to enable mocking in tests
- Avoid Circular Dependencies: Structure your services to avoid circular dependencies
- Consider Context Propagation: Pass context.Context through your service methods for timeouts and cancellation
- Use Dependency Injection: Pass dependencies to services rather than creating them internally
- Standardize Error Handling: Create consistent error types and handling patterns
Summary
Service containers provide a powerful pattern for structuring your Echo applications. By organizing your business logic into cohesive services and injecting them into your handlers, you can create applications that are easier to understand, test, and maintain.
This pattern is especially valuable as your applications grow in complexity, allowing you to manage that complexity through clear separation of concerns and dependency management.
Additional Resources
Exercises
- Convert an existing Echo handler into the service container pattern
- Create a new service that depends on multiple other services
- Write unit tests for a service without using the actual HTTP layer
- Implement a service that uses an external API
- Create a middleware that adds service information to request context
With the service container pattern in your Echo toolkit, you'll be able to build more maintainable, testable, and structured web applications in Go!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)