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

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:
MessengerServiceMessengerRepository
👉 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
User Request
↓
Primary Adapter (HTTP / gRPC)
↓
Port Interface (Service)
↓
Core Business Logic
↓
Port Interface (Repository)
↓
Secondary Adapter (DB / Redis)
↓
Response back to userWhy 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.
├── 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.modThis 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:
- The request reaches the HTTP handler (Gin)
- The handler validates and parses input
- The handler calls the service layer
- The service applies business logic
- The service calls the repository via interface
- The repository interacts with the database
- The response is returned back to the client
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:
store := repository.NewMessengerPostgresRepository()Later, if you want to switch to Redis:
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:
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
| Feature | Layered | Hexagonal | Clean Architecture |
|---|---|---|---|
| Coupling | High | Low | Very Low |
| Testability | Medium | High | Very High |
| Flexibility | Low | High | Very High |
| Complexity | Low | Medium | High |
| Best for | Small apps | APIs, services | Large 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:
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:
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:
mkdir Messenger
cd Messenger
go mod init MessengerCreate the following structure:
└── 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.goNow 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:
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:
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.
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.
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.
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.
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.
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 mod tidyTEST: Using Redis Database
Next, run the server using the below command.
go run cmd/main.go --db=redisOutput
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 :5000Using Postman, save a message, read a message and read all messages.
a. Save messages
Using the below image and add up to 5 messages.

b. Read all messages
Read all messages from the redis database

c. Read a message
Read a single message using message ID

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


