Best Practices for Structuring Scalable Golang APIs with Echo

Otto Thornton-Silver
5 min read6 days ago

--

A high-level best practice guide to structuring application architecture when designing an API using Golang’s Echo framework.

This article provides a high-level guide to structuring an Echo API using best practices. It emphasises fundamental design principles such as Separation of Concerns, alongside layered architecture, offering patterns to simplify design and reduce cognitive load. The approach aims to enhance consistency, maintainability, testability, and clearly separate responsibilities within the application.

Table of Contents

  • Handlers
  • Core Services
  • Complex Foreign APIs
  • Simple Foreign APIs
  • DB Facades
  • Transforming Data with Adapters
This structural diagram outlines how various layers of a server interact with one another, and with third party entities.

Handlers

  • Echo Handlers should only be responsible for binding a request to a struct and calling a service method.
  • This maintains SRP and has many benefits. For example, it mitigates the need to arrange complex echo request contexts when unit-testing business logic.
  • If done correctly, they should not necessarily require unit testing as their responsibilities will be extremely limited.

Handler Code Example:

================================================================================
// Example of Good Handler

api.POST("/good_handler", goodHandler)

func goodHandler(c echo.Context) error {
var req EndpointRequestStruct
err := c.Bind(&req)
if err != nil {
return c.String(http.StatusBadRequest, err.Error())
}

ctx := c.Request().Context()
foreignID := c.Param("id")

// service is intialized in main and accessible within the package
// service is also passed the foreign client
// so it can make the http requests it needs.
err = service.ServiceHandler(ctx, foreignID, &req)
return handleError(c, err)
}

===============================================================================
// Example of Bad Handler

api.POST("/bad_handler", func(c echo.Context) error {
// Wrapping a handler is an anti-pattern is generally used as
// a way of injecting dependencies to make testing easier
// when an endpoint has too much responsibility
return badHandler(c, service, foreign)
})

// This handler does not require dependency injection
// if responsibility is properly separated between service + handler
func badHandler(c echo.Context, service Service, foreign Foreign) error {
var req EndpointRequestStruct
err := c.Bind(&req)
if err != nil {
return c.String(http.StatusBadRequest, err.Error())
}

ctx := c.Request().Context()
foreignID := c.Param("id")

// Do not make this request here, do it within the service.
// Violation of SRP
foreignEntity, err := foreign.Entity(ctx, foreignID)
if err != nil {
...
return c.JSON(http.StatusBadRequest, nil)
}

err = service.ServiceHandler(c.Request().Context(), foreignEntity , &req)
return handleError(c, err)
}

Core Services

  • Core Services should be initialized in the main package and accessible in the endpoint handlers, also defined in the main package.
  • Initializing services within the main will mitigate any need to wrap an echo handler in another function that injects the service into the handler, as demonstrated in the handler code example above.
  • You should avoid passing services via context, as this requires type asserting them whenever they are used.

Initialization Example:

package main

import (
foreign "github.com/my_company/foreign-repo/client"

"github.com/my_company/repo/service"
)


================================================================================
// Example of Good Initialisation

foreign := foreign.New(...)
// Service accepts foreign client to facilitate downstream use without
// passing the service as an arg
service := service.New(foreign)

================================================================================
// Example of Bad Initialization

// We should avoid custom middleware to inject services into the context
func (s *service) injectClients() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("foreignClient", s.foreignClient)
}
}
}

// Setting services/clients on the context requires the consumers
// of those services or clients to assert the type which can lead to panics.
// It also makes mocking less intuitive, as context arrangement is required.

s.echo.Use(s.injectClients())
  • Service Methods should be the orchestrators of all business logic tied to the workflow they are responsible for. Boundaries between Core Services should be relative to the internally defined entities and/or flows that they are responsible for. This will be application specific, but modularity and separation of concerns should be considered the most important factor in deciding how to split responsibilities!
  • Core Services should operate on internally defined entities. Transformation should be the responsibility of the abstraction layer that is called by the core service. [See Note on Transformation Below]
  • Core Services should be extensively unit tested as it should be easy to mock all dependencies and arrange arguments if you adhere to the application architecture outlined in this article.
  • Note: If any part of the service layer becomes complex, you might want to further decompose it into smaller services or introduce additional abstraction layers.

Complex Foreign APIs — (Facade Pattern)

  • Complex API interactions require abstraction layers to simplify application architecture and maintain Single-Responsibility Principles. If you are interacting with a service that requires complex interactions in a specific order, with multiple possible returns, you should abstract this logic.
  • Abstraction Layers should be responsible for the following:
    - Transforming internal entities into Foreign API Request Bodies.
    -
    Making calls in series and handling domain-specific errors.
    - Returning internally defined errors that a service can easily handle.
  • When done correctly code will be more readable, and easier to test.

Simple Foreign API Interactions

  • Simple Foreign API Interactions can occur directly via the foreign service’s HTTP Client or an abstraction layer, depending on how heavily the service is utilized.
  • Writing an abstraction layer is likely over-engineered if you are making a single call to a service requiring little transformation. However, adding an abstraction layer is probably wise if you interact heavily with several endpoints.

DB Abstraction Layer (Facade Pattern)

  • DB Interactions should also be abstracted for similar reasons.
  • DB abstraction layers should be responsible for:
    - Transforming structs to models
    - Handling Sessions
    - Executing Queries
  • Again, this makes mocking and unit testing much easier, as the concepts and processes you must be concerned with are significantly limited within your core services.

Transformations (Adapter Pattern)

  • Transformations should ideally be performed using the adapter pattern. Complex foreign service facades will often require the addition of an adapter, which can then be used to transform internal data structures into Request bodies or other objects used for making calls to third parties.
  • The Core Services may also require adapters. When interacting with a simple foreign service that makes a single call but still requires a transformation of request and response bodies, the core service should utilize an adapter to transform these data structures, therefore maintaining SRP but not misusing the facade concept.

--

--

Otto Thornton-Silver

Senior Backend Software Engineer Based in San Francisco. Currently working in the ticketing space, experience in Python, Golang and Javascript.