Before we dive in, I want to offer a caveat that there is no "right" way to structure an API. If you research various examples of APIs, you will find that everyone has their own approach to structuring their API. However, there is a consistent principle that the API is separated into distinct layers each handling a single concern. Where exactly those lines are drawn is up to you.
I typically structure my APIs into the following layers: routing, controllers, services, and repositories.
- The routing layer captures HTTP requests and dispatches them to the correct controller.
- The controllers layer handles HTTP transport concerns of request/response processing.
- The services layer encapsulates the application's core business logic.
- The repositories layer provides an interface with the database or external data sources.
Routing Layer
The routing layer is the entry point of the API. It deals with capturing HTTP requests and dispatching them to the correct controller. This layer maps endpoints (URI paths + HTTP methods) to controller methods that handle those requests.
This layer is kept thin and stateless. It should only determine which controller method to call without embedding any business logic or request processing.
Example:
router := httprouter.New()
router.HandlerFunc(http.MethodPost, "/v1/orders", app.createOrderHandler)
router.HandlerFunc(http.MethodGet, "/v1/orders/:id", app.showOrderHandler)
Controllers Layer
Controllers handle the HTTP-specific aspects of request processing. They are responsible for:
- Parsing the incoming request (path parameters, query strings, request body)
- Validating that the request information conforms to expected DTOs (Data Transfer Objects)
- Calling the appropriate service methods
- Forming and returning the HTTP response
Controllers act as the bridge between HTTP concerns and business logic, ensuring that only valid, well-formed requests reach the service layer.
Example:
type OrdersController interface {
Create(w http.ResponseWriter, r *http.Request)
}
type ordersControllerImpl struct {
orderService OrderService
}
func (c *ordersControllerImpl) Create(w http.ResponseWriter, r *http.Request) {
// Parse request body into DTO
var createOrderDTO CreateOrderDTO
if err := json.NewDecoder(r.Body).Decode(&createOrderDTO); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request format")
return
}
// Validate DTO
if err := c.validateCreateOrder(createOrderDTO); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
// Call service layer
order, err := c.orderService.CreateOrder(r.Context(), createOrderDTO)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to create order")
return
}
// Form response
writeJSON(w, http.StatusCreated, order)
}
Services Layer
Services contain the application's core business logic, independent of HTTP or database details. Services work with domain models and coordinate data operations, validations, and business workflows.
Services receive clean, validated data from controllers and return domain objects or business results. They contain the "what" and "why" of your application's operations.
Example (resource-oriented service):
type orderService interface {
CreateOrder(ctx context.Context, dto CreateOrderDTO) (*Order, error)
CancelOrder(ctx context.Context, orderID string) error
ShipOrder(ctx context.Context, orderID string) error
}
type orderServiceImpl struct {
orderRepo OrderRepository
}
func (s *orderServiceImpl) CreateOrder(ctx context.Context, dto CreateOrderDTO) (*Order, error) {
// Business validation
if dto.Quantity <= 0 {
return nil, fmt.Errorf("quantity must be greater than 0")
}
if dto.TotalAmount <= 0 {
return nil, fmt.Errorf("total amount must be greater than 0")
}
// Business logic
order := &Order{
CustomerID: dto.CustomerID,
Quantity: dto.Quantity,
TotalAmount: dto.TotalAmount,
Status: "pending",
CreatedAt: time.Now(),
}
// Persist through repository
orderID, err := s.orderRepo.Save(order)
if err != nil {
return nil, err
}
order.ID = orderID
return order, nil
}
Implementation notes
A common starting point is to group services around a single resource. For example, you might have an OrdersService that handles all logic
for the orders resource. This works fine when the resource is small and simple. But as a system grows, these resource-oriented services can
become bloated classes with too many responsibilities. At the same time, controllers often end up holding more logic than intended,
since fulfilling a request may require coordinating multiple services.1
An alternative is to create smaller, use-case-focused services that encapsulate the entire workflow for a specific action (e.g., CreateOrder,
CancelOrder, ShipOrder). This keeps controllers thin and makes each workflow easy to find, test, and evolve. Brandur Leach describes this
style as the "mediator pattern".1
Example (mediator service):
type CreateOrderMediator interface {
Run(ctx context.Context, dto CreateOrderDTO) (*Order, error)
}
type createOrderMediatorImpl struct {
OrdersRepo OrderRepository
Billing BillingGateway
}
func (uc *createOrderMediatorImpl) Run(ctx context.Context, dto CreateOrderDTO) (*Order, error) {
if dto.Quantity <= 0 {
return nil, fmt.Errorf("quantity must be greater than 0")
}
if dto.TotalAmount <= 0 {
return nil, fmt.Errorf("total amount must be greater than 0")
}
order := &Order{
CustomerID: dto.CustomerID,
Quantity: dto.Quantity,
TotalAmount: dto.TotalAmount,
Status: "pending",
CreatedAt: time.Now(),
}
if _, err := uc.OrdersRepo.Save(order); err != nil {
return nil, err
}
if err := uc.Billing.Authorize(order.ID, order.TotalAmount); err != nil {
return nil, err
}
return order, nil
}
Repositories Layer
Repositories provide an interface to the database or an external data sources. They encapsulate data access logic and provide a clean abstraction for the service layer to interact with persistent storage.
Repositories use interfaces for abstraction, allowing you to swap implementations (e.g., from SQL to NoSQL) without changing upper layers.
Example:
type OrderRepository interface {
Save(order *Order) (int, error)
FindByID(id int) (*Order, error)
FindByCustomerID(customerID int) ([]*Order, error)
Update(order *Order) error
Delete(id int) error
}
type SQLOrderRepository struct {
db *sql.DB
}
func (r *SQLOrderRepository) Save(order *Order) (int, error) {
query := `INSERT INTO orders (customer_id, quantity, total_amount, status, created_at)
VALUES (?, ?, ?, ?, ?)`
result, err := r.db.Exec(query, order.CustomerID, order.Quantity,
order.TotalAmount, order.Status, order.CreatedAt)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
return int(id), err
}