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
“Your application never touches the real world” (Alistair Cockburn, 2005)
“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…
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
The ‘’Adapter’’ pattern: in Gamma, E., Helm, R., Johnson, R., Vlissides, J., ‘’Design Patterns’’, Addison-Wesley, 1995, pp. 139–150.
“Ports and Adapters Pattern (Hexagonal Architecture), Juan Manuel Garrido de Paz, https://jmgarridopaz.github.io/content/hexagonalarchitecture.html
“Hexagonal Architecture”, Alistair Cockburn, 2005, https://alistair.cockburn.us/hexagonal-architecture/
“Go http api course”, Codelytv, https://github.com/CodelyTV/go-hexagonal_http_api-course