Golang-based Hexagonal Architecture for Scalable and Maintainable Web Development

Photo by Josie Weiss on Unsplash

Golang-based Hexagonal Architecture for Scalable and Maintainable Web Development

Overview

There are common questions when we start a new software development project like How to use architecture patterns in my project? How to swap any technology used in my project? How to organize my project directory?

In this article, I want to share an example of hexagonal architecture (Ports & Adapters pattern proposed by Alistair Cockburn in 2005) using Golang based on my personal experience in web development projects for easy scalability and maintenance.

Main ideas about Hexagonal architecture

  1. “Your application never touches the real world” (Alistair Cockburn, 2005)

  2. “Swap real-world technologies easily” (Alistair Cockburn, 2005)

Figure 1 .- Hexagonal Architecture Chart

Concepts

Application

  • This section has important aspects for the business or problem that the application seeks to solve.

  • Business logic without reference to any real-world technology, framework, or device.

  • The hexagonal architecture does not say anything about the internal structure in the application (The hexagon) so it can have layers or include patterns like DDD, TDD, etc.

Actors

  • Actors are environments, applications, hardware, or any entity that seeks to interact with the application.

  • There are two types of actors, the actors that drive the application (driver actors ) and the actors that are directed by the application (driven actors).

Ports

  • “It is a conversation reason or a communicated intent towards the application” (Alistair Cockburn, 2005).

  • They are all the calls to functions, all the services offered around a type of conversation.

  • “The name for the ports starts with To do something …” (Alistair Cockburn, 2005) for example To manage users.

  • The ports are interfaces that the application offers to the outside world so that actors can interact with them.

  • The ports belong to the application.

  • There are driver and driven ports, based on the type of actor that looking to interact with our application.

Adapters

  • The component that allows a specific technology to interact with an application port.

  • A driver adapter uses a driver port interface modifying a specific technology request to an agnostic technology request on a driver port.

  • A driven adapter implements a driven port interface by modifying technology-agnostic methods of the driven port into technology-specific methods.

Case of study

In this section, we are going to implement these previous concepts in a real project with the following functional requirements:

  • Register a new user and save on the database

We start with the following directory structure that holds the application and business logic in the ../core directory with its driver and driven ports that are located inside the ../core directory as indicated by hexagonal architecture.

├── /cmd
    ├── /bootstrap
    ├── main.go
├── /pkg # external libraries and packages to use in this project
└── /internal
    ├── /adapters
    ├── /core
    │   ├── /application
    │   ├── /domain
    │   └── /ports
    └── /platform # Database and Web Server actors
        ├── /server
        └── /storage

We will implement actors, adapters and ports two for each one. Our study case manages users.

  • Web driver actor (Echo package)

  • Database-driven actor (MySQL)

  • Web driver adapter

  • Database driven adapter

  • Web driver port

  • Database driven port

We will start with the inside of your application by defining a data transfer object used for this use case

//Path ./internal/core/application/dto/user_dto.go

package dto

import "time"

type User struct {
 ID        string     `json:"id"`
 Name      string     `json:"name"`
 Lastname  string     `json:"lastname"`
 Email     string     `json:"email"`
 Password  string     `json:"password"`
 CreatedAt time.Time  `json:"created_at,omitempty"`
 UpdatedAt time.Time  `json:"updated_at,omitempty"`
 DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

Then, we will write the business logic for the users, validations, and instantiate the initial value of each field.

// Path ./internal/core/domain/user.go

package domain

import (
 "errors"
 "fmt"
 "net/mail"
 "time"

 "github.com/dany0814/go-hexagonal/pkg/uidgen"
)

var ErrUserConflict = errors.New("user already exists")
var ErrInvalidUserID = errors.New("invalid User ID")
var ErrInvalidUserEmail = errors.New("invalid Email")
var ErrInvalidUserPassword = errors.New("invalid Password")
var ErrEmptyName = errors.New("the field name is required")

// NewUserID function to instantiate the initial value for UserID

type UserID struct {
 value string
}

func NewUserID(value string) (UserID, error) {
 v, err := uidgen.Parse(value)
 if err != nil {
  return UserID{}, fmt.Errorf("%w: %s", ErrInvalidUserID, value)
 }
 return UserID{
  value: v,
 }, nil
}

func (id UserID) String() string {
 return id.value
}

// NewUserEmail function to instantiate the initial value for UserEmail

type UserEmail struct {
 value string
}

func NewUserEmail(value string) (UserEmail, error) {
 _, err := mail.ParseAddress(value)
 if err != nil {
  return UserEmail{}, fmt.Errorf("%w: %s", ErrInvalidUserEmail, value)
 }
 return UserEmail{
  value: value,
 }, nil
}

func (email UserEmail) String() string {
 return email.value
}

// NewUserPassword function to instantiate the initial value for UserPassword

type UserPassword struct {
 value string
}

func NewUserPassword(value string) (UserPassword, error) {
 if value == "" {
  return UserPassword{}, fmt.Errorf("%w: %s", ErrInvalidUserPassword, value)
 }
 return UserPassword{
  value: value,
 }, nil
}

func (pass UserPassword) String() string {
 return pass.value
}

// NewUserUsername function to instantiate the initial value for UserUsername

// NewUser function to instantiate the initial value for User

type User struct {
 ID        UserID
 Name      string
 Lastname  string
 Email     UserEmail
 Password  UserPassword
 CreatedAt time.Time
 UpdatedAt time.Time
 DeletedAt *time.Time
}

func NewUser(userID, name, lastname, email, password string) (User, error) {
 idVo, err := NewUserID(userID)
 if err != nil {
  return User{}, err
 }

 if name == "" {
  return User{}, fmt.Errorf("%w: %s", ErrEmptyName, name)
 }

 emailVo, err := NewUserEmail(email)
 if err != nil {
  return User{}, err
 }

 passwordVo, err := NewUserPassword(password)
 if err != nil {
  return User{}, err
 }

 return User{
  ID:       idVo,
  Name:     name,
  Lastname: lastname,
  Email:    emailVo,
  Password: passwordVo,
 }, nil
}

func (u User) UserID() UserID {
 return u.UserID()
}

Now, we will create the driver port to the web which is an interface for its implementation that depends on the specific technology, a web framework; in this example is echo/v4.

// Path ./internal/core/ports/driver/user_web.go

package driverport

type UserAPI interface {
 SignInHandler() error
}

We will also create the port driven to database

// Path ./internal/core/ports/driven/user_db.go

package drivenport

import (
 "context"

 "github.com/dany0814/go-hexagonal/internal/core/domain"
)

type UserDB interface {
 Create(ctx context.Context, user domain.User) error
}

Finally, with close your application, we will add the service that uses the business logic in our domain and calls the methods created in the port driven to our database.

// ./internal/core/application/user_service.go

package application

import (
 "context"
 "time"

 "github.com/dany0814/go-hexagonal/internal/core/application/dto"
 "github.com/dany0814/go-hexagonal/internal/core/domain"
 outdb "github.com/dany0814/go-hexagonal/internal/core/ports/driven"
 "github.com/dany0814/go-hexagonal/pkg/encryption"
 "github.com/dany0814/go-hexagonal/pkg/uidgen"
)

type UserService struct {
 userDB outdb.UserDB
}

func NewUserService(userDB outdb.UserDB) UserService {
 return UserService{
  userDB: userDB,
 }
}

func (usrv UserService) Register(ctx context.Context, user dto.User) (*dto.User, error) {
 id := uidgen.New().New()

 newuser, err := domain.NewUser(id, user.Name, user.Lastname, user.Email, user.Password)

 if err != nil {
  return nil, err
 }

 pass, err := encryption.HashAndSalt(user.Password)

 if err != nil {
  return nil, err
 }

 passencrypted, _ := domain.NewUserPassword(pass)

 newuser.Password = passencrypted
 newuser.CreatedAt = time.Now()
 newuser.UpdatedAt = time.Now()

 err = usrv.userDB.Create(ctx, newuser)

 if err != nil {
  return nil, err
 }

 user.ID = id
 return &user, nil
}

So far, we have created our application with a driver port and a driven port to implement a web driver actor and a database-driven actor.

We will create the driver adapter for the web driver actor.

// Path ./internal/adapters/driver/user_handler.go

package driveradapt

import (
 "errors"
 "net/http"

 "github.com/dany0814/go-hexagonal/internal/core/application"
 "github.com/dany0814/go-hexagonal/internal/core/application/dto"
 "github.com/dany0814/go-hexagonal/internal/core/domain"
 "github.com/dany0814/go-hexagonal/pkg/helpers"
 "github.com/labstack/echo/v4"
 _ "github.com/labstack/echo/v4"
)

type UserHandler struct {
 userService application.UserService
 Ctx         echo.Context
}

func NewUserHandler(usrv application.UserService) UserHandler {
 return UserHandler{
  userService: usrv,
 }
}

func (usrh UserHandler) SignInHandler() error {
 ctx := usrh.Ctx
 var req dto.User
 if err := ctx.Bind(&req); err != nil {
  ctx.JSON(http.StatusBadRequest, err.Error())
  return nil
 }
 res, err := usrh.userService.Register(ctx.Request().Context(), req)

 if err != nil {
  switch {
  case errors.Is(err, domain.ErrUserConflict):
   ctx.JSON(http.StatusConflict, err.Error())
   return nil
  default:
   ctx.JSON(http.StatusInternalServerError, err.Error())
   return nil
  }
 }
 ctx.JSON(http.StatusCreated, helpers.DataResponse(0, "User created", res))
 return nil
}

To finish implementing the driver actor, we will create our web server in the platform directory that uses a specific technology which is echo/v4.

// Path ./internal/platform/server/server.go

package server

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

 web "github.com/dany0814/go-hexagonal/internal/adapters/driver"
 "github.com/dany0814/go-hexagonal/internal/core/application"
 "github.com/labstack/echo/v4"
)

type AppService struct {
 UserService application.UserService
}

type Server struct {
 engine          *echo.Echo
 httpAddr        string
 ShutdownTimeout time.Duration
 app             AppService
}

func NewServer(ctx context.Context, host string, port uint, shutdownTimeout time.Duration, app AppService) (context.Context, Server) {
 srv := Server{
  engine:          echo.New(),
  httpAddr:        fmt.Sprintf("%s:%d", host, port),
  ShutdownTimeout: shutdownTimeout,
  app:             app,
 }
 srv.registerRoutes()
 return serverContext(ctx), srv
}

func (s *Server) Run(ctx context.Context) error {
 log.Println("Server running on", s.httpAddr)
 srv := &http.Server{
  Addr:    s.httpAddr,
  Handler: s.engine,
 }

 go func() {
  if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
   log.Fatal("server shut down", err)
  }
 }()

 <-ctx.Done()
 ctxShutDown, cancel := context.WithTimeout(context.Background(), s.ShutdownTimeout)
 defer cancel()

 return srv.Shutdown(ctxShutDown)
}

func serverContext(ctx context.Context) context.Context {
 c := make(chan os.Signal, 1)
 signal.Notify(c, os.Interrupt)
 ctx, cancel := context.WithCancel(ctx)
 go func() {
  <-c
  cancel()
 }()

 return ctx
}

func (s *Server) registerRoutes() {
 // User Routes
 uh := web.NewUserHandler(s.app.UserService)
 s.engine.POST("/user/sigin", func(c echo.Context) error {
  uh.Ctx = c
  return uh.SignInHandler()
 })
}

We have finished the implementation of a web driver actor that sends data to our application, which executes some validations and applies its business logic to create a new user.

On the other hand, we need to save this user data in our database; so we will create the storage-driven actor.

// Path ./internal/platform/storage/mysql/user.go

package mysqldb

import "time"

const (
 sqlUserTable = "users"
)

type SqlUser struct {
 ID        string     `db:"user_id"`
 Name      string     `db:"name"`
 Lastname  string     `db:"lastname"`
 Email     string     `db:"email"`
 Password  string     `db:"password"`
 CreatedAt time.Time  `db:"created_at"`
 UpdatedAt time.Time  `db:"updated_at"`
 DeletedAt *time.Time `db:"deleted_at"`
}
// Path ./internal/platform/storage/user_repository.go

package mysqldb

import (
 "context"
 "database/sql"
 "fmt"
 "time"

 "github.com/dany0814/go-hexagonal/internal/core/domain"
 "github.com/huandu/go-sqlbuilder"
)

type UserRepository struct {
 db        *sql.DB
 dbTimeout time.Duration
}

// NewUserRepository initializes a MySQL-based implementation of UserRepository.
func NewUserRepository(db *sql.DB, dbTimeout time.Duration) *UserRepository {
 return &UserRepository{
  db:        db,
  dbTimeout: dbTimeout,
 }
}

// Save implements the adapter userRepository interface.
func (r *UserRepository) Save(ctx context.Context, user domain.User) error {
 userSQLStruct := sqlbuilder.NewStruct(new(SqlUser))
 query, args := userSQLStruct.InsertInto(sqlUserTable, SqlUser{
  ID:        user.ID.String(),
  Name:      user.Name,
  Lastname:  user.Lastname,
  Email:     user.Email.String(),
  Password:  user.Password.String(),
  CreatedAt: user.CreatedAt,
  UpdatedAt: user.UpdatedAt,
  DeletedAt: user.DeletedAt,
 }).Build()

 _, err := r.db.ExecContext(ctx, query, args...)
 if err != nil {
  return fmt.Errorf("Error trying to persist course on database: %v", err)
 }

 return nil
}

We did it! Finally, we will set up a client for the database and some third-party libraries for this application in the ./pkg directory.

// Path ./pkg/config/config.go

package config

import (
 "context"
 "database/sql"
 "fmt"
 "time"

 _ "github.com/go-sql-driver/mysql"
 "github.com/kelseyhightower/envconfig"
)

type config struct {
 // Database config
 DbUser    string        `default:"admin"`
 DbPass    string        `default:"admin"`
 DbHost    string        `default:"0.0.0.0"`
 DbPort    string        `default:"3306"`
 DbName    string        `default:"admin"`
 DbTimeout time.Duration `default:"10s"`
 // Server config
 Host            string        `default:"0.0.0.0"`
 Port            uint          `default:"8080"`
 ShutdownTimeout time.Duration `default:"20s"`
}

var Cfg config

func LoadConfig() error {
 err := envconfig.Process("IRIS", &Cfg)
 if err != nil {
  return err
 }
 return nil
}

func ConfigDb(ctx context.Context) (*sql.DB, error) {
 mysqlURI := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", Cfg.DbUser, Cfg.DbPass, Cfg.DbHost, Cfg.DbPort, Cfg.DbName)
 fmt.Println("uri: ", mysqlURI)
 db, err := sql.Open("mysql", mysqlURI)
 if err != nil {
  fmt.Println("Failed database connection")
  panic(err)
 }

 fmt.Println("Successfully Connected to MySQL database")

 db.SetConnMaxLifetime(time.Minute * 4)
 db.SetMaxOpenConns(10)
 db.SetMaxIdleConns(10)

 err = db.Ping()
 if err != nil {
  return nil, err
 }
 return db, nil
}
// Path ./pkg/encryption/bcrypt.go

package encryption

import (
 "golang.org/x/crypto/bcrypt"
)

func ComparePasswords(hashed, plain string) bool {
 err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plain))
 return err == nil
}

func HashAndSalt(password string) (string, error) {
 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
 if err != nil {
  return "", err
 }
 return string(hash), nil
}
// Path ./pkg/helpers/utils.go

package helpers

// Message api
func Message(code int, message string) map[string]interface{} {
 return map[string]interface{}{"code": code, "message": message}
}

// Message api error
func MessageError(code int, err error) map[string]interface{} {
 return map[string]interface{}{"code": code, "message": err.Error()}
}

// DataResponse api
func DataResponse(code int, message string, data interface{}) map[string]interface{} {
 return map[string]interface{}{"code": code, "message": message, "data": data}
}
// Path ./pkg/uidgen/uidgen.go

package uidgen

import "github.com/google/uuid"

type UIDGen interface {
 New() string
}

type uidgen struct{}

func New() UIDGen {
 return &uidgen{}
}

func (u uidgen) New() string {
 return uuid.New().String()
}

func Parse(value string) (string, error) {
 v, err := uuid.Parse(value)
 if err != nil {
  return "", err
 }
 return v.String(), nil
}

Moreover, we will call the client setup for the database and also call server implementation. We will write this calling in the ./cmd directory.

// Path ./cmd/bootstrap/bootstrap.go

package bootstrap

import (
 "context"
 "fmt"
 "log"

 database "github.com/dany0814/go-hexagonal/internal/adapters/driven"
 "github.com/dany0814/go-hexagonal/internal/core/application"
 "github.com/dany0814/go-hexagonal/internal/platform/server"
 mysqldb "github.com/dany0814/go-hexagonal/internal/platform/storage/mysql"
 "github.com/dany0814/go-hexagonal/pkg/config"
)

func Run() error {

 err := config.LoadConfig()
 if err != nil {
  return err
 }
 fmt.Println("Web server ready!")

 ctx := context.Background()
 db, err := config.ConfigDb(ctx)

 if err != nil {
  log.Fatalf("Database configuration failed: %v", err)
 }

 userRepository := mysqldb.NewUserRepository(db, config.Cfg.DbTimeout)
 userAdapter := database.NewUserAdapter(userRepository)
 userService := application.NewUserService(userAdapter)

 ctx, srv := server.NewServer(context.Background(), config.Cfg.Host, config.Cfg.Port, config.Cfg.ShutdownTimeout, server.AppService{
  UserService: userService,
 })

 return srv.Run(ctx)
}
// Path ./cmd/main.go

package main

import (
 "log"

 "github.com/dany0814/go-hexagonal/cmd/bootstrap"
)

func main() {
 if err := bootstrap.Run(); err != nil {
  log.Fatal(err)
 }
}

We finished our application using hexagonal architecture!!! To run the app, change the directory at ./cmd and execute: go run main. go

You can view the final codebase at:

GitHub - dany0814/go-hexagonal

You can't perform that action at this time. You signed in with another tab or window. You signed out in another tab or…

github.com

Conclusions

  • We need a MySQL engine run in a 3306 port with DbUser: ”admin”, DbPass: “admin” and DbName: ”admin” setup.

  • The name of the directories is optional and for didactic purposes.

  • In the server.go file that implements a specific web technology, we look that depends on our adapter and in turn, our adapter depends on our application through the driver port with an interface and a SignInHandler() method. The following dependency schema verifies our correct implementation of the hexagonal philosophy.
    Echo Web Server-> Adapter-> Application (Port)

  • The configurable dependencies pattern offers an easy way to swap between elements in the real world. For example, using Gin as a web server or PostgreSQL as a database engine.

References