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

Deepak Prasad
Tested on RHEL 10.2 (Coughlan)
Package go 1.26.5
github.com/gin-gonic/gin 1.12.0
gorm.io/gorm 1.31.2
github.com/redis/go-redis/v9 9.22.0
Applies to Any host with Go 1.25+ installed
Privilege Normal user
Scope Explains driving and driven ports in Go, folder layout, a Gin messaging lab with PostgreSQL or Valkey backends, mock-based unit tests, and architecture comparisons. Does not cover composite cache layers or production deployment.
Related guides gRPC in Go
database/sql in Go
Go structs
Getting started with Go
Short on time?Beta Get the gist of this 13-minute read in seconds, then ask follow-up questions

Hexagonal architecture (ports and adapters) keeps your business rules in the center and pushes HTTP and database clients to the edges. In Go that usually means small internal/core packages, interfaces in ports, concrete Gin and GORM code in adapters, and a main() function that wires implementations together. The walkthrough below uses a messaging API you can run against PostgreSQL or Valkey.


What is hexagonal architecture in Go?

Alistair Cockburn's hexagonal model treats your application as a core that speaks to the outside world only through ports. In Go, a port is an interface; an adapter is whatever satisfies it (a Gin handler, a GORM repository, a Valkey client).

Cockburn distinguishes driving (primary) and driven (secondary) sides:

  • Driving side — the user or client initiates work; the HTTP handler is the adapter that translates the request into a call on an inbound port.
  • Driven side — the core calls outbound ports; repository adapters talk to PostgreSQL or Valkey.

That separation pays off when you need to:

  • swap PostgreSQL for Valkey at wiring time without touching services
  • test SaveMessage with a mock repository instead of a running database
  • replace Gin with another HTTP router while keeping domain logic intact

Go fits the pattern well because interfaces are implicit: an adapter does not declare implements MessengerRepository; it only needs the right methods. Your composition root is main() — one place that picks concrete adapters and passes them into services.

Why classic layered CRUD breaks down

Classic handler → service → repository stacks are easy to start with, but dependencies often drift outward:

  • services import GORM models or Redis clients directly
  • handlers grow validation, mapping, and SQL error handling in one function
  • unit tests need Docker for Postgres before you can assert business rules

Hexagonal architecture reverses the dependency direction. Adapters depend on port interfaces defined beside the domain; the core never imports Gin, GORM, or Redis packages. External systems sit on the outside and stay replaceable.


Ports and adapters in one request

The diagram shows the path for the messaging API in this article: an inbound HTTP adapter, the service core, an outbound repository port, and a swappable database adapter.

Request flow from Gin handler through core service and repository port to Valkey or PostgreSQL

Read the flow left to right for POST /messages. The HTTP client is the driving actor. The Gin handler (driving adapter) decodes JSON and calls ports.MessengerService (inbound port). The service applies rules (assigning a UUID) and calls MessengerRepository (outbound port). The Valkey or PostgreSQL adapter implements that interface. The JSON response travels back through the same chain. The core never names a specific database driver.

Layer Role in this lab Package in the sample
Domain Message struct and business data internal/core/domain
Ports MessengerService (inbound) and MessengerRepository (outbound) internal/core/ports
Services Use cases; implements inbound port, depends on outbound port internal/core/services
Driving adapter HTTP routes (Gin) call ports.MessengerService internal/adapters/handler
Driven adapter PostgreSQL (GORM) or Valkey persistence internal/adapters/repository

Dependency rule: imports point inward. handler and repository may import core; core must not import handler or repository.

Request flow for POST /messages

Each create request in the lab follows the same steps:

  1. The HTTP client sends POST /messages.
  2. The Gin handler binds JSON to domain.Message.
  3. The handler calls ports.MessengerService.SaveMessage.
  4. The service assigns a UUID and calls MessengerRepository.
  5. The repository adapter persists the row or hash field.
  6. The handler returns the saved Message as JSON.

Swapping storage is a wiring change in main() because both repository adapters implement the same outbound port:

go
store := repository.NewMessengerPostgresRepository()
go
store := repository.NewMessengerRedisRepository("localhost:6379")

Domain models, service methods, and port interfaces stay untouched; only the adapter selected in main() changes. The sample picks one backend per process — not PostgreSQL and Valkey together.


Golang project structure for hexagonal apps

This layout matches the sample repository:

go
├── cmd/
│   └── main.go                # Composition root
├── internal/
│   ├── core/
│   │   ├── domain/            # Business models
│   │   ├── ports/             # Inbound and outbound interfaces
│   │   └── services/          # Use cases (implements inbound ports)
│   ├── adapters/
│   │   ├── handler/           # HTTP / gRPC (driving adapters)
│   │   └── repository/        # DB (driven adapters)
├── go.mod

What belongs in each folder:

  • Domain — entities and pure rules. No framework imports.
  • Ports — interfaces describing what the core exposes (MessengerService) and what it needs (MessengerRepository).
  • Services — orchestration and business rules. Depends on outbound port interfaces, not concrete databases.
  • Adapters — Gin handlers, GORM repositories, Valkey clients. Framework imports live here only.

Common mistakes:

  • putting SQL or Valkey calls inside services
  • importing Gin inside domain
  • injecting concrete *services.MessengerService into handlers instead of ports.MessengerService
  • skipping interfaces and calling repository.NewMessengerPostgresRepository() from handlers

Hexagonal architecture best practices in Go

  • Keep ports purpose-based and small — MessengerRepository covers persistence; do not fold HTTP concerns into it.
  • Depend on ports at adapter boundaries — handlers take ports.MessengerService; services take ports.MessengerRepository.
  • Wire adapters only in main() — no package-level service singletons; locals keep the composition root obvious.
  • Keep framework types out of core — gin.Context stays in handler; GORM and go-redis stay in repository.
  • Introduce an interface when a boundary is real — a second database, a mock in tests, or a future gRPC adapter justifies a port; avoid interfaces with one implementation forever.

Hexagonal vs clean vs layered architecture

Property Layered Hexagonal Clean Architecture
Dependency direction Down through adjacent layers Inward toward core via ports Strictly inward across multiple rings
Core boundary Often porous Port interfaces around domain + services Entities and use cases with explicit rules
Infrastructure coupling Services/handlers often import DB and HTTP libs directly Adapters implement ports; core stays pure Go Frameworks confined to outermost ring
Prescribed layers Three common (handler / service / repo) Core, ports, adapters (flexible package count) Four or more named rings
Typical trade-off Fast to start; refactors ripple outward More interfaces upfront; adapters swap cleanly Most ceremony; suits large multi-team codebases

Pick the layout that matches scope:

  • Layered — scripts, internal tools, one database, short lifetime
  • Hexagonal — most Go HTTP APIs and microservices; ports around persistence and inbound HTTP
  • Clean Architecture — multiple teams, strict import enforcement, many delivery mechanisms
  • Skip both — one-file CLIs and throwaway prototypes; add ports when a second adapter appears

Hexagonal adds interfaces and packages upfront. That cost pays off when you swap stores or test services without infrastructure; it hurts when the app will never grow past one handler and one table.


Testing through ports

Services depend on ports.MessengerRepository, so tests inject a mock instead of Valkey or Postgres. Save the mock and test in internal/core/services/services_test.go:

go
package services

import (
   "Messenger/internal/core/domain"
   "testing"
)

type MockRepository struct {
   saved domain.Message
}

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

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

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

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

   msg := domain.Message{Body: "hello"}
   saved, err := svc.SaveMessage(msg)
   if err != nil {
       t.Fatalf("expected no error, got %v", err)
   }
   if saved.ID == "" {
       t.Fatal("expected SaveMessage to assign a UUID")
   }
   if saved.Body != "hello" {
       t.Fatalf("expected body hello, got %q", saved.Body)
   }
   if mockRepo.saved.ID != saved.ID {
       t.Fatalf("repository did not receive assigned ID: got %q want %q", mockRepo.saved.ID, saved.ID)
   }
}

From the module root, run the service package tests:

bash
go test ./internal/core/services

The mock satisfies the full repository port and the test checks the UUID rule — the only business logic in SaveMessage:

output
ok      Messenger/internal/core/services    0.041s

Build the messaging service

Create the directory layout from the project structure section, then add each file below.

Initialize the project

Create the module and directory tree:

bash
mkdir -p Messenger/cmd Messenger/internal/core/{domain,ports,services} Messenger/internal/adapters/{handler,repository}
cd Messenger
go mod init Messenger

This application uses Gin for HTTP and persists messages in PostgreSQL or Valkey. Each store sits behind MessengerRepository; main() selects one backend per run.

Configure domain

internal/core/domain/model.go holds the business model:

go
package domain

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

Configure ports

internal/core/ports/ports.go defines inbound and outbound contracts:

go
package ports

import "Messenger/internal/core/domain"

type MessengerService interface {
   SaveMessage(message domain.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)
}

MessengerService is the inbound (driving) port the HTTP adapter calls. MessengerRepository is the outbound (driven) port the service calls.

Configure service

internal/core/services/services.go implements the inbound port and depends on the outbound port:

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) (*domain.Message, error) {
   message.ID = uuid.New().String()
   if err := m.repo.SaveMessage(message); err != nil {
       return nil, err
   }
   return &message, nil
}

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 adapters

Adapters split by direction:

  • Driving — internal/adapters/handler/http.go serves Gin routes and calls ports.MessengerService
  • Driven — internal/adapters/repository/postgres.go and redis.go implement MessengerRepository

PostgreSQL repository (internal/adapters/repository/postgres.go)

MessengerPostgresRepository uses GORM v2 and runs AutoMigrate for domain.Message:

go
package repository

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

   "gorm.io/driver/postgres"
   "gorm.io/gorm"
)

type MessengerPostgresRepository struct {
   db *gorm.DB
}

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

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

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

   return &MessengerPostgresRepository{
       db: db,
   }
}

func (m *MessengerPostgresRepository) SaveMessage(message domain.Message) error {
   return m.db.Create(&message).Error
}

func (m *MessengerPostgresRepository) ReadMessage(id string) (*domain.Message, error) {
   message := &domain.Message{}
   result := m.db.First(message, "id = ?", id)
   if errors.Is(result.Error, gorm.ErrRecordNotFound) {
       return nil, errors.New("message not found")
   }
   if result.Error != nil {
       return nil, result.Error
   }
   return message, nil
}

func (m *MessengerPostgresRepository) ReadMessages() ([]*domain.Message, error) {
   var messages []*domain.Message
   if err := m.db.Find(&messages).Error; err != nil {
       return nil, err
   }
   return messages, nil
}

Valkey repository (internal/adapters/repository/redis.go)

MessengerRedisRepository stores JSON in a hash and returns errors from HSet:

go
package repository

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

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

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 {
   ctx := context.Background()
   data, err := json.Marshal(message)
   if err != nil {
       return err
   }
   return r.client.HSet(ctx, "messages", message.ID, data).Err()
}

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

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

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

   return messages, nil
}

HTTP handler (internal/adapters/handler/http.go)

The Gin handler is a driving adapter. It calls ports.MessengerService; it does not implement that interface.

go
package handler

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

   "net/http"

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

type HTTPHandler struct {
   svc ports.MessengerService
}

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

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
   }

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

   ctx.JSON(http.StatusCreated, saved)
}

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 cmd/main.go

main() constructs the driven adapter, builds the service, and passes the inbound port into the handler:

go
package main

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

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

func main() {
   dbFlag := flag.String("db", "postgres", "Database for storing messages")
   redisHost := flag.String("redis", "localhost:6379", "Valkey or Redis address")
   flag.Parse()

   fmt.Printf("Application running using %s\n", *dbFlag)

   var store ports.MessengerRepository
   switch *dbFlag {
   case "redis":
       store = repository.NewMessengerRedisRepository(*redisHost)
   default:
       store = repository.NewMessengerPostgresRepository()
   }

   svc := services.NewMessengerService(store)

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

PostgreSQL is the default. Pass --db=redis to use the Valkey adapter with no service changes.


Run the lab with curl

Prerequisites

Requirement Purpose
Go 1.25+ with modules Current Gin release requires Go 1.25 or newer
Valkey 8.x (tested) or a Redis version supported by go-redis/v9 --db=redis path on localhost:6379
PostgreSQL on localhost:5432 Default --db=postgres; match credentials in postgres.go or edit the DSN
Free TCP port 5000 Gin listen address in main()

Before the first build:

  • start Valkey or Redis for the --db=redis path; for a minimal go-redis/v9 client setup, see use Go as a Redis client
  • for PostgreSQL, create the role and database to match postgres.go, or edit the DSN constants

Pull dependencies from the module root:

bash
go mod tidy

Start the API and verify endpoints

From the project root, start the Valkey-backed API (it stays in the foreground):

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

When the process stays in the foreground, Gin prints route registration and listens on :5000:

output
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://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :5000

In a second shell, clear any leftover hash keys so the list matches a single create:

bash
valkey-cli FLUSHALL

Create a message. POST /messages returns the saved row, including the server-generated id:

bash
curl -s -X POST http://localhost:5000/messages -H 'Content-Type: application/json' -d '{"body":"Ship logs before deploy"}'
output
{"id":"a360d5f0-7216-480f-b7f7-8cc602c30def","body":"Ship logs before deploy"}

Read that message by id without listing the whole hash:

bash
curl -s http://localhost:5000/messages/a360d5f0-7216-480f-b7f7-8cc602c30def
output
{"id":"a360d5f0-7216-480f-b7f7-8cc602c30def","body":"Ship logs before deploy"}

List all messages (order is not guaranteed):

bash
curl -s http://localhost:5000/messages
output
[{"id":"a360d5f0-7216-480f-b7f7-8cc602c30def","body":"Ship logs before deploy"}]

To exercise PostgreSQL, press Ctrl+C in the terminal running the Valkey-backed API so port 5000 is free, then start go run cmd/main.go without --db=redis.


Troubleshooting

Symptom Likely cause Fix
listen tcp :5000: bind: address already in use Previous go run still listening Press Ctrl+C in the server terminal before starting another backend
panic on startup with postgres DSN mismatch or Postgres not running Confirm host, port, user, password, and database match NewMessengerPostgresRepository()
Redis/Valkey errors on save/read Nothing listening on localhost:6379 Start Valkey or Redis; verify with valkey-cli ping (or redis-cli ping on distros that ship Redis)
go: cannot find module Dependencies not downloaded Run go mod tidy from the module root
201 on POST but empty GET /messages Stale keys from an earlier run Run valkey-cli FLUSHALL before the verification curls

References

Go Official Documentation
Gin Web Framework Documentation
GORM v2 Documentation
go-redis v9 Client Documentation
Hexagonal Architecture by Alistair Cockburn


Summary

The messaging lab shows hexagonal boundaries in Go: ports.MessengerService as the inbound port, ports.MessengerRepository as the outbound port, Gin and GORM/Valkey as adapters, and main() as the composition root. Handlers depend on the service port interface; services depend on the repository port interface — not the other way around.

If you can switch from Valkey to PostgreSQL by changing wiring in main() and still unit-test SaveMessage with a mock repository, the ports are doing their job. Add more adapters (gRPC, queues) only when you have a second delivery or persistence mechanism worth isolating.


[[FAQ]]

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Antony Shikubu

Systems Integration Engineer

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

  • Go (programming language)
  • Python (programming language)
  • Amazon Web Services