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:
Presentation Layer (HTTP Server): Handles HTTP requests and responses, serving as the entry point for the application.
Application Layer (Service/Use Case): Contains business logic and coordinates operations.
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
Decoupling: Each layer depends only on interfaces, making the system flexible and modular.
Testability: Interfaces can be mocked, enabling unit tests for each layer independently.
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!