How to save money with hexagonal architecture and SST

matyv3

Maty

Posted on February 25, 2023

How to save money with hexagonal architecture and SST

With the microservices trending as a way to go for many software companies, many of them are choosing to deploy them in containers with the help of docker or kubernetes.
There are also solutions like ECS (Elastic Container Service) with AWS or Cloud Run with Google Cloud which make it easier to manage and you don’t have to worry about management of the infrastructure for many applications (sort of).

The problem with many of this solutions that are based in containerized applications with Docker is the pricing. The cost of deploy all these in AWS or GCP can be quite big for many startups or companies than just don’t want to pay that amount for money for this.
The other issue is that many of these microservices are used from time to time, and you are paying for a 24/7 running service for nothing.

SST at the rescue

SST is a framework for deploying serverless applications to AWS and is built on top of CDK. This means that for example, when you develop a lambda, you can define the entire AWS architecture right in the same project. It also allows to live testing these apps at the development stage with real AWS configuration like the production release.
This is incredible powerful and very easy to manage, especially when you have hundreds of microservices.
They also have an awesome documentation: sst.dev

The other key feature of SST is that it allows you to define the configuration of your project with Typescript, like CDK is used to, while at the same time develop the application with another language. This is awesome for us given that we use Go for our projects.

So how do you go from Docker to Lambda without rewriting the entire application?

Hexagonal Architecture

Diagram explained by Herberto Graca in his post

Nowadays hexagonal architecture is quite common in the software industry, it has many advantages while developing a long term application especially if it’s gonna be a big monolith.

I won’t talk about how hexagonal architecture works, but if you’ve never work with it, it basically allows you to organize your project in a way that your business logic is isolated from the “technical things” like databases, entry points like HTTP endpoints, queues and events. That’s why is awesome for our case, all these external things will be managed by SST.

If you want to know more about hexagonal architecture there are a lot of info around internet, but in this post you have a very detailed explanation: https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/

The project

For this example we’ll start with an already “production running” Go project that uses hexagonal architecture. The project is just a TODO list service with two http endpoints, one for creating a todo and one for getting all todos:


├── api
│ └── server.go
├── core
│ ├── domain
│ │ └── todo.go
│ ├── handlers
│ │ └── http
│ │ └── handler.go
│ ├── ports
│ │ └── todo.go
│ ├── repositories
│ │ └── todo_repository.go
│ └── services
│ └── todo_service.go
├── go.mod
├── go.sum
└── main.go

So here, our “core” folder will keep all our ports, adapters, and definitions for our todo list project. At the same level where the “api” folder is, we’ll be all our entry points for the application, for example we could also have a folder called “jobs” or “cli”.
And yes, this is just an example, for a real use case you can organize your project in a better way.

The project uses echo for serve as an http server, and we saved the config at the api/server.go file:

package api

import (
 "fmt"

 "github.com/labstack/echo"
 httphandler "github.com/matyv3/hexagonal-go-sst/core/handlers/http"
 "github.com/matyv3/hexagonal-go-sst/core/repositories"
 "github.com/matyv3/hexagonal-go-sst/core/services"
)

func StartHTTPServer() {
 fmt.Println("starting http server...")

 repository := repositories.CreateTODORepository()
 service := services.CreateTODOService(repository)
 controller := httphandler.CreateHTTPController(service)

 server := echo.New()
 // Routes
 server.GET("/todos", controller.GetTODOS)
 server.POST("/todos", controller.CreateTODO)

 // Start server
 server.Logger.Fatal(server.Start(":4000"))
}
Enter fullscreen mode Exit fullscreen mode

And our main.go file just calls the server start:

package main

import "github.com/matyv3/hexagonal-go-sst/api"

func main() {
 api.StartHTTPServer()
}
Enter fullscreen mode Exit fullscreen mode

As we saw in the hexagonal architecture definition, the flow of control goes from the “user interface” (in this case an http endpoint) all through to the data access layer, and back to it.

So for example, for the GET todos method, our current control flow looks like this:
http route -> http handler -> todo service -> todo repository

Flow of control

The http handler handles the incoming request handled by echo:

package httphandler

import (
 "net/http"

 "github.com/labstack/echo/v4"
 "github.com/matyv3/hexagonal-go-sst/core/domain"
 "github.com/matyv3/hexagonal-go-sst/core/ports"
)

type HTTPController struct {
 service ports.TODOService
}

func CreateHTTPController(service ports.TODOService) HTTPController {
 return HTTPController{
  service: service,
 }
}

func (c HTTPController) CreateTODO(ctx echo.Context) error {
 todo := new(domain.TODO)
 if err := ctx.Bind(todo); err != nil {
  return echo.NewHTTPError(http.StatusBadRequest, "There is an error with the body format").SetInternal(err)
 }

 result, err := c.service.CreateTODO(*todo)
 if err != nil {
  return echo.NewHTTPError(http.StatusBadRequest).SetInternal(err)
 }

 return ctx.JSON(201, result)
}

func (c HTTPController) GetTODOs(ctx echo.Context) error {
 todos, err := c.service.GetTODOs()
 if err != nil {
  return echo.NewHTTPError(http.StatusBadRequest).SetInternal(err)
 }

 return ctx.JSON(200, todos)
}
Enter fullscreen mode Exit fullscreen mode

After that, the http handler calls the service, which has the “business logic” related to each todo action.

package services

import (
 "github.com/matyv3/hexagonal-go-sst/core/domain"
 "github.com/matyv3/hexagonal-go-sst/core/ports"
)

type TODOService struct {
 repository ports.TODORepository
}

func CreateTODOService(repository ports.TODORepository) ports.TODOService {
 return TODOService{
  repository: repository,
 }
}

func (s TODOService) CreateTODO(todo domain.TODO) (domain.TODO, error) {
 todo.Status = "pending"
 err := s.repository.CreateTODO(todo)
 if err != nil {
  return domain.TODO{}, err
 }
 return todo, nil
}

func (s TODOService) GetTODOs() ([]domain.TODO, error) {
 todos, err := s.repository.GetTODOs()
 if err != nil {
  return []domain.TODO{}, err
 }
 return todos, nil
}
Enter fullscreen mode Exit fullscreen mode

And finally, the service calls the repository. Usually, the repository would make something like an SQL query, or make an external http request. For this case we’re gonna use a simple array as our database, remember, the implementation isn’t important now.

package repositories

import (
 "github.com/matyv3/hexagonal-go-sst/core/domain"
 "github.com/matyv3/hexagonal-go-sst/core/ports"
)

var todos []domain.TODO = []domain.TODO{
 {
  Title:       "first todo",
  Description: "description",
  Status:      "done",
 },
}

type TODORepository struct{}

func CreateTODORepository() ports.TODORepository {
 return TODORepository{}
}

func (r TODORepository) CreateTODO(todo domain.TODO) error {
 todos = append(todos, todo)
 return nil
}

func (r TODORepository) GetTODOs() ([]domain.TODO, error) {
 return todos, nil
}
Enter fullscreen mode Exit fullscreen mode

Now we cant test it by running go run . and making some requests

Image description

Now we can create a TODO:

Image description

And we can get all the TODOs:

Image description

Adding SST

Before we start, you must have an AWS account, if you don’t have any, you can create one and use the free tier for this project.

The official documentation of SST doesn’t explain how to install it in an existing project, just how to start from scratch. But it does have a documentation explaining how to migrate from other project.
As we are “migrating” our existing golang project to a serverless approach, we’re going to follow this. Also, it’s most likely that if you are reading this, you already have a working application.

So as the documentation says, the first thing we have to do is create a fresh SST project, and then copy all the config files to ours:

  • Run npx create-sst sst
  • Choose “minimal” as our kind of project
  • And for our case we’ll choose “minimal/go-starter”.

This will create something like this:

Image description

From this we’re going to copy to copy all files except from the services folder.
If you already have a .gitignore file in your project, make sure to append the sst one to yours.
Also, in the sst.json file, change the name to your project name.
So our project should look something like this:

Image description

After you copy all this, make shure to run npm install

Now we’re going to create a folder where we’re going to put all our lambdas entrypoints in our “handlers” folders, let’s call it sst.
In here, we’re going to create a folder for each “action”, such as “create” or “get”, then in here we’ll have a single file called main.go. This is important given that with golang you must have a main.go file with a main function for the entrypoint.
If you are wondering for the already existing main.go file at the root folder, this is the “old” one we used for runnig the app with go run .and in useless for sst, remember sst is going to pack a lambda for each route, so is like we have a project for each lambda.
Hope it makes sense :D

Image description

Now we’ve created this, in our stacks/index.ts file we’re going to change the srcPath to our application folder is, which in this case is called core

import { MyStack } from "./MyStack";
import { App } from "@serverless-stack/resources";

export default function (app: App) {
  app.setDefaultFunctionProps({
    runtime: "go1.x",
    srcPath: "core",
  });
  app.stack(MyStack);
}
Enter fullscreen mode Exit fullscreen mode

Before we follow configuring the routes, we need to install a few packages in order to work with aws sdk:

go get github.com/aws/aws-lambda-go/lambda
go get github.com/aws/aws-lambda-go/events
Enter fullscreen mode Exit fullscreen mode

Now we can add the handlers, our core/handlers/sst/create/main.go file should look something like this:

package main

import (
 "encoding/json"

 "github.com/aws/aws-lambda-go/events"
 "github.com/aws/aws-lambda-go/lambda"
 "github.com/matyv3/hexagonal-go-sst/core/domain"
 "github.com/matyv3/hexagonal-go-sst/core/repositories"
 "github.com/matyv3/hexagonal-go-sst/core/services"
)

func Handler(request events.APIGatewayV2HTTPRequest) (events.APIGatewayProxyResponse, error) {
 repository := repositories.CreateTODORepository()
 service := services.CreateTODOService(repository)

 todo := new(domain.TODO)
 err := json.Unmarshal([]byte(request.Body), &todo)
 if err != nil {
  return events.APIGatewayProxyResponse{}, err
 }
 result, err := service.CreateTODO(*todo)
 if err != nil {
  return events.APIGatewayProxyResponse{}, err
 }

 response, err := json.Marshal(result)
 if err != nil {
  return events.APIGatewayProxyResponse{}, err
 }

 return events.APIGatewayProxyResponse{
  Body:       string(response),
  StatusCode: 201,
 }, nil
}

func main() {
 lambda.Start(Handler)
}
Enter fullscreen mode Exit fullscreen mode

And for the get/main.go file:

package main

import (
 "encoding/json"

 "github.com/aws/aws-lambda-go/events"
 "github.com/aws/aws-lambda-go/lambda"
 "github.com/matyv3/hexagonal-go-sst/core/repositories"
 "github.com/matyv3/hexagonal-go-sst/core/services"
)

func Handler(request events.APIGatewayV2HTTPRequest) (events.APIGatewayProxyResponse, error) {
 repository := repositories.CreateTODORepository()
 service := services.CreateTODOService(repository)

 result, err := service.GetTODOs()
 if err != nil {
  return events.APIGatewayProxyResponse{}, err
 }

 response, err := json.Marshal(result)
 if err != nil {
  return events.APIGatewayProxyResponse{}, err
 }

 return events.APIGatewayProxyResponse{
  Body:       string(response),
  StatusCode: 201,
 }, nil
}

func main() {
 lambda.Start(Handler)
}
Enter fullscreen mode Exit fullscreen mode

Now in our stacks/MyStack.ts file we can set up our routes

import { StackContext, Api } from "@serverless-stack/resources";

export function MyStack({ stack }: StackContext) {
  const api = new Api(stack, "api", {
    routes: {
       "GET /todos": "handlers/sst/get/main.go",
       "POST /todos": "handlers/sst/create/main.go",
    },
  });
  stack.addOutputs({
    ApiEndpoint: api.url,
  });
}}
Enter fullscreen mode Exit fullscreen mode

Running the app

So we should have all set up now, we can run the SST project with the npx sst start command, this should deploy all the lambdas to aws, and create all the resources that you put in the SST config.
Once the deploy has finished, SST will give a temporary url for testing your local application:

Image description

Here are some examples testing it with postman:

Image description

Image description

Conclusion

Adding SST to an existing project can be tricky, specially if you work with golang as I did, but it’s worth the try if you’re goal is to save money.
Even if you have a really big project, you can migrate just some routes instead of migrating the entire project.
I used hexagonal architecture for this project because is what i’m used to work with, and I think is an awesome for isolating your business logic from technical things like this, as you can see, we never change our “business logic” layer.

You can check this project at Github: https://github.com/matyv3/hexagonal-go-sst

Hope this post helps you migrating to serverless with SST, happy coding! 🚀 🚀 🚀

💖 💪 🙅 🚩
matyv3
Maty

Posted on February 25, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related