| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | go 1.26.5github.com/gin-gonic/gin 1.12.0gorm.io/gorm 1.31.2github.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 |
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
SaveMessagewith 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.
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:
- The HTTP client sends
POST /messages. - The Gin handler binds JSON to
domain.Message. - The handler calls
ports.MessengerService.SaveMessage. - The service assigns a UUID and calls
MessengerRepository. - The repository adapter persists the row or hash field.
- The handler returns the saved
Messageas JSON.
Swapping storage is a wiring change in main() because both repository adapters implement the same outbound port:
store := repository.NewMessengerPostgresRepository()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:
├── 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.modWhat 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.MessengerServiceinto handlers instead ofports.MessengerService - skipping interfaces and calling
repository.NewMessengerPostgresRepository()from handlers
Hexagonal architecture best practices in Go
- Keep ports purpose-based and small —
MessengerRepositorycovers persistence; do not fold HTTP concerns into it. - Depend on ports at adapter boundaries — handlers take
ports.MessengerService; services takeports.MessengerRepository. - Wire adapters only in
main()— no package-level service singletons; locals keep the composition root obvious. - Keep framework types out of core —
gin.Contextstays inhandler; GORM andgo-redisstay inrepository. - 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:
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:
go test ./internal/core/servicesThe mock satisfies the full repository port and the test checks the UUID rule — the only business logic in SaveMessage:
ok Messenger/internal/core/services 0.041sBuild 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:
mkdir -p Messenger/cmd Messenger/internal/core/{domain,ports,services} Messenger/internal/adapters/{handler,repository}
cd Messenger
go mod init MessengerThis 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:
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:
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:
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.goserves Gin routes and callsports.MessengerService - Driven —
internal/adapters/repository/postgres.goandredis.goimplementMessengerRepository
PostgreSQL repository (internal/adapters/repository/postgres.go)
MessengerPostgresRepository uses GORM v2 and runs AutoMigrate for domain.Message:
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:
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.
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:
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")
}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=redispath; for a minimalgo-redis/v9client 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:
go mod tidyStart the API and verify endpoints
From the project root, start the Valkey-backed API (it stays in the foreground):
go run cmd/main.go --db=redisWhen the process stays in the foreground, Gin prints route registration and listens on :5000:
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 :5000In a second shell, clear any leftover hash keys so the list matches a single create:
valkey-cli FLUSHALLCreate a message. POST /messages returns the saved row, including the server-generated id:
curl -s -X POST http://localhost:5000/messages -H 'Content-Type: application/json' -d '{"body":"Ship logs before deploy"}'{"id":"a360d5f0-7216-480f-b7f7-8cc602c30def","body":"Ship logs before deploy"}Read that message by id without listing the whole hash:
curl -s http://localhost:5000/messages/a360d5f0-7216-480f-b7f7-8cc602c30def{"id":"a360d5f0-7216-480f-b7f7-8cc602c30def","body":"Ship logs before deploy"}List all messages (order is not guaranteed):
curl -s http://localhost:5000/messages[{"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]]

