Logo

Software Design with Hexagonal Architecture in Go

Hexagonal Architecture, also known as Ports and Adapters pattern, stands out as a powerful pattern that promotes clean, maintainable, and testable code.

avatar
Ngoc TruongPublished on February 02, 2024
loading

Introduction

The terms Clean architecture and Hexagonal Architecture are very popular nowadays, but people blindly implement the same structure and solution in the OOP languages like Java and C# everywhere. This eventually led to projects that were hard to maintain, code that unreadable, and the community refused to use these Java-like solution.

In this blog post, let's find the answer from a different angle. We'll delve into the fundamentals, advantages, and implement application using Hexagonal Architecture with idiomatic Go.

Understanding Hexagonal Architecture

The architecture emphasizes the separation of concerns in software design. The fundamental idea is treat the business logic as the core of everything in the system, separate it from external concerns, such as databases, user interfaces, and external services.

hex-arch

Core Business Logic

The Hexagonal Architecture places the core business logic at the heart of the system, independent of any external technologies, libraries or frameworks.

hexagon

This layer manage the application independent business rules. They encapsulate the most general and high-level logic. It's maybe the entities and value object that contain behaviors. External system or framework change does not affected to the core layer.

Ports and Adapters:

Ports are just the interfaces, which represent the interactions between the core logic and external systems.

Implement adapters using concrete types that connect the core logic to databases, HTTP servers, or any external dependencies. The left of the hexagon is called driver adapters other side is called driven adapters.

Also, the driver adapters (adapters from the left of the hexagon) have the responsibility for managing another very crucial type of logic which is application-specific business rule (Uncle Bob mentioned many times in his clean architecture blog & presentations).

Advantages

Testability:

Go's testing capabilities combined with Hexagonal Architecture make it easy to write unit tests for the core business logic, ensuring the application's reliability.

Flexibility and Adaptability:

Go's simplicity and flexibility make it easy to swap out adapters, allowing the application to adapt to changes in external systems or technologies.

Clean and Readable Code:

The straightforward syntax and idioms of Go contribute to clean and readable code, enhancing the maintainability of the application.
Full code at github repo

The Sample Application

Let's get your hand dirty with the sample application. I've written an example using the hexagonal architecture and also include test suite as well

The example contain 2 APIs:

  • POST /api/books - Create a book
  • GET /api/books/:isbn - Get a book info by isbn

Component Level Architecture

hexagonal-sample

It's very simple:

  • We have an adapter (Handler) to deals with HTTP request and response, provide REST api endpoints.
  • Handler depend on the core domain (Book) and define an interface to working with the storage.
  • We also have 2 adapters as the implementation for the Storage interface show you the flexibility and interchangeability of the architecture, of course secure by tests (all the tests is not included in the blog content, so please check github repo).

Many implementations of Hexagonal Architecture are frequently used service layer (someone calls them usecases or interactors). I decided not to include a service layer in my implementation. First, because simplicity matters, a service layer would have been causing an unnecessary distraction. Second, people usually overuse the service layer to make a big service class that domain-logic and application-logic live in the same place. This eventually turn domain model to an Anemic Domain Model, often described as an anti-pattern in OOP.

But my idea is not limited to 2 layers, feel free to add a service layer to your application when needed. But remember, strive for simplicity in Go application.

The core

Let's start with the core hexagon. I defined an entity called Book and the Storage interface in the book domain.

// domain/book/book.go

type Storage interface {
	Save(book *Book) error
	FindByISBN(isbn string) (*Book, error)
}

type Book struct {
	ISBN string
	Name string
}

func NewBook(isbn string, name string) Book {
	return Book{ISBN: isbn, Name: name}
}

HTTP Server Adapter

Here the server adapter provides the REST API endpoints. I use the book.Storage interface to decouple code of the http server from the storage implementation gives us the ability to write unit tests without the real storage engine needed.

// adapters/httpserver/server.go

type Server struct {
	Router *echo.Echo
	// storage adapters
	BookStore book.Storage
}

func New() (*Server, error) {
	s := Server{Router: echo.New()}

	s.Router.POST("/api/books", s.CreateBook)
	s.Router.GET("/api/books/:isbn", s.GetBook)

	return &s, nil
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	s.Router.ServeHTTP(w, r)
}

func (s *Server) handleError(c echo.Context, err error, status int) error {
	log.Println(err)
	return c.JSON(status, map[string]string{
		"message": http.StatusText(status),
	})
}

Sample tests with testify mock

t.Run("return status 201 after created bookTest", func(t *testing.T) {
    mockStore.On("Save", &bookTest).Return(nil).Times(1)

    response := httptest.NewRecorder()
    ctx := echo.New().NewContext(newCreateBookRequest(bookTest), response)

    err := server.CreateBook(ctx)

    assert.NoError(t, err)
    assert.Equal(t, http.StatusCreated, response.Code)
    mockStore.AssertExpectations(t)
})

Storage Adapters

I have 2 implementation of Storage interface:

  • An in memory storage using the BadgerDB (https://github.com/dgraph-io/badger)
// adapters/inmemstore/book_store.go

type BookStore struct {
	db *badger.DB
}

func NewBookStore(db *badger.DB) *BookStore {
	return &BookStore{db}
}

func (b *BookStore) Save(data *book.Book) error {
	key := []byte(data.ISBN)
	value := []byte(data.Name)
	return b.db.Update(func(txn *badger.Txn) error {
		return txn.Set(key, value)
	})
}

func (b *BookStore) FindByISBN(isbn string) (*book.Book, error) {
	result := book.Book{}
	err := b.db.View(func(txn *badger.Txn) error {
		item, err := txn.Get([]byte(isbn))
		if err != nil {
			return err
		}

		_ = item.Value(func(val []byte) error {
			result.ISBN = string(item.Key())
			result.Name = string(val)
			return nil
		})

		return nil
	})
	if err != nil {
		return nil, err
	}

	return &result, nil
}
  • The postgresql implementation
// adapters/postgrestore/book_store.go

type BookStore struct {
	db *sqlx.DB
}

func NewBookStore(db *sqlx.DB) *BookStore {
	return &BookStore{db}
}

func (s *BookStore) Save(b *book.Book) error {
	_, err := s.db.Exec(`INSERT INTO books(isbn,name) VALUES ($1,$2)`, b.ISBN, b.Name)
	if err != nil {
		return fmt.Errorf("cannot save the book: %w", err)
	}
	return nil
}

func (s *BookStore) FindByISBN(isbn string) (*book.Book, error) {
	var result BookQuerySchema
	err := s.db.Get(&result, `SELECT isbn,name FROM books WHERE isbn=$1`, isbn)
	if err != nil {
		return nil, fmt.Errorf("cannot get the book '%s': %w", isbn, err)
	}

	b := book.NewBook(result.ISBN, result.Name)
	return &b, nil
}

The application

Of course, we have the main application, the dirtiest component in our system. Let's compose all the components and run it.

And you can switching between the implementations of the Storage and play with it :).

// cmd/httpserver/main.go

func main() {
	cfg, err := config.LoadConfig()
	if err != nil {
		log.Fatal(err)
	}

	db, err := postgrestore.NewConnection(postgrestore.ParseFromConfig(cfg))
	if err != nil {
		log.Fatal(err)
	}

	server, err := httpserver.New()
	if err != nil {
		log.Fatal(err)
	}
	server.BookStore = postgrestore.NewBookStore(db)

	//db, err := inmemstore.NewConnection()
	//server.BookStore = inmemstore.NewBookStore(db)

	addr := fmt.Sprintf(":%d", cfg.Port)
	log.Println("server started!")
	log.Fatal(http.ListenAndServe(addr, server))
}

Now start the application, you'll see the result

λ  curl -i -X POST -d '{"isbn":"9781642001396","name":"Go Succinctly"}' -H 'Content-Type: application/json' http://localhost:8088/api/books

HTTP/1.1 201 Created
Date: Tue, 30 Jan 2024 08:06:45 GMT
Content-Length: 0
λ  curl -i -X GET -H 'Content-Type: application/json' http://localhost:8088/api/books/9781642001396 
                                       
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Date: Thu, 01 Feb 2024 03:18:22 GMT
Content-Length: 48

{"ISBN":"9781642001396","Name":"Go Succinctly"}

Please check the github repo and following the README.

Conclusion

Hexagonal Architecture offers a holistic approach to software design, prioritizing flexibility, maintainability, and testability.
Keeping the core business logic at the center and surrounding it with ports and adapters, developers can create robust, scalable, and easily maintainable software systems using Go. As technology continues to change, we will have the confident to update part of the system when it obsoleted with the minimum effor

0
1
159
share post

Comments

avatar