Hexagonal Architecture, also known as Ports and Adapters pattern, stands out as a powerful pattern that promotes clean, maintainable, and testable code.
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.
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.
The Hexagonal Architecture places the core business logic at the heart of the system, independent of any external technologies, libraries or frameworks.
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 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).
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
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:
It's very simple:
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.
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}
}
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)
})
I have 2 implementation of Storage interface:
// 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
}
// 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
}
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.
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
Comments