DEV Community

thecillu
thecillu

Posted on

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, and 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.

Hexagonal Architecture

The Core component is completed isolated from the external system/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.

Hexagonal SW Architecture

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 service
  • to use AWS DynamoDB as Serverless NoSql repository

How complex 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 repository of our application is simple as to write 2 new Adapters, without to modify our Core components!

AWS

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:

GCP

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 come simple APIs:

  • to create a New Team
  • to retrieve the List of the Teams
  • to invite a Player to connect to the Team
  • to retrive 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.

Mobile-App

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.

Go Application

Our Go application is organized in different packages:

  • ports
    • team_service_port.go
    • repository_port.go
  • core
    • domain
      • team.go
      • player.go
    • services
      • team_service.go
  • adapters
    • http_handler.go
    • memory_repository.go
  • main.go
Domain Entities

team.go

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

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"`
}
Enter fullscreen mode Exit fullscreen mode
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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode
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)
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode
Application Main

In order to start our application we need to create an instance of the MemoryRepository:

memoryRepository := adapters.NewMemoryRepository()
Enter fullscreen mode Exit fullscreen mode

Then we need to create an instance of the Core Service (injecting our MemoryRepository)...

teamService := services.New(memoryRepository)
Enter fullscreen mode Exit fullscreen mode

...and an instance of the HttpHandler (injecting our teamService implementation):

httpHandler := adapters.NewHttpHandler(teamService)
Enter fullscreen mode Exit fullscreen mode

Finally we can configure our web server routes (using the Gin framework):

    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()
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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 GO Application to the Cloud is as 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 of lambda function in the article repository

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

Hexagonal Architecture is a really simple and clean pattern you should use for your Cloud Native application, it's not important if it runs on K8S, on a FaaS Service, on VM, or in other containerized platforms.

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

Suorce code

You can find the source code here:

EXAMPLE OF HEXAGONAL ARCHITECTURE IN GO

Simple Go Application designed using the Hexagonal Architecture pattern.

How to run

To run just clone the repo and the run "go run ." inside the project folder

REST Endpoints

The app expose few endpoints:

GET /api/v1/teams

POST /api/v1/teams

POST /api/v1/teams/:team_id/invite-player

GET /api/v1/teams/:team_id/players

on localhost:8080.

Top comments (0)