Serverless Lock-in doesn’t exist (if your Team knows Hexagoxal Architecture): an example in Go

Scope

Serverless architecture built on top of the PaaS solutions offered from the different Cloud provider are fantastic.

Nonetheless many Companies remain prudent about it because of their fears about Lock-in.

How to avoid Cloud Lock-In when to decide to develop and deploy your application using one or more Serverless components in AWS, Google Cloud, Azure or another provider?

What is the Hexagonal Architecture and how this architectural style can help you to avoid lock-in when using serverless services on the Cloud?

In this post I’ll try to answer these questions implementing a simple Hexagonal web application in Go(lang), demonstrating how apply this pattern can be a good strategy to avoid or mitigate vendor Lock-In.

What is “Hexagonal Architecture”

There are tons of articles, posts and materials about this argument, starting from the original definition of Ports and Adapters provided by Alistair Cockburn.

I will try to provide a very simple explanation.

According to this architectural style, an application should be divided in different components which are build around a Core component.

The Core is the hearth of the application: it contains the Domain Entities and the business logic of the provided application functionalities, called Use Cases.

The Core component is completed isolated from the external systems/components and its implementation must be independent from the input sources and output destinations.

This means the Core component must provide an ingress to permits the different external input components to access the core functionalities of the application.The ingresses to the Core component are called Input Ports and usually they are implemented as abstract components (usually Interface) which defines how the external components access the internal functionalities.

In a similar way, when the Core component needs to communicate with the external world, it defines specific Output Ports, which defines the way it will communicate with the external components.

The different input sources and output destinations are called Adapter. The Input Adapter are often called Driver Adapter, the Output Adapter are called Driven Adapter)

Basically in our application we have different Input/Output Adapters which communicate with the Core components using Input and Output Ports.

Simple, right?

Software Architecture

But how to map it in a software architecture?

Let’s consider a simple web application which receive http requests from a browser, apply some business logic on the received data and then save the data into one or more repositories.

In this application:

  • HttpController is an input Adapter
  • ServicePort is an interface which expose the Core functionalities of the application
  • Service is an implementation of the ServicePort interface, and contains the business logic of the application
  • RepositoryPort is an interface which defines how my Service component will communicate with a repository
  • SQLRepository and MemoryRepository are implementations of the RepositoryPort interface, providing the repository integration with the different repository infrastructure/technology

But wait, why do I need to design my application in this way?

Benefits

Using the Hexagonal Architecture style provided some well known benefits:

  1. Easier to test in isolation: each layer of the application is easier to test because you can simply mock any dependencies
  2. Independent from external services: you can develop the core functionalities without take care of the external system/technologies used to ingest or store the data
  3. Agnostic to the outside world: the application can interacts with different Drivers without to change its business logic
  4. The Ports and Adapters are replaceable: you can add/remove any ports/adapter without change the internal business logic
  5. Separation of the different rates of change: Adapters and Core components have different evolution
  6. High Maintainability: changes in one area of the application doesn’t really affect others, make it easily to make fast changes of the application

Moving to the Cloud

Now let’s image in the future we change strategy and decide to:

  • migrate our application into the AWS Cloud
  • deploy the application in the AWS Lambda FaaS
  • to use AWS DynamoDB as Serverless NoSql repository

How complex it will be to adapt our Application to this new strategy?

The answer is simple: if we used Hexagonal Architecture during the design of our application, changing the entry point and the repository of our application is simple as write 2 new Adapters, without to modify our Core components!

What if in the future we decides to move our application in Google Cloud using Functions and Firestore Services?

Well, it’s just a matter of write 2 new Adapters:

Talk is Cheap, show me the code!

It’s time to get our hands dirty, so let’s consider a simple mobile App which permits to create sport Team and to invite Players (team-app).
The mobile App interacts with our Back-End Application which expose some simple APIs:

  • to create a New Team
  • to retrieve the List of the Teams
  • to invite a Player to connect to the Team
  • to retrieve the List of the Players of a specific Team

How we could design the back-end application using an Hexagonal Architecture?

First of all we need to define the entry point for our application, which will receive the requests from the mobile App. We will call this component HttpHandler.

The HttpHandler will expose 4 different APIs:

  • /teams (POST with the json body of a Team) → return the new Team
  • /teams (GET) → return an array of Team
  • /teams/:team_id/invite-player (POST with the json body of a Player) → return the new Player
  • /teams/:team_id/players (GET) → return the List of the Players of a Team

It will communicate with the Core components using the interface TeamServicePort which expose the methods:

  • createNewTeam(Team team) → return the new Team
  • listTeams() → return Team[]
  • invitePlayer(Player player) → return the new Player
  • listTeamPlayers(int team_id) → return Player[]

The Core of our Back-end application will contain the implementation of TeamServicePort interface (TeamService), which contains the business logic of the methods exposed by the interface.

In particular the TeamService will apply the following business logic:

  • createNewTeam: store the new Team in the repository and return the saved data
  • listTeams: query the repository in order to retrieve all the stored Teams
  • invitePlayer: save the player information into the repository
  • listTeamPlayers: query the repository in order to retrieve all the stored Players for a given team_id

Let’s implement our Back-End application in Go(lang).

Hexagonal Go Application

Our Go application is organized in different packages:

  • ports (files: team_service_port.go, repository_port.go)
  • core/domain (files: team.go, player.go)
  • core/services (files: team_service.go)
  • adapters (files: http_handler.go, memory_repository.go)
  • main (files: main.go)

— Domain Entities

team.go

type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
Sport string `json:"sport"`
}

player.go

type Player struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email" binding:"required"`
TeamID int64 `json:"team_id"`
}

— Ports

This interface defines the functionalities of our applications (use cases).
The interface is used from the input adapter to communicate with the core component:

team_service_port.go

type TeamServicePort interface {
CreateNewTeam(team domain.Team) (domain.Team, error)
ListTeams() ([]domain.Team, error)
InvitePlayer(player domain.Player) (domain.Player, error)
ListTeamPlayers(team_id int64) ([]domain.Player, error)
}

repository_port.go

This interface defines the functionalities that a repository needs to implement in order to permits our application to store the domain entities.
The interface is used from the Core service in order to persist our Entities:

type RepositoryPort interface {
GetTeams() ([]domain.Team, error)
SaveTeam(team domain.Team) (domain.Team, error)
SavePlayer(member domain.Player) (domain.Player, error)
GetPlayersByTeam(team_id int64) ([]domain.Player, error)
}

— Core Service

This Adapter is the entry point for our http request; it manages the http requests using the interface TeamServicePort as port to communicate with the Core component:

TeamService is the heart of our application; it’s an implementation of the TeamServicePort and contains the business logic of our Back-End application.
The Service uses the repository interface in order to persists the domain entities:

team_service.go

type teamService struct {
repository ports.RepositoryPort
}
func New(repository ports.RepositoryPort) *teamService {
return &teamService{
repository: repository,
}
}
func (srv *teamService) CreateNewTeam(team domain.Team) (domain.Team, error) {
team, err := srv.repository.SaveTeam(team)
if err != nil {
return domain.Team{}, err
}
return team, nil
}
func (srv *teamService) ListTeams() ([]domain.Team, error) {
teams, err := srv.repository.GetTeams()
if err != nil {
return nil, err
}
return teams, nil
}
func (srv *teamService) InvitePlayer(player domain.Player) (domain.Player, error) {
player, err := srv.repository.SavePlayer(player)
if err != nil {
return domain.Player{}, err
}
return player, nil
}
func (srv *teamService) ListTeamPlayers(team_id int64) ([]domain.Player, error) {
players, err := srv.repository.GetPlayersByTeam(team_id)
if err != nil {
return nil, err
}
return players, nil
}

— Adapters

http_handler.go

type httpHandler struct {
service ports.TeamServicePort
}
func NewHttpHandler(service ports.TeamServicePort) *httpHandler {
return &httpHandler{
service: service,
}
}
func (hdl *httpHandler) RetrieveTeams(c *gin.Context) {
teams, err := hdl.service.ListTeams()
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
c.JSON(http.StatusOK, teams)
}
func (hdl *httpHandler) CreateNewTeam(c *gin.Context) {
var input domain.Team
if err := c.ShouldBindJSON(&input); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
team, err := hdl.service.CreateNewTeam(input)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
c.JSON(http.StatusOK, team)
}
func (hdl *httpHandler) ListTeamPlayers(c *gin.Context) {
team_id, err := strconv.ParseInt(c.Param("team_id"), 10, 64)
players, err := hdl.service.ListTeamPlayers(team_id)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
c.JSON(http.StatusOK, players)
}
func (hdl *httpHandler) InvitePlayer(c *gin.Context) {
var input domain.Player
if err := c.ShouldBindJSON(&input); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
player, err := hdl.service.InvitePlayer(input)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
return
}
c.JSON(http.StatusOK, player)
}

memory_repository.go

It’s a simple implementation of a memory repository; it’s used from our core service:

var (
team_counter int64 = 1
player_counter int64 = 1
)
type memoryRepository struct {
inMemoryTeams map[int64]domain.Team
inMemoryPlayers map[int64][]domain.Player
}
func NewMemoryRepository() *memoryRepository {
inMemoryTeams := map[int64]domain.Team{}
inMemoryPlayers := map[int64][]domain.Player{}
return &memoryRepository{inMemoryTeams: inMemoryTeams, inMemoryPlayers: inMemoryPlayers}
}
func (repo *memoryRepository) SaveTeam(team domain.Team) (domain.Team, error) {
if team.ID == 0 {
team.ID = team_counter
team_counter++
}
repo.inMemoryTeams[team.ID] = team
fmt.Println(repo.inMemoryTeams)
return team, nil
}
func (repo *memoryRepository) GetTeams() ([]domain.Team, error) {
var teams []domain.Team = make([]domain.Team, 0)
for _, team := range repo.inMemoryTeams {
teams = append(teams, team)
}
return teams, nil
}
func (repo *memoryRepository) SavePlayer(player domain.Player) (domain.Player, error) {
if player.ID == 0 {
player.ID = player_counter
player_counter++
}
teamPlayers := repo.inMemoryPlayers[player.TeamID]
fmt.Println("teamPlayers", teamPlayers)
teamPlayers = append(teamPlayers, player)
repo.inMemoryPlayers[player.TeamID] = teamPlayers
fmt.Println(repo.inMemoryPlayers)
return player, nil
}
func (repo *memoryRepository) GetPlayersByTeam(team_id int64) ([]domain.Player, error) {
players := repo.inMemoryPlayers[team_id]
if players == nil {
players = []domain.Player{}
}
return players, nil
}

— Main

In order to start our application we need to:

  1. create an instance of the MemoryRepository:
memoryRepository := adapters.NewMemoryRepository()

2. create an instance of the Core Service, injecting our MemoryRepository:

teamService := services.New(memoryRepository)

3. create an instance of the HttpHandler, injecting our teamService implementation:

httpHandler := adapters.NewHttpHandler(teamService)

4. finally to configure our web server routes using the Gin framework (optional):

r := gin.Default()    v1 := r.Group("/api/v1")
{
teams := v1.Group("/teams")
{
teams.GET("", httpHandler.RetrieveTeams)
teams.POST("", httpHandler.CreateNewTeam)
teams.POST(":team_id/invite-player", httpHandler.InvitePlayer)
teams.GET(":team_id/players", httpHandler.ListTeamPlayers)
}
}
r.Run()

This is the entire main file:

main.go

func main() {
memoryRepository := adapters.NewMemoryRepository()
teamService := services.New(memoryRepository)
httpHandler := adapters.NewHttpHandler(teamService)
r := gin.Default() v1 := r.Group("/api/v1")
{
teams := v1.Group("/teams")
{
teams.GET("", httpHandler.RetrieveTeams)
teams.POST("", httpHandler.CreateNewTeam)
teams.POST(":team_id/invite-player", httpHandler.InvitePlayer)
teams.GET(":team_id/players", httpHandler.ListTeamPlayers)
}
}
r.Run()
}

Our Hexagonal is now ready, and if we launch it (with go build main.go) it will expose the following rest endpoint:

GET — http://localhost:8080/api/v1/teams
POST — http://localhost:8080/api/v1/teams
GET — http://localhost:8080/api/v1/teams/:team_id/players
POST — http://localhost:8080/api/v1/teams/:team_id/invite-player

Moving our App to the Cloud

As we discussed before, move our Hexagonal Go Application to the Cloud is simple as to write one or more new Adapters.

Let’s image we want to deploy our Backend App as AWS Lambda; we need just to replace our HttpHandler with one more Lambda Functions, for example one function to manage the Team Entity and another to manage the Player Entity.

That’s all!

For our example which use the Go Gin framework, to minimize the changes we could also use the great AWS Lambda Go API Proxy. You can find an example in the repo (https://github.com/thecillu/hexagonal-architecture/blob/main/aws_lambda_team.go)

What about the repository?

Well, if you need to replace the MemoryRepository with another Serverless components, you need just to write the new adapter implementing the RepositoryPort interface.

What about to change Cloud provider?

Again, you need to write only the new adapters to use the new Provider Serverless services.

Conclusion

Serverless Architecture are really powerful, but if you don’t design your application in the right way, the cost to migrate to another cloud provider in the future could be high.

Hexagonal Architecture is a really simple and clean pattern you should use for your Cloud Native application, it’s not important if it’s deployed on Kubernetes, on a FaaS Service, on VM, or in other container orchestration platforms.

Design your applications following this architectural style will help you to avoid or mitigate the Cloud Lock-In Phobia.

Suorce code

All the source code is available on GitHub: https://github.com/thecillu/hexagonal-architecture

Platform Architect / Cloud Solution Architect

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store