Hexagonal architecture with Golang (part 2)

Hexagonal architecture with Golang (part 2)

·

17 min read

Introduction

In Part 1 of this series, we explored the Hexagonal architecture, delving into its key concepts and the numerous advantages it offers, such as creating isolated components, enhancing project flexibility and helping easy testing. Now, we are ready to take the next step and apply this powerful architecture to our project. However, before we proceed, let’s make sure everyone is on the same page regarding the basics of Golang, as this series focuses on experiences using Golang. Join me for this exciting experience using Golang and the Hexagonal architecture!

source: unsplash.com

Structure of project

I will create a project for the example of Part 1 to make it easier for you to think. I build the User service that provides a signup feature to create new accounts. Let’s take a look at our architecture first.

The structure of the project is also built based on components of Hexagonal architecture. The architecture emphasizes a clear separation of concerns and isolates the core application logic from external dependencies. Here’s how our architecture is organized:

├── Dockerfile
├── cmd
│   └── runner.go
├── conf
│   └── app.yaml
├── internal
│   ├── controller
│   │   └── controller.go
│   ├── core
│   │   ├── common
│   │   │   ├── router
│   │   │   │   └── router.go
│   │   │   └── utils
│   │   │       └── logger.go
│   │   ├── dto
│   │   │   └── user.go
│   │   ├── entity
│   │   ├── model
│   │   │   ├── request
│   │   │   │   └── request.go
│   │   │   └── response
│   │   │       └── response.go
│   │   ├── port
│   │   │   ├── repository
│   │   │   │   ├── db.go
│   │   │   │   └── user.go
│   │   │   └── service
│   │   │       └── user.go
│   │   ├── server
│   │   │   └── http_server.go
│   │   └── service
│   │       └── user.go
│   └── infra
│       ├── config
│       │   └── config.go
│       └── repository
│           ├── db.go
│           └── user.go
├── schema
│   └── schema.sql
└── script
    └── run.sh

Now, I introduce the summary role of folders:

/cmd

This directory contains the entry point of your application, in this case, the “runner.go” file. It’s where you typically define the main function and set up your application.

/internal

This directory is a fundamental part of the Hexagonal architecture as it contains the internal code of the application, separated into different packages.

/internal/controller

This package contains files responsible for handling HTTP requests and calling the appropriate core logic. For example, the "controller.go" file could define functions for handling different HTTP endpoints. It is a primary/driver adapter.

/internal/core

This package is the core application logic and is divided further into sub-packages:

  • common: It contains common utilities used throughout the application, such as "router.go" (for HTTP routing) and "logger.go" (for logging).

  • dto: This package defines data transfer objects (DTOs) used for passing data between different layers.

  • entity: It contains the domain entities, representing the core data structures used in the application.

  • model: This package contains model structures representing specific HTTP request and response bodies.

  • port: Here, you define interfaces (ports) that represent the required functionalities of the application. For example, “repository” interfaces define methods for accessing data and “service” interfaces define methods for business logic.

  • server: This package contains the HTTP server setup.

  • service: This package contains the core application services that handle business logic.

/internal/infra:

This package contains the infrastructure-related code, they are secondary/driven adapters:

  • config: This file handles the configuration setup and parsing.

  • repository: This package contains implementations of the repository interfaces, such as "db.go" and "user.go," interacting with the database.

Besides, we have several other folders:

conf: This folder holds the configuration files for your application, such as the "app.yaml" file. It's where you store settings related to your application's behavior.

schema: This folder typically contains database schema files, like "schema.sql," that define the structure of the database tables.

script: This directory stores script files, such as "run.sh," which can be used for automating common tasks or executing the application.

Dockerfile: This is a file used to define the Docker image for your application, allowing you to containerize it.

LICENSE and README.md: These files contain the project's license information and the project's documentation, respectively.

By organizing the code in this way, the Hexagonal architecture promotes a clean separation between the core application logic and external dependencies, leading to greater maintainability, testability, and flexibility. It allows you to easily replace or modify external components without affecting the core application logic.

Through an introduction to the functionality of folders, we can easily visualize and understand the code structure of projects. In the next section, with this knowledge in hand, we’ll then open our laptops and embark on a coding journey, implementing our project step by step.

A Step-by-Step Guide to Organized Code

source: github.com/rudrabarad/Gifs

Before we begin coding, we need to understand the requirements of a project, especially if it is a SignUp feature. The Signup feature should allow users to create an account by providing a unique username and a secure password. Upon successful registration, the system should return a response indicating success, and in case of any errors, appropriate error messages should be returned.

Creating Request and Response Models:

We'll start by creating the necessary request and response models to handle Signup requests and responses.

// ./internal/core/model/request/request.go

package request

type SignUpRequest struct {
 Username string `json:"username"`
 Password string `json:"password"`
}
// ./internal/core/model/response/response.go

package response

import "user-service/internal/core/entity/error_code"

type Response struct {
 Data         interface{}          `json:"data"`
 Status       bool                 `json:"status"`
 ErrorCode    error_code.ErrorCode `json:"errorCode"`
 ErrorMessage string               `json:"errorMessage"`
}

type SignUpDataResponse struct {
 DisplayName string `json:"displayName"`
}

In the "internal/core/model/request/request.go" file, we define the SignUpRequest structure, which holds the username and password information. In "internal/core/model/response/response.go" file, we define the general Response structure with various fields such as data, status, error code, and error message. Additionally, we create a specific SignUpDataResponse structure to represent the display name data in the Signup response.

Defining the UserService Interface (Primary Port Component):

We define the primary port component, which is the UserService interface in "internal/core/port/service/user.go" file. This interface outlines the functionality required for handling Signup requests.

// ./internal/core/port/service/user.go

package service

import (
 "user-service/internal/core/model/request"
 "user-service/internal/core/model/response"
)

type UserService interface {
 SignUp(request *request.SignUpRequest) *response.Response
}

Implementing the UserService (Application/Business Component):

In the "internal/core/service/user.go" file, we implement the UserService interface with a concrete service called userService. This service handles the core business logic of the project. The SignUp function in this service validates the request, generates a random display name, and then saves the new user to the database using the UserRepository.

// ./internal/core/service/user.go

package service

import (
 "user-service/internal/core/common/utils"
 "user-service/internal/core/dto"
 "user-service/internal/core/entity/error_code"
 "user-service/internal/core/model/request"
 "user-service/internal/core/model/response"
 "user-service/internal/core/port/repository"
 "user-service/internal/core/port/service"
)

const (
 invalidUserNameErrMsg = "invalid username"
 invalidPasswordErrMsg = "invalid password"
)

type userService struct {
 userRepo repository.UserRepository
}

func NewUserService(userRepo repository.UserRepository) service.UserService {
 return &userService{
  userRepo: userRepo,
 }
}

func (u userService) SignUp(request *request.SignUpRequest) *response.Response {
 // validate request
 if len(request.Username) == 0 {
  return u.createFailedResponse(error_code.InvalidRequest, invalidUserNameErrMsg)
 }

 if len(request.Password) == 0 {
  return u.createFailedResponse(error_code.InvalidRequest, invalidPasswordErrMsg)
 }

 currentTime := utils.GetUTCCurrentMillis()
 userDTO := dto.UserDTO{
  UserName:    request.Username,
  Password:    request.Password,
  DisplayName: u.getRandomDisplayName(request.Username),
  CreatedAt:   currentTime,
  UpdatedAt:   currentTime,
 }

 // save a new user
 err := u.userRepo.Insert(userDTO)
 if err != nil {
  if err == repository.DuplicateUser {
   return u.createFailedResponse(error_code.DuplicateUser, err.Error())
  }

  return u.createFailedResponse(error_code.InternalError, error_code.InternalErrMsg)
 }

 // create data response
 signUpData := response.SignUpDataResponse{
  DisplayName: userDTO.DisplayName,
 }

 return u.createSuccessResponse(signUpData)
}

func (u userService) getRandomDisplayName(username string) string {
 return username + utils.GetUUID()
}

func (u userService) createFailedResponse(
 code error_code.ErrorCode, message string,
) *response.Response {
 return &response.Response{
  Status:       false,
  ErrorCode:    code,
  ErrorMessage: message,
 }
}

func (u userService) createSuccessResponse(data response.SignUpDataResponse) *response.Response {
 return &response.Response{
  Data:         data,
  Status:       true,
  ErrorCode:    error_code.Success,
  ErrorMessage: error_code.SuccessErrMsg,
 }
}

In the above code, I use UserDTO object, some service error_code. Structures are defined as:

// ./internal/core/dto/user.go

package dto

type UserDTO struct {
 UserName  string
 Password  string
 CreatedAt uint64
 UpdatedAt uint64
}
// ./internal/core/entity/error_code/error_code.go

package error_code

type ErrorCode string

// error code
const (
 Success        ErrorCode = "SUCCESS"
 InvalidRequest ErrorCode = "INVALID_REQUEST"
 DuplicateUser  ErrorCode = "DUPLICATE_USER"
 InternalError  ErrorCode = "INTERNAL_ERROR"
)

// error message
const (
 SuccessErrMsg  = "success"
 InternalErrMsg = "internal error"
 InvalidRequestErrMsg = "invalid request"
)

Creating the UserRepository Interface (Secondary Port Component):

To interact with the database, we create the secondary port component, the UserRepository interface, in "internal/core/port/repository/user.go" file. This interface defines the contract for inserting a new UserDTO object.

// ./internal/core/port/repository/user.go

package repository

import (
 "errors"

 "user-service/internal/core/dto"
)

var (
 DuplicateUser = errors.New("duplicate user")
)

type UserRepository interface {
 Insert(user dto.UserDTO) error
}

Implementing the UserRepository (Secondary Adapter):

The "internal/infra/repository/user.go" file contains the implementation of the UserRepository interface. This repository communicates with the database to insert a new user. It also handles any errors that might occur during the insertion process, such as duplicate user entries. It is a secondary adapter.

// ./internal/infra/

package repository

import (
 "errors"
 "strings"

 "user-service/internal/core/dto"
 "user-service/internal/core/port/repository"
)

const (
 duplicateEntryMsg = "Duplicate entry"
 numberRowInserted = 1
)

var (
 insertUserErr = errors.New("failed to insert user")
)

const (
 insertUserStatement = "INSERT INTO User ( " +
  "`username`, " +
  "`password`, " +
  "`display_name`, " +
  "`created_at`," +
  "`updated_at`) " +
  "VALUES (?, ?, ?, ?, ?)"
)

type userRepository struct {
 db repository.Database
}

func NewUserRepository(db repository.Database) repository.UserRepository {
 return &userRepository{
  db: db,
 }
}

func (u userRepository) Insert(user dto.UserDTO) error {
 result, err := u.db.GetDB().Exec(
  insertUserStatement,
  user.UserName,
  user.Password,
  user.DisplayName,
  user.CreatedAt,
  user.UpdatedAt,
 )

 if err != nil {
  if strings.Contains(err.Error(), duplicateEntryMsg) {
   return repository.DuplicateUser
  }

  return err
 }

 numRow, err := result.RowsAffected()
 if err != nil {
  return err
 }

 if numRow != numberRowInserted {
  return insertUserErr
 }

 return nil
}

To connect to the database, we define the Database interface (in "internal/core/port/repository/db.go" file) to provide database connections for the service. Additionally, we implement the database adapter in "internal/infra/repository/db.go", which sets up the connection and initializes the database driver.

// ./internal/core/port/repository/db.go

package repository

import (
    "database/sql"
    "io"
)

type Database interface {
    io.Closer
    GetDB() *sql.DB
}

And this is the database adapter:

// ./internal/infra/repository/db.go

package repository

import (
    "database/sql"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "user-service/internal/core/port/repository"
    "user-service/internal/infra/config"
)

type database struct {
    *sql.DB
}

func NewDB(conf config.DatabaseConfig) (repository.Database, error) {
    db, err := newDatabase(conf)
    if err != nil {
        return nil, err
    }

    return &database{
        db,
    }, nil
}

func newDatabase(conf config.DatabaseConfig) (*sql.DB, error) {
    db, err := sql.Open(conf.Driver, conf.Url)
    if err != nil {
        return nil, err
    }
    db.SetConnMaxLifetime(time.Minute * time.Duration(conf.ConnMaxLifetimeInMinute))
    db.SetMaxOpenConns(conf.MaxOpenConns)
    db.SetMaxIdleConns(conf.MaxIdleConns)

    err = db.Ping()
    if err != nil {
        return nil, err
    }

    return db, err
}

func (da database) Close() error {
    return da.DB.Close()
}

func (da database) GetDB() *sql.DB {
    return da.DB
}

At this step, we are almost done with the project. They already have a component that handles business logic (UserService) and a component that connects to the database (UserRepository and Database). Let's take a look at the architecture to find what components we're missing.

Creating the UserController (Primary Adapter):

We are missing an important component which is the controller (Primary adapter) of the service. Now, we need the primary adapter component, the UserController, to handle incoming HTTP requests and invoke the core application logic. The "internal/controller/controller.go" file contains the implementation of this controller. It receives HTTP requests for the Signup feature and delegates the request processing to the UserService.

// ./internal/controller/controller.go

package controller

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "user-service/internal/core/common/router"
    "user-service/internal/core/entity/error_code"
    "user-service/internal/core/model/request"
    "user-service/internal/core/model/response"
    "user-service/internal/core/port/service"
)

var (
    invalidRequestResponse = &response.Response{
        ErrorCode:    error_code.InvalidRequest,
        ErrorMessage: error_code.InvalidRequestErrMsg,
        Status:       false,
    }
)

type UserController struct {
    gin         *gin.Engine
    userService service.UserService
}

func NewUserController(
    gin *gin.Engine,
    userService service.UserService,
) UserController {
    return UserController{
        gin:         gin,
        userService: userService,
    }

}

func (u UserController) InitRouter() {
    api := u.gin.Group("/api/v1")
    router.Post(api, "/signup", u.signUp)
}

func (u UserController) signUp(c *gin.Context) {
    req, err := u.parseRequest(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusOK, &invalidRequestResponse)
        return
    }

    resp := u.userService.SignUp(req)
    c.JSON(http.StatusOK, resp)
}

func (u UserController) parseRequest(ctx *gin.Context) (*request.SignUpRequest, error) {
    var req request.SignUpRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        return nil, err
    }

    return &req, nil
}

In the UserController's constructor function, we register the appropriate routes for the Signup feature using the "github.com/gin-gonic/gin" package. For example, we set up the /api/v1/signup endpoint to handle Signup requests. The signUp function in the UserController parses incoming requests and sends the response back to the client. If the request is invalid, it returns an appropriate error response.

Creating the HTTP Server:

The next step is to create an HTTP server to handle incoming requests. The HTTP server will serve as the entry point for our application, directing requests to the appropriate controllers for processing.

// ./internal/core/server/http_server.go

package server

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "user-service/internal/infra/config"
)

const defaultHost = "0.0.0.0"

type HttpServer interface {
    Start()
    Stop()
}

type httpServer struct {
    Port   uint
    server *http.Server
}

func NewHttpServer(router *gin.Engine, config config.HttpServerConfig) HttpServer {
    return &httpServer{
        Port: config.Port,
        server: &http.Server{
            Addr:    fmt.Sprintf("%s:%d", defaultHost, config.Port),
            Handler: router,
        },
    }
}

func (httpServer httpServer) Start() {
    go func() {
        if err := httpServer.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf(
                "failed to stater HttpServer listen port %d, err=%s",
                httpServer.Port, err.Error(),
            )
        }
    }()
    log.Printf("Start Service with port %d", httpServer.Port)
}

func (httpServer httpServer) Stop() {
    ctx, cancel := context.WithTimeout(
        context.Background(), time.Duration(3)*time.Second,
    )
    defer cancel()

    if err := httpServer.server.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown err=%s", err. Error())
    }
}

In the "internal/core/server/http_server.go" file, we define the HttpServer interface with two methods: Start() and Stop(). The Start() method launches the server to listen for incoming requests, while the Stop() method gracefully shuts down the server when needed.

Putting It All Together - Main Function:

Finally, with all the pieces of our Signup feature in place, we are now ready to prepare the main function of our program. The main function will bring everything together and start the HTTP server, allowing the Signup feature to be accessible to users.

// ./cmd/runner.go

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"

    "github.com/gin-gonic/gin"
    "user-service/internal/controller"
    "user-service/internal/core/server"
    "user-service/internal/core/service"
    "user-service/internal/infra/config"
    "user-service/internal/infra/repository"
)

func main() {
    // Create a new instance of the Gin router
    instance := gin.New()
    instance.Use(gin.Recovery())

    // Initialize the database connection
    db, err := repository.NewDB(
        config.DatabaseConfig{
            Driver: "mysql",
            Url: "user:password@tcp(127.0.0.1:3306)/your_database_name?charset=utf8mb4&parseTime=true&loc=UTC&tls=false&readTimeout=3s&writeTimeout=3s&timeout=3s&clientFoundRows=true",
            ConnMaxLifetimeInMinute: 3,
            MaxOpenConns:            10,
            MaxIdleConns:            1,
        },
    )
    if err != nil {
        log.Fatalf("failed to new database err=%s\n", err.Error())
    }

    // Create the UserRepository
    userRepo := repository.NewUserRepository(db)

    // Create the UserService
    userService := service.NewUserService(userRepo)

    // Create the UserController
    userController := controller.NewUserController(instance, userService)

    // Initialize the routes for UserController
    userController.InitRouter()

    // Create the HTTP server
    httpServer := server.NewHttpServer(
        instance,
        config.HttpServerConfig{
            Port: 8000,
        },
    )

    // Start the HTTP server
    httpServer.Start()
    defer httpServer.Stop()

    // Listen for OS signals to perform a graceful shutdown
    log.Println("listening signals...")
    c := make(chan os.Signal, 1)
    signal.Notify(
        c,
        os.Interrupt,
        syscall.SIGHUP,
        syscall.SIGINT,
        syscall.SIGQUIT,
        syscall.SIGTERM,
    )
    <-c
    log.Println("graceful shutdown...")
}

In the main function, we create a new instance of the Gin router using gin.New(). We also add middleware to handle panic recovery with gin.Recovery().

Then, we initialize the database connection using the "mysql" driver and relevant configuration details. The repository.NewDB() function creates a new database connection instance, which will be used to interact with the database.

Using the database connection, we create a new UserRepository instance with repository.NewUserRepository(db). This repository will be responsible for handling user-related database interactions.

Next, we create the UserService by passing the UserRepository to service.NewUserService(userRepo). This service will handle the core business logic of the Signup feature.

We set up the HTTP server using server.NewHttpServer(instance, config.HttpServerConfig{Port: 8000}). The server will listen on port 8000. The httpServer.Start() function launches the server to begin handling incoming requests.

Finally, we use a channel to listen for OS signals (e.g., SIGINT, SIGTERM) to gracefully shutdown the server when necessary. When a signal is received, we call httpServer.Stop() to stop the server and perform a clean shutdown.

We handled user service that provides SignUp feature. Let's take a look at the code diagram.

Overview diagram code

This diagram illustrates the flow of data and control between the different components of the User Service. The UserController receives HTTP requests and passes them to the UserService, which handles the business logic and interacts with the UserRepository to access the database.

                           +------------------------+
                           |     UserController     |
                           +------------------------+
                           |  - UserService         |
                           +------------------------+
                           |  + SignUp(request)     |
                           +------------------------+
                                     |
                                     | HTTP Requests/Responses
                                     |
                                     V
                           +------------------------+
                           |      UserService       |
                           +------------------------+
                           |  - UserRepository      |
                           +------------------------+
                           |  + SignUp(request)     |
                           +------------------------+
                                     |
                                     | Business Logic
                                     |
                                     V
                           +------------------------+
                           |    UserRepository     |
                           +------------------------+
                           |  - Database            |
                           +------------------------+
                           |  + Insert(user)        |
                           +------------------------+

Running

We can write a script to run the User service as:

# ./script/run.sh
#! /bin/sh
go run ../cmd/runner.go

Before running the service, we need to prepare the database schema. Create a file named schema.sql inside the schema/ directory with the following content:

-- ./schema/schema.sql
create table User
(
    username     varchar(20) not null primary key,
    password     varchar(64) not null,
    display_name varchar(20) not null,
    created_at   bigint      not null,
    updated_at   bigint      not null
);

To run the User Service, you execute the following command in the root directory of the project. The service will start running on port 8000.

# execute file run 
./run.sh                                                                                                                                                              ─╯
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /api/v1/signup            --> user-service/internal/controller.UserController.signUp-fm (2 handlers)
2023/07/31 20:23:41 Start Service with port 8000
2023/07/31 20:23:41 listening signals...

Testing the Signup API

Now, let's test the /api/v1/signup API using curl or any other HTTP client. In this blog, I use the curl command.

 # Execute curl
curl --location 'http://localhost:8000/api/v1/signup' \                                                                                                               ─╯
--header 'Content-Type: application/json' \
--data '{
    "username": "test_abc",
    "password": "12345"
}'

# Result 
{"data":{"displayName":"test_abc1690810036744"},"status":true,"errorCode":"SUCCESS","errorMessage":"success"}

If you execute the above curl command again, you will receive the following result with errorCode DUPLICATE_USER:

curl --location 'http://localhost:8000/api/v1/signup' \                                                                                                               ─╯
--header 'Content-Type: application/json' \
--data '{
    "username": "test_abc",
    "password": "12345"
}'
{"data":null,"status":false,"errorCode":"DUPLICATE_USER","errorMessage":"duplicate user"}

Try to write the unit test

Let's add a unit test file for the UserService component. We'll create a file named user_test.go in the internal/core/service directory.

// ./internal/core/service/user_service_test.go
package service

import (
    "testing"

    "user-service/internal/core/dto"
    "user-service/internal/core/entity/error_code"
    "user-service/internal/core/model/request"
    "user-service/internal/core/model/response"
    "user-service/internal/core/port/repository"
)

// Define a mock UserRepository for testing
type mockUserRepository struct{}

func (m *mockUserRepository) Insert(user dto.UserDTO) error {
    // Simulate a duplicate user case
    if user.UserName == "test_user" {
        return repository.DuplicateUser
    }

    // Simulate successful insertion
    return nil
}

func TestUserService_SignUp_Success(t *testing.T) {
    // Create a mock UserRepository for testing
    userRepo := &mockUserRepository{}

    // Create the UserService using the mock UserRepository
    userService := NewUserService(userRepo)

    // Test case: Successful signup
    req := &request.SignUpRequest{
        Username: "test_abc",
        Password: "12345",
    }

    res := userService.SignUp(req)
    if !res.Status {
        t.Errorf("expected status to be true, got false")
    }

    data := res.Data.(response.SignUpDataResponse)
    if data.DisplayName == "" {
        t.Errorf("expected non-empty display name, got empty")
    }
}

func TestUserService_SignUp_InvalidUsername(t *testing.T) {
    // Create a mock UserRepository for testing
    userRepo := &mockUserRepository{}

    // Create the UserService using the mock UserRepository
    userService := NewUserService(userRepo)

    // Test case: Invalid request with empty username
    req := &request.SignUpRequest{
        Username: "",
        Password: "12345",
    }

    res := userService.SignUp(req)
    if res.Status {
        t.Errorf("expected status to be false, got true")
    }

    if res.ErrorCode != error_code.InvalidRequest {
        t.Errorf("expected error code to be InvalidRequest, got %s", res.ErrorCode)
    }
}

func TestUserService_SignUp_InvalidPassword(t *testing.T) {
    // Create a mock UserRepository for testing
    userRepo := &mockUserRepository{}

    // Create the UserService using the mock UserRepository
    userService := NewUserService(userRepo)

    // Test case: Invalid request with empty password
    req := &request.SignUpRequest{
        Username: "test_user",
        Password: "",
    }

    res := userService.SignUp(req)
    if res.Status {
        t.Errorf("expected status to be false, got true")
    }
    if res.ErrorCode != error_code.InvalidRequest {
        t.Errorf("expected error code to be InvalidRequest, got %s", res.ErrorCode)
    }
}

func TestUserService_SignUp_DuplicateUser(t *testing.T) {
    // Create a mock UserRepository for testing
    userRepo := &mockUserRepository{}

    // Create the UserService using the mock UserRepository
    userService := NewUserService(userRepo)

    // Test case: Duplicate user
    req := &request.SignUpRequest{
        Username: "test_user",
        Password: "12345",
    }

    res := userService.SignUp(req)
    if res.Status {
        t.Errorf("expected status to be false, got true")
    }

    if res.ErrorCode != error_code.DuplicateUser {
        t.Errorf("expected error code to be DuplicateUser, got %s", res.ErrorCode)
    }
}

Each test function creates a new instance of the UserService with the mock UserRepository and then calls the SignUp method with the appropriate request. It then checks the response to ensure that the service behaves as expected.

To run the unit tests, you execute the following command in the root directory of the project:

go test -v ./internal/core/service
                                                                                                                                                                                          ─╯
=== RUN   TestUserService_SignUp_Success
--- PASS: TestUserService_SignUp_Success (0.00s)
=== RUN   TestUserService_SignUp_InvalidUsername
--- PASS: TestUserService_SignUp_InvalidUsername (0.00s)
=== RUN   TestUserService_SignUp_InvalidPassword
--- PASS: TestUserService_SignUp_InvalidPassword (0.00s)
=== RUN   TestUserService_SignUp_DuplicateUser
--- PASS: TestUserService_SignUp_DuplicateUser (0.00s)
PASS
ok      user-service/internal/core/service      0.203s

Conclusion

In this blog, we successfully developed a User Service using Golang and followed the principles of the Hexagonal architecture. We created components like UserRepository, UserService, and UserController. The UserRepository was responsible for interacting with the database, the UserService handled essential business logic, and the UserController acted as the HTTP entry point.

To ensure the reliability of our service, we implemented comprehensive unit tests. However, to keep the blog focused, I didn't delve deep into other crucial aspects like Docker, configuration management, logging and testing. I will cover these topics in future blogs.

The hexagonal architecture allowed us to build a flexible and maintainable application structure, laying the foundation for scalability. You try to implement this User service by gRPC protocol. We only need to add the gRPC controller and keep all other components in the project. You can find the code of the project on my GitHub here.