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!
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.