Implementing Hexagonal Architecture in Go

In this section, we will walk through the implementation of Hexagonal Architecture in a Go application, focusing on defining ports and adapters and integrating external services.

Project Overview

We will build a simple blog application that supports creating and retrieving posts. The architecture will ensure that the core business logic is isolated from external systems, making the application more modular, maintainable, and testable.

Project Structure

The project structure will be organized to reflect Hexagonal Architecture principles, with distinct directories for core business logic, ports, and adapters.

go
myapp/ │ ├── cmd/ │ └── myapp/ │ └── main.go ├── internal/ │ ├── adapters/ │ │ ├── http/ │ │ │ └── post_handler.go │ │ ├── repositories/ │ │ │ └── post_repository.go │ ├── core/ │ │ ├── domain/ │ │ │ └── post.go │ │ └── ports/ │ │ └── post_repository.go │ ├── services/ │ │ └── post_service.go ├── pkg/ │ └── config/ │ └── config.go └── go.mod

Step-by-Step Implementation

1. Domain Model

First, define the core business entity, which in this case is a Post.

go
// internal/core/domain/post.go package domain type Post struct { ID int Title string Content string }

2. Ports

Define the interfaces for the repository. These ports serve as the contract between the core business logic and the external systems.

go
// internal/core/ports/post_repository.go package ports import "myapp/internal/core/domain" type PostRepository interface { GetByID(id int) (*domain.Post, error) Save(post *domain.Post) error }

3. Services

Implement the business logic using the defined ports. The service interacts with the repository via the port interface.

go
// internal/services/post_service.go package services import ( "myapp/internal/core/domain" "myapp/internal/core/ports" ) type PostService struct { Repo ports.PostRepository } func NewPostService(repo ports.PostRepository) *PostService { return &PostService{Repo: repo} } func (s *PostService) GetPost(id int) (*domain.Post, error) { return s.Repo.GetByID(id) } func (s *PostService) CreatePost(post *domain.Post) error { return s.Repo.Save(post) }

4. Repositories (Adapters)

Implement the repository to interact with the database. This adapter fulfills the contract defined by the PostRepository port.

go
// internal/adapters/repositories/post_repository.go package repositories import ( "database/sql" "myapp/internal/core/domain" "myapp/internal/core/ports" ) type PostGORMRepo struct { DB *sql.DB } func NewPostGORMRepo(db *sql.DB) *PostGORMRepo { return &PostGORMRepo{DB: db} } func (repo *PostGORMRepo) GetByID(id int) (*domain.Post, error) { post := &domain.Post{} row := repo.DB.QueryRow("SELECT id, title, content FROM posts WHERE id = ?", id) err := row.Scan(&post.ID, &post.Title, &post.Content) if err != nil { return nil, err } return post, nil } func (repo *PostGORMRepo) Save(post *domain.Post) error { _, err := repo.DB.Exec("INSERT INTO posts (title, content) VALUES (?, ?)", post.Title, post.Content) return err }

5. HTTP Handlers (Adapters)

Implement the HTTP handlers to handle web requests. These adapters convert HTTP requests into calls to the core business logic.

go
// internal/adapters/http/post_handler.go package http import ( "encoding/json" "myapp/internal/core/domain" "myapp/internal/services" "net/http" "strconv" ) type PostHandler struct { Service *services.PostService } func NewPostHandler(service *services.PostService) *PostHandler { return &PostHandler{Service: service} } func (h *PostHandler) GetPost(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.URL.Query().Get("id")) if err != nil { http.Error(w, "Invalid ID", http.StatusBadRequest) return } post, err := h.Service.GetPost(id) if err != nil { http.Error(w, "Post not found", http.StatusNotFound) return } json.NewEncoder(w).Encode(post) } func (h *PostHandler) CreatePost(w http.ResponseWriter, r *http.Request) { var post domain.Post if err := json.NewDecoder(r.Body).Decode(&post); err != nil { http.Error(w, "Invalid input", http.StatusBadRequest) return } if err := h.Service.CreatePost(&post); err != nil { http.Error(w, "Could not create post", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) }

6. Database Connection

Set up the database connection.

go
// pkg/config/config.go package config import ( "os" ) func GetEnv(key string, defaultValue string) string { value := os.Getenv(key) if value == "" { return defaultValue } return value }

7. Main Application

Wire everything together in the main application.

go
// cmd/myapp/main.go package main import ( "database/sql" "myapp/internal/adapters/http" "myapp/internal/adapters/repositories" "myapp/internal/core/ports" "myapp/internal/services" "myapp/pkg/config" "net/http" _ "github.com/go-sql-driver/mysql" ) func main() { dsn := config.GetEnv("MYSQL_DSN", "user:password@tcp(127.0.0.1:3306)/myapp") db, err := sql.Open("mysql", dsn) if err != nil { panic(err) } postRepo := repositories.NewPostGORMRepo(db) postService := services.NewPostService(postRepo) postHandler := http.NewPostHandler(postService) http.HandleFunc("/post", postHandler.GetPost) http.HandleFunc("/create", postHandler.CreatePost) if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) } }

Integrating External Services

Integrating external services (such as third-party APIs or other microservices) follows the same principles of Hexagonal Architecture. Define ports for the interactions and create adapters to handle the specifics of the external service communication.

Example: Integrating a Notification Service

Let's say we want to send a notification whenever a new post is created. We will define a port for the notification service and implement an adapter to interact with a hypothetical external notification service.

  1. Define the Port
go
// internal/core/ports/notification_service.go package ports type NotificationService interface { SendNotification(message string) error }
  1. Implement the Adapter
go
// internal/adapters/notification/email_notification.go package notification import ( "fmt" "myapp/internal/core/ports" ) type EmailNotificationService struct { SMTPServer string Port int Username string Password string } func NewEmailNotificationService(smtpServer string, port int, username, password string) *EmailNotificationService { return &EmailNotificationService{ SMTPServer: smtpServer, Port: port, Username: username, Password: password, } } func (s *EmailNotificationService) SendNotification(message string) error { // Simplified example of sending an email notification fmt.Printf("Sending email notification: %s\n", message) return nil }
  1. Update the Service to Use the Notification Port
go
// internal/services/post_service.go package services import ( "myapp/internal/core/domain" "myapp/internal/core/ports" ) type PostService struct { Repo ports.PostRepository Notification ports.NotificationService } func NewPostService(repo ports.PostRepository, notification ports.NotificationService) *PostService { return &PostService{ Repo: repo, Notification: notification, } } func (s *PostService) GetPost(id int) (*domain.Post, error) { return s.Repo.GetByID(id) } func (s *PostService) CreatePost(post *domain.Post) error { err := s.Repo.Save(post) if err != nil { return err } return s.Notification.SendNotification("New post created: " + post.Title) }
  1. Wire Everything in the Main Application
go
// cmd/myapp/main.go package main import ( "database/sql" "myapp/internal/adapters/http" "myapp/internal/adapters/notification" "myapp/internal/adapters/repositories" "myapp/internal/core/ports" "myapp/internal/services" "myapp/pkg/config" "net/http" _ "github.com/go-sql-driver/mysql" ) func main() { dsn := config.GetEnv("MYSQL_DSN", "user:password@tcp(127.0.0.1:3306)/myapp") db, err := sql.Open("mysql", dsn) if err != nil { panic(err) } postRepo := repositories.NewPostGORMRepo(db) emailService := notification.NewEmailNotificationService("smtp.example.com", 587, "user", "pass") postService := services.NewPostService(postRepo, emailService) postHandler := http.NewPostHandler(postService) http.HandleFunc("/post", postHandler.GetPost) http.HandleFunc("/create", postHandler.CreatePost) if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) } }

Conclusion

By following the principles of Hexagonal Architecture, you can create a Go application that is modular, maintainable, and testable. The core business logic is decoupled from external systems through well-defined interfaces (ports) and concrete implementations (adapters). Integrating external services becomes straightforward, as you can add new adapters without modifying the core logic. This approach promotes clean code, better testability, and flexibility in adapting to changing requirements.

Becoming a Senior Go Developer: Mastering Go and Its Ecosystem