Hexagonal Architecture in Golang: Project Structure, Example & Best Practices

Hexagonal Architecture in Golang: Project Structure, Example & Best Practices

What is Hexagonal Architecture in Golang?

Hexagonal Architecture in Golang (also known as Ports and Adapters) is a design pattern that separates your core business logic from external systems like databases, APIs, and frameworks.

Instead of tightly coupling your application to tools like HTTP frameworks or databases, this architecture ensures that your domain logic remains independent, testable, and flexible.

This becomes extremely useful when:

  • You want to switch databases (e.g., PostgreSQL → Redis)
  • You want to change frameworks (e.g., Gin → Fiber)
  • You want better unit testing without external dependencies

Why traditional layered architecture fails

In traditional layered architecture:

  • Business logic depends on database layer
  • Frameworks influence core logic
  • Tight coupling makes changes difficult

Common problems:

  • ❌ Hard to test without database
  • ❌ Difficult to replace external systems
  • ❌ Changes ripple across layers
  • ❌ Business logic gets polluted with infrastructure code

How hexagonal architecture solves real-world problems

Hexagonal architecture solves this by:

  • Keeping business logic at the center
  • Using interfaces (ports) to define behavior
  • Using adapters to connect external systems

Key benefits:

  • ✅ Loose coupling
  • ✅ Easy testing (mock ports)
  • ✅ Technology independence
  • ✅ Cleaner codebase

Hexagonal Architecture Explained (Ports and Adapters)

Visual Diagram

Hexagonal Architecture in Golang

Understanding the Diagram

The diagram represents how different parts of the application interact while keeping the core isolated.

1. Core Business Logic (Center Hexagon)

This is the heart of your application.

It contains:

  • Domain models (e.g., Message struct)
  • Business rules
  • Use cases

👉 Important rule: This layer must NOT depend on any external system.

2. Ports (Interfaces / Contracts)

Ports define how the core communicates with the outside world.

Examples in Go:

  • MessengerService
  • MessengerRepository

👉 Think of ports as: "Contracts that adapters must implement"

3. Primary Adapters (Inbound)

These handle incoming requests.

Examples:

  • HTTP API (Gin)
  • gRPC server
  • CLI commands

Flow: User → Adapter → Core

👉 Their job:

  • Convert external request → domain format
  • Call core services

4. Secondary Adapters (Outbound)

These interact with external systems.

Examples:

  • PostgreSQL
  • Redis
  • External APIs

Flow: Core → Adapter → External system

👉 Their job:

  • Implement interfaces defined in ports
  • Handle data storage or external communication

5. External Systems (Outer Layer)

These are things your app depends on:

  • Databases
  • APIs
  • Messaging systems

👉 Important: They are replaceable without affecting core logic

Request Flow Explained

text
User Request
Primary Adapter (HTTP / gRPC)
Port Interface (Service)
Core Business Logic
Port Interface (Repository)
Secondary Adapter (DB / Redis)
Response back to user

Why this diagram matters for interviews and real projects

This exact flow is what interviewers expect when you explain:

  • Clean architecture
  • Microservices design
  • Scalable backend systems

👉 If you understand this diagram, you understand: 80% of system design fundamentals


Golang Hexagonal Architecture Project Structure

A well-structured project layout is essential when implementing Hexagonal Architecture in Golang. Many tutorials show code, but fail to explain where things belong — which is exactly what users search for.

Below is a production-ready structure that clearly separates business logic from external systems.

go
├── cmd/
   └── main.go                # Entry point
├── internal/
   ├── core/
      ├── domain/            # Business models
      ├── ports/             # Interfaces (contracts)
      └── services/          # Business logic
   ├── adapters/
      ├── handler/           # HTTP / gRPC (primary adapters)
      └── repository/        # DB / cache (secondary adapters)
├── go.mod

This structure ensures:

  • Core logic is isolated and independent
  • External systems are replaceable
  • Codebase remains clean and scalable

The most important part is understanding what goes inside each layer.

What goes inside domain, ports, adapters

The domain layer contains your core business entities and rules.
It should be pure Go code with no dependency on frameworks or databases.

The ports layer defines interfaces that describe how the core interacts with the outside world. These are contracts, not implementations.

The services layer contains business use cases. It connects domain logic with ports, but still does not depend on any external system.

The adapters layer is where actual implementations live. This includes HTTP handlers, database repositories, or integrations with external APIs.

A simple way to remember:

  • Domain → What your app does
  • Ports → How your app communicates
  • Adapters → Where external logic lives

The direction of dependency is always inward — adapters depend on core, not the other way around.

Common mistakes to avoid:

  • Mixing database logic inside services
  • Importing frameworks (like Gin) inside domain
  • Skipping interfaces and directly calling repositories
  • Writing business logic inside handlers

If you follow the structure correctly, your application becomes easier to test, extend, and maintain.


Build a Real-World Example (Messaging Service)

To understand how this works in practice, let’s walk through a messaging service example.

Instead of focusing only on code, it’s more important to understand how data flows through the system.

Step-by-step flow (request → domain → repository)

When a client sends a request:

  1. The request reaches the HTTP handler (Gin)
  2. The handler validates and parses input
  3. The handler calls the service layer
  4. The service applies business logic
  5. The service calls the repository via interface
  6. The repository interacts with the database
  7. The response is returned back to the client
text
Client → Handler → Service → Repository → Database
       ←------------- Response ------------------←

Each layer has a clear responsibility, which keeps the system predictable and easy to debug.

Switching database without changing core logic

One of the biggest advantages of Hexagonal Architecture is flexibility.

For example, if you start with PostgreSQL:

go
store := repository.NewMessengerPostgresRepository()

Later, if you want to switch to Redis:

go
store := repository.NewMessengerRedisRepository()

No changes are required in:

  • Domain models
  • Services
  • Interfaces (ports)

Only the adapter changes.

This makes your system:

  • Easy to upgrade
  • Safe to refactor
  • Independent of specific technologies

How Redis and PostgreSQL fit into adapters

Both Redis and PostgreSQL act as secondary adapters.

They implement the same interface defined in ports:

go
type MessengerRepository interface {
    SaveMessage(message domain.Message) error
}

This allows multiple implementations:

  • PostgreSQL → persistent storage
  • Redis → fast in-memory storage

You can even use both together (e.g., Redis for caching, PostgreSQL for persistence) without changing your business logic.

This design is widely used in real-world systems where scalability and flexibility are required.

If your architecture allows you to replace external systems without touching core logic, you have implemented Hexagonal Architecture correctly.


Hexagonal vs Clean vs Layered Architecture

Understanding the difference between these architectures is important because many developers confuse them, especially in Golang projects.

All three architectures aim to improve code structure, but they differ in how strictly they separate concerns.

Layered Architecture (Traditional)

  • Organized into layers (Controller → Service → Repository)
  • Each layer depends on the next
  • Simple to implement but tightly coupled

Problems:

  • Business logic often depends on database
  • Hard to test without full stack
  • Difficult to replace components

Hexagonal Architecture (Ports and Adapters)

  • Core logic is at the center
  • Uses interfaces (ports) to communicate
  • External systems are plugged via adapters

Advantages:

  • Loose coupling
  • Easy to replace database or framework
  • Highly testable

Clean Architecture

  • More structured version of Hexagonal
  • Adds strict layering rules (Entities, Use Cases, Interface Adapters, Frameworks)
  • Focuses heavily on dependency direction

Compared to Hexagonal:

  • More opinionated
  • More layers → more complexity
  • Better suited for large enterprise systems

Quick comparison

FeatureLayeredHexagonalClean Architecture
CouplingHighLowVery Low
TestabilityMediumHighVery High
FlexibilityLowHighVery High
ComplexityLowMediumHigh
Best forSmall appsAPIs, servicesLarge systems

Which one should you choose?

  • Use Layered Architecture for simple applications or prototypes
  • Use Hexagonal Architecture for APIs, microservices, and scalable systems
  • Use Clean Architecture when building large enterprise systems with multiple teams

👉 For most Golang backend services, Hexagonal Architecture is the best balance between simplicity and scalability.


Testing in Hexagonal Architecture (Golang)

One of the biggest advantages of Hexagonal Architecture is how easy it makes testing.

Since your core logic does not depend on external systems, you can test it in isolation.

Unit testing domain without DB

In traditional architecture, testing often requires:

  • Database setup
  • External dependencies
  • Complex environment

With Hexagonal Architecture, your domain and services are independent.

Example:

go
func TestSaveMessage(t *testing.T) {
    mockRepo := &MockRepository{}
    svc := services.NewMessengerService(mockRepo)

    msg := domain.Message{Body: "hello"}
    err := svc.SaveMessage(msg)

    if err != nil {
        t.Errorf("expected no error, got %v", err)
    }
}

👉 No database required
👉 Fast execution
👉 Easy debugging

Mocking ports for fast tests

Since ports are interfaces, you can easily mock them.

Example mock:

go
type MockRepository struct{}

func (m *MockRepository) SaveMessage(message domain.Message) error {
    return nil
}

func (m *MockRepository) ReadMessage(id string) (*domain.Message, error) {
    return &domain.Message{ID: id, Body: "test"}, nil
}

This allows you to:

  • Test business logic independently
  • Simulate failures (DB down, API error)
  • Run tests without infrastructure

👉 This is a major reason why Hexagonal Architecture is preferred in production systems.


Practical Example: Build the Messaging Service (Project Structure + Code)

Let’s now build the same messaging service using proper Hexagonal Architecture structure.

Initialize the project

Start by creating the project:

bash
mkdir Messenger
cd Messenger
go mod init Messenger

Create the following structure:

go
└── Messenger
   ├── cmd
      └── main.go
   ├── go.mod
   ├── go.sum
   └── internal
       ├── adapters
          ├── handler
             └── http.go
          └── repository
              ├── postgres.go
              └── redis.go
       └── core
           ├── domain
              └── model.go
           ├── ports
              └── ports.go
           └── services
               └── services.go

Now implement each layer.

This application will use different technologies to demonstrate loose coupling using hexagonal architecture. This application will store messages in two data stores. One will be PostgreSQL database and redis. The API for consuming data will be implemented using the Go Gin webframe. Good news is these technology stacks can be switched when need be.

Configure Domain

Domain represents your business model internal/core/domain/domain.go:

go
package domain

type Message struct {
   ID   string `json:"id"`
   Body string `json:"body"`
}

Configure Ports

Ports define contracts between core and external systems internal/core/ports/ports.go:

go
package ports

import "Messenger/internal/core/domain"

type MessengerService interface {
   SaveMessage(message domain.Message) error
   ReadMessage(id string) (*domain.Message, error)
   ReadMessages() ([]*domain.Message, error)
}

type MessengerRepository interface {
   SaveMessage(message domain.Message) error
   ReadMessage(id string) (*domain.Message, error)
   ReadMessages() ([]*domain.Message, error)
}

In the ports.go file, we define the MessengerService and MessengerRepository interfaces. These two interfaces have the same methods that need to be implemented. In these contracts/logic, you will be able to Save a message, Read a Message and Read Messages. Next, navigate to the services module.

Configure Service

Service contains business logic and depends only on ports internal/core/services/services.go:

You start off by defining the MessengerService struct that has a repository as one of its attributes.Each service instance must have a repository attribute to enable you get access to whichever datastore you like. In this case we will be able to work with both Redis and PostgreSQL databases. We define a NewMessengerService() function that takes in a repository as an argument and returns a MessengerService instance.

go
package services

import (
   "Messenger/internal/core/domain"
   "Messenger/internal/core/ports"

   "github.com/google/uuid"
)

type MessengerService struct {
   repo ports.MessengerRepository
}

func NewMessengerService(repo ports.MessengerRepository) *MessengerService {
   return &MessengerService{
       repo: repo,
   }
}

func (m *MessengerService) SaveMessage(message domain.Message) error {
   message.ID = uuid.New().String()
   return m.repo.SaveMessage(message)
}

func (m *MessengerService) ReadMessage(id string) (*domain.Message, error) {
   return m.repo.ReadMessage(id)
}

func (m *MessengerService) ReadMessages() ([]*domain.Message, error) {
   return m.repo.ReadMessages()
}

Configure Adapter

This folder hosts code that implements the MessengerService and MessengerRepository in the services and ports modules respectively. The adapters have been put into two different modules ,based on their functionality. There are the handler modules that serve HTTP requests from Go Gin web framework and the repository module that hosts PostgreSQL and Redis.

Configure PostgreSQL Repository internal/adapters/repository/postgres.go:

In this file, you define the MessengerPostgresRepository struct that has the gorm ORM as its attribute. This struct implements the MessengerRepository interface in the ports module, by having the SaveMessage, ReadMessage and ReadMessages methods. You also define the NewMessengerPostgresRepository() function that returns an instance of the MessengerPostgresRepository.

go
package repository

import (
   "Messenger/internal/core/domain"
   "errors"
   "fmt"

   "github.com/jinzhu/gorm"
   _ "github.com/jinzhu/gorm/dialects/postgres"
)

type MessengerPostgresRepository struct {
   db *gorm.DB
}

func NewMessengerPostgresRepository() *MessengerPostgresRepository {
   host := "localhost"
   port := "5432"
   user := "postgres"
   password := "pass1234"
   dbname := "postgres"

   conn := fmt.Sprintf("host=%s port=%s user=%s dbname=%s password=%s sslmode=disable",
       host,
       port,
       user,
       dbname,
       password,
   )

   db, err := gorm.Open("postgres", conn)
   if err != nil {
       panic(err)
   }
   db.AutoMigrate(&domain.Message{})

   return &MessengerPostgresRepository{
       db: db,
   }
}

func (m *MessengerPostgresRepository) SaveMessage(message domain.Message) error {
   req := m.db.Create(&message)
   if req.RowsAffected == 0 {
       return errors.New(fmt.Sprintf("messages not saved: %v", req.Error))
   }
   return nil
}

func (m *MessengerPostgresRepository) ReadMessage(id string) (*domain.Message, error) {
   message := &domain.Message{}
   req := m.db.First(&message, "id = ? ", id)
   if req.RowsAffected == 0 {
       return nil, errors.New("message not found")
   }
   return message, nil
}

func (m *MessengerPostgresRepository) ReadMessages() ([]*domain.Message, error) {
   var messages []*domain.Message
   req := m.db.Find(&messages)
   if req.Error != nil {
       return nil, errors.New(fmt.Sprintf("messages not found: %v", req.Error))
   }
   return messages, nil
}

Configure Redis Repository internal/adapters/repository/redis.go

In the redis.go file, you define the MessengerRedisRepository struct that implements the MessengerRepository interface in the ports module. It has the SaveMessage, ReadMessage and ReadMessages methods. To get a new instance of the redis repository, you define the NewMessengerRedisRepository function, that takes in the redis host as an argument. It returns a new instance of the MessengerRedisRepository.

go
package repository

import (
   "Messenger/internal/core/domain"
   "encoding/json"

   "github.com/go-redis/redis/v7"
)

type MessengerRedisRepository struct {
   client *redis.Client
}

func NewMessengerRedisRepository(host string) *MessengerRedisRepository {
   client := redis.NewClient(&redis.Options{
       Addr:     host,
       Password: "",
       DB:       0,
   })
   return &MessengerRedisRepository{
       client: client,
   }
}

func (r *MessengerRedisRepository) SaveMessage(message domain.Message) error {
   json, err := json.Marshal(message)
   if err != nil {
       return err
   }
   r.client.HSet("messages", message.ID, json)
   return nil
}

func (r *MessengerRedisRepository) ReadMessage(id string) (*domain.Message, error) {
   value, err := r.client.HGet("messages", id).Result()
   if err != nil {
       return nil, err
   }
   message := &domain.Message{}
   err = json.Unmarshal([]byte(value), message)
   if err != nil {
       return nil, err
   }
   return message, nil
}

func (r *MessengerRedisRepository) ReadMessages() ([]*domain.Message, error) {
   messages := []*domain.Message{}
   value, err := r.client.HGetAll("messages").Result()
   if err != nil {
       return nil, err
   }

   for _, val := range value {
       message := &domain.Message{}
       err = json.Unmarshal([]byte(val), message)
       if err != nil {
           return nil, err
       }
       messages = append(messages, message)
   }

   return messages, nil

}

Configure Handler internal/adapters/http.go

HTTP handler acts as primary adapter. The handler module hosts the Gin webframe work that serves HTTP requests from clients. In this case the handler is a primary actor, meaning it initiates events that go to the core of the application.

In the internal/adapters/http.go file, you define the HTTPHandler struct as a service as its attribute, which gives you access to our different repositories via the service. The HTTPHandler type implements both the MessangerService and MessangerRepository interfaces.

go
package handler

import (
   "Messenger/internal/core/domain"
   "Messenger/internal/core/services"

   "net/http"

   "github.com/gin-gonic/gin"
)

type HTTPHandler struct {
   svc services.MessengerService
}

func NewHTTPHandler(MessengerService services.MessengerService) *HTTPHandler {
   return &HTTPHandler{
       svc: MessengerService,
   }
}

func (h *HTTPHandler) SaveMessage(ctx *gin.Context) {
   var message domain.Message
   if err := ctx.ShouldBindJSON(&message); err != nil {
       ctx.JSON(http.StatusBadRequest, gin.H{
           "Error": err,
       })

       return
   }

   err := h.svc.SaveMessage(message)
   if err != nil {
       ctx.JSON(http.StatusBadRequest, gin.H{
           "error": err,
       })
       return
   }

   ctx.JSON(http.StatusCreated, gin.H{
       "message": "New message created successfully",
   })
}

func (h *HTTPHandler) ReadMessage(ctx *gin.Context) {
   id := ctx.Param("id")
   message, err := h.svc.ReadMessage(id)

   if err != nil {
       ctx.JSON(http.StatusBadRequest, gin.H{
           "error": err.Error(),
       })
       return
   }
   ctx.JSON(http.StatusOK, message)
}

func (h *HTTPHandler) ReadMessages(ctx *gin.Context) {

   messages, err := h.svc.ReadMessages()

   if err != nil {
       ctx.JSON(http.StatusBadRequest, gin.H{
           "error": err.Error(),
       })
       return
   }
   ctx.JSON(http.StatusOK, messages)
}

Configure main.go

In the main.go file, we bring everything together. We need the repository, services and the HTTP handler code in one place. Add the below code in the main.go file.

go
package main

import (
   "Messenger/internal/adapters/handler"
   "Messenger/internal/adapters/repository"
   "Messenger/internal/core/services"
   "flag"
   "fmt"

   "github.com/gin-gonic/gin"
)

var (
   repo        = flag.String("db", "postgres", "Database for storing messages")
   redisHost   = "localhost:6379"
   httpHandler *handler.HTTPHandler
   svc         *services.MessengerService
)

func main() {
   flag.Parse()

   fmt.Printf("Application running using %s\n", *repo)
   switch *repo {
   case "redis":
       store := repository.NewMessengerRedisRepository(redisHost)
       svc = services.NewMessengerService(store)
   default:
       store := repository.NewMessengerPostgresRepository()
       svc = services.NewMessengerService(store)
   }

   InitRoutes()

}

func InitRoutes() {
   router := gin.Default()
   handler := handler.NewHTTPHandler(*svc)
   router.GET("/messages/:id", handler.ReadMessage)
   router.GET("/messages", handler.ReadMessages)
   router.POST("/messages", handler.SaveMessage)
   router.Run(":5000")
}

In main.go, we wire all components together — repository, service, and HTTP handler.

We define a few global variables such as repo, redisHost, and svc. The repo variable is a Go flag that allows selecting the datastore at runtime.

By default, PostgreSQL is used. If --db=redis is passed, the application switches to Redis without any change in business logic.

Inside main(), we parse the flag using flag.Parse() and initialize the appropriate repository using a switch statement. The selected repository is then injected into the service layer.

Finally, InitRoutes() sets up the HTTP endpoints and starts the Gin server on port 5000.

Running the application

We will start off by running the server using redis as the data store. Make sure you are at the root of your application i.e /Messenger. In the terminal, add any missing dependencies first by issuing the below command.

go
go mod tidy

TEST: Using Redis Database

Next, run the server using the below command.

text
go run cmd/main.go --db=redis

Output

go
Application running using redis
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env:   export GIN_MODE=release
- using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /messages/:id             --> Messenger/internal/adapters/handler.(*HTTPHandler).ReadMessage-fm (3 handlers)
[GIN-debug] GET    /messages                 --> Messenger/internal/adapters/handler.(*HTTPHandler).ReadMessages-fm (3 handlers)
[GIN-debug] POST   /messages                 --> Messenger/internal/adapters/handler.(*HTTPHandler).SaveMessage-fm (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :5000

Using Postman, save a message, read a message and read all messages.


a. Save messages

Using the below image and add up to 5 messages.

image


b. Read all messages

Read all messages from the redis database

image


c. Read a message

Read a single message using message ID

image


Frequently Asked Questions

1. What is Hexagonal Architecture in Golang?

Hexagonal Architecture in Golang is a design pattern that separates core business logic from external systems using ports and adapters, making applications more maintainable and testable.

2. What are ports and adapters in Go?

Ports are interfaces that define how the core communicates, while adapters are implementations such as HTTP handlers or databases that interact with the outside world.

3. When should you use Hexagonal Architecture in Go?

It is best suited for scalable applications, microservices, and systems where business logic must remain independent of frameworks and databases.

4. Is Hexagonal Architecture same as Clean Architecture?

Both share similar principles, but Hexagonal focuses on ports and adapters, while Clean Architecture adds more layered separation and rules.

Summary

In this article, you learned how to implement Hexagonal Architecture (Ports and Adapters) in Golang using a real-world messaging service.

By separating the core business logic from external systems like HTTP frameworks and databases, you achieved:

  • Clear separation of concerns
  • Flexibility to switch adapters (PostgreSQL ↔ Redis)
  • Improved testability using interfaces
  • Scalable and maintainable project structure

The key idea is simple: your application should depend on interfaces (ports), not implementations. Adapters handle external communication, while the core remains stable and independent.

If you can replace a database, framework, or external system without changing your business logic, your architecture is correctly designed.


References

Go Official Documentation
Gin Web Framework Documentation
GORM (PostgreSQL ORM) Documentation
Redis Official Documentation
The Twelve-Factor App Methodology
Hexagonal Architecture by Alistair Cockburn

Antony Shikubu

Antony Shikubu

Systems Integration Engineer

Highly skilled software developer with expertise in Python, Golang, and AWS cloud services.