Building a Modular and Testable n-Tier Architecture in GoLang Using the Consumer Interface Pattern

Building a Modular and Testable n-Tier Architecture in GoLang Using the Consumer Interface Pattern

In this blog post, we'll explore an n-tier architecture implemented in GoLang with a consumer interface pattern that promotes modularity, decoupling, and unit testability. This approach is particularly useful for projects requiring clean separation of concerns and a scalable structure.


What Is n-Tier Architecture?

An n-tier architecture splits an application into multiple logical layers, each responsible for a specific function. Typical tiers include:

  1. Presentation Layer (HTTP Server): Handles HTTP requests and responses, serving as the entry point for the application.

  2. Application Layer (Service/Use Case): Contains business logic and coordinates operations.

  3. Data Access Layer (Repository): Interfaces with the database to manage data persistence.

To tie everything together, we use a Dependency Injection Layer to wire the components and make them available to the router.


The Consumer Interface Pattern

The consumer interface pattern involves defining contracts (interfaces) between layers. Each layer implements the interface of the layer it depends on, ensuring:

  • Loose Coupling: Layers interact through defined contracts, not concrete implementations.

  • Unit Testability: Interfaces can be mocked for testing.

  • Extensibility: New implementations can be swapped without impacting other layers.


Folder Structure

Our project is organized into the following directories:

├── app
│   └── usecases        # Business logic (service layer)
├── config              # Configuration (e.g., database setup)
├── dependencyinjector  # Dependency injection (wiring services and repositories)
├── domain
│   ├── dao             # Data Access Objects
│   └── dto             # Data Transfer Objects
├── httpServer
│   ├── handler         # Controllers (presentation layer)
│   └── routers         # Routing logic
├── infra
│   └── mysqlRepo       # Data access layer (repository)
└── main.go             # Application entry point

Implementation

Find a git repo url: https://github.com/Aekshant/go-crud-base-template

1. HTTP Server (Controller Layer)

The controller layer defines a UserService consumer interface that interacts with the service layer:

package handler

import (
    "goGinTemplate/app/common"
    dto "goGinTemplate/domain/dto"
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
)

type UserController interface {
    GetAllUserData(c *gin.Context)
}
//consumer interface struct to be implement in services
type UserService interface {
    GetAllUser() ([]dto.GetUserDto, error)
}

type UserControllerImpl struct {
    service UserService
}

func UserControllerInit(userService UserService) *UserControllerImpl {
    return &UserControllerImpl{service: userService}
}

func (ctrl *UserControllerImpl) GetAllUserData(c *gin.Context) {
    data, err := ctrl.service.GetAllUser()
    if err != nil {
        c.JSON(http.StatusInternalServerError, common.ServiceRepo[[]dto.GetUserDto]{
            Data:    nil,
            Err:     err,
            Success: false,
        })
        return
    }
    log.Println("Successfully fetched users.")
    c.JSON(http.StatusOK, common.ServiceRepo[[]dto.GetUserDto]{
        Data:    data,
        Err:     nil,
        Success: true,
    })
}

2. Application Layer (Service Layer)

The service layer defines a UserService interface and implements the business logic. It interacts with the repository layer via a UserRepository interface:

package usersusecases

import (
    dao "goGinTemplate/domain/dao"
    dto "goGinTemplate/domain/dto"
    "log"
)

type UserService interface {
    GetAllUser() ([]dto.GetUserDto, error)
}
//consumer interface struct to be implement in repository data layer
type UserRepository interface {
    FindAllUser() ([]dao.User, error)
}

type UserServiceImpl struct {
    userRepository UserRepository
}

func UserServiceInit(userRepository UserRepository) *UserServiceImpl {
    return &UserServiceImpl{userRepository: userRepository}
}

func (srv UserServiceImpl) GetAllUser() ([]dto.GetUserDto, error) {
    users, err := srv.userRepository.FindAllUser()
    if err != nil {
        return nil, err
    }

    var userResponses []dto.GetUserDto
    for _, user := range users {
        log.Println(user.Name, user.Email)
        userResponses = append(userResponses, dto.GetUserDto{
            ID:    uint(user.ID),
            Name:  user.Name,
            Email: user.Email,
        })
    }

    return userResponses, nil
}

3. Data Access Layer (Repository Layer)

The repository layer implements database interactions. It uses GORM for ORM operations:

package mysqlrepo

import (
    "goGinTemplate/domain/dao"
    log "github.com/sirupsen/logrus"
    "gorm.io/gorm"
)

type UserRepository interface {
    FindAllUser() ([]dao.User, error)
}

type UserRepositoryImpl struct {
    db *gorm.DB
}

func UserRepositoryInit(db *gorm.DB) *UserRepositoryImpl {
    return &UserRepositoryImpl{db: db}
}

func (repo UserRepositoryImpl) FindAllUser() ([]dao.User, error) {
    var users []dao.User
    err := repo.db.Find(&users).Error
    if err != nil {
        log.Error("Error fetching users: ", err)
        return nil, err
    }
    return users, nil
}

4. Dependency Injection

The dependency injection layer wires everything together:

package dependencyinjector

import (
    userService "goGinTemplate/app/usecases/usersUsecases"
    userController "goGinTemplate/httpServer/handler"
    repository "goGinTemplate/infra/mysqlRepo"
    "gorm.io/gorm"
)

type Initialization struct {
    UserCtrl userController.UserController
}

func InitUserInjector(gormDB *gorm.DB) *Initialization {
    userRepo := repository.UserRepositoryInit(gormDB)
    userService := userService.UserServiceInit(userRepo)
    userCtrl := userController.UserControllerInit(userService)

    return &Initialization{UserCtrl: userCtrl}
}

func InitAllDependencyInjectors() Initialization {
    gormDB := config.InitDB()
    return InitUserInjector(gormDB)
}

5. Routing

Finally, the router initializes routes and assigns controllers to endpoints:

package routers

import (
    "goGinTemplate/dependencyinjector"
    "github.com/gin-gonic/gin"
)

func InitRoutes(router *gin.Engine, controllers dependencyinjector.Initialization) {
    api := router.Group("/api")
    UserRoutes(api.Group("/users"), controllers.UserCtrl)
}

func UserRoutes(router *gin.RouterGroup, userCtrl handler.UserController) {
    router.GET("/", userCtrl.GetAllUserData)
}

6. Main Application

The main.go file sets up the Gin router and starts the application:

package main

import (
    "goGinTemplate/dependencyinjector"
    "goGinTemplate/httpServer/routers"
    "github.com/gin-gonic/gin"
    env "github.com/joho/godotenv"
)

func init() {
    env.Load()
}

func main() {
    controllers := dependencyinjector.InitAllDependencyInjectors()
    router := gin.Default()
    routers.InitRoutes(router, controllers)
    router.Run(":3000")
}

Benefits of This Approach

  1. Decoupling: Each layer depends only on interfaces, making the system flexible and modular.

  2. Testability: Interfaces can be mocked, enabling unit tests for each layer independently.

  3. Scalability: The architecture easily accommodates new features or changes.


Conclusion

By combining n-tier architecture with the consumer interface pattern, this approach ensures a clean, scalable, and maintainable application structure. It emphasizes the separation of concerns while enabling robust unit testing.

Feel free to experiment with this setup and adapt it to your project requirements!