Hexagonal Architecture in Go
M ost Go services start out reasonable. A handler calls a database. A function returns a struct. Six months later, your HTTP handler is importing your database package, your database package is calling an external API, and your tests require a live Postgres instance to run a single unit.
This is not a Go problem. It is an architecture problem — and hexagonal architecture is the structural answer.
What Hexagonal Architecture Actually Says#
Ports and adapters — the original name — was coined by Alistair Cockburn in 2005. The core insight is deceptively simple:
The hexagon is your application core: pure business logic, no imports of HTTP, SQL, or gRPC packages. It exposes ports — interfaces that describe what the core needs from the outside world. Adapters sit outside and implement those ports.
Make it work, make it right, make it fast. In that order.
An HTTP handler is an adapter. A Postgres repository is an adapter. A Kafka consumer is an adapter. They are interchangeable. The core does not care which one is plugged in.
The Three Zones#
┌─────────────────────────────────────────┐
│ Adapters (In) │
│ HTTP handlers · gRPC · CLI · Kafka │
│ │ │
│ ┌───────▼───────┐ │
│ │ Application │ │
│ │ Core │ │
│ │ (use cases) │ │
│ └───────┬───────┘ │
│ │ │
│ Adapters (Out) │
│ Postgres · Redis · S3 · HTTP client │
└─────────────────────────────────────────┘
Driving adapters (in) call your core. They translate external signals — an HTTP request, a CLI flag, a queue message — into calls your application understands.
Driven adapters (out) are called by your core. They translate your application’s needs into external actions — a SQL query, a Redis write, an outbound API call.
Ports are the interfaces between them. The core defines the interface. The adapter implements it.
What It Looks Like in Go#
Let us build a minimal user registration service to make this concrete.
The Core: Define the Port#
// internal/user/port.go
package user
import "context"
// Repository is a driven port — the core defines what it needs,
// not how it is implemented.
type Repository interface {
Save(ctx context.Context, u User) error
FindByEmail(ctx context.Context, email string) (User, error)
}
// User is a domain object. No database tags. No JSON tags.
type User struct {
ID string
Email string
Name string
}
The Core: The Use Case#
// internal/user/service.go
package user
import (
"context"
"fmt"
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Register(ctx context.Context, email, name string) (User, error) {
_, err := s.repo.FindByEmail(ctx, email)
if err == nil {
return User{}, fmt.Errorf("email already registered: %s", email)
}
u := User{
ID: newID(),
Email: email,
Name: name,
}
if err := s.repo.Save(ctx, u); err != nil {
return User{}, fmt.Errorf("save user: %w", err)
}
return u, nil
}
No database/sql. No net/http. The core imports nothing from infrastructure.
The Driven Adapter: Postgres#
// internal/user/postgres.go
package user
import (
"context"
"database/sql"
"fmt"
)
type PostgresRepository struct {
db *sql.DB
}
func NewPostgresRepository(db *sql.DB) *PostgresRepository {
return &PostgresRepository{db: db}
}
func (r *PostgresRepository) Save(ctx context.Context, u User) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`,
u.ID, u.Email, u.Name,
)
return err
}
func (r *PostgresRepository) FindByEmail(ctx context.Context, email string) (User, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, email, name FROM users WHERE email = $1`, email,
)
var u User
if err := row.Scan(&u.ID, &u.Email, &u.Name); err != nil {
return User{}, fmt.Errorf("find by email: %w", err)
}
return u, nil
}
PostgresRepository implements Repository. The core never imports this file.
The Driving Adapter: HTTP Handler#
// internal/user/handler.go
package user
import (
"encoding/json"
"net/http"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
u, err := h.svc.Register(r.Context(), body.Email, body.Name)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(u)
}
The handler knows about HTTP. It does not know about Postgres. It calls the service and translates the result.
Wiring It Together#
// cmd/api/main.go
package main
import (
"database/sql"
"net/http"
"github.com/mikemwita/myblog/internal/user"
_ "github.com/lib/pq"
)
func main() {
db, _ := sql.Open("postgres", "postgres://localhost/mydb?sslmode=disable")
repo := user.NewPostgresRepository(db)
svc := user.NewService(repo)
h := user.NewHandler(svc)
http.HandleFunc("/users", h.Register)
http.ListenAndServe(":8080", nil)
}
The wiring happens at the outermost layer. Only main knows how all the pieces connect.
Why Testing Becomes Trivial#
Because the core depends on an interface, not a concrete type, you replace the database with a fake in tests:
// internal/user/service_test.go
package user_test
import (
"context"
"fmt"
"testing"
"github.com/mikemwita/myblog/internal/user"
)
type fakeRepo struct {
users map[string]user.User
}
func (f *fakeRepo) Save(_ context.Context, u user.User) error {
f.users[u.Email] = u
return nil
}
func (f *fakeRepo) FindByEmail(_ context.Context, email string) (user.User, error) {
if u, ok := f.users[email]; ok {
return u, nil
}
return user.User{}, fmt.Errorf("not found")
}
func TestRegister_DuplicateEmail(t *testing.T) {
repo := &fakeRepo{users: make(map[string]user.User)}
svc := user.NewService(repo)
ctx := context.Background()
svc.Register(ctx, "mike@example.com", "Mike")
_, err := svc.Register(ctx, "mike@example.com", "Mike Again")
if err == nil {
t.Fatal("expected error for duplicate email, got nil")
}
}
The test runs in milliseconds and is fully deterministic.
The Folder Structure#
myblog/
├── cmd/
│ └── api/
│ └── main.go ← wiring only
├── internal/
│ └── user/
│ ├── user.go ← domain types
│ ├── port.go ← interfaces (ports)
│ ├── service.go ← use cases (core)
│ ├── service_test.go ← unit tests
│ ├── postgres.go ← driven adapter
│ └── handler.go ← driving adapter
Each package owns its domain. internal/ ensures nothing outside this module imports these packages directly.
When Not to Use It#
The pattern pays off when:
- You have non-trivial business logic that changes independently of infrastructure
- You need to swap adapters (e.g. Postgres → DynamoDB, REST → gRPC)
- You want fast, isolated unit tests without infrastructure dependencies
- Multiple teams touch the same codebase and need clear boundaries
If none of those apply, a flat package with direct database calls is fine.
The purpose of software architecture is to make the cost of change low.
The One Rule to Remember#
Everything else — the folder names, the number of layers, whether you call it “hexagonal” or “clean” or “onion” — is secondary. The dependency rule is the invariant. Keep it and the rest follows.
| Driving adapters (in) | Core | Driven adapters (out) | |
|---|---|---|---|
| Knows about | HTTP, gRPC, Kafka | Domain types, ports | SQL, Redis, HTTP client |
| Direction | Calls inward → | ← defines interfaces → | ← implements interfaces |
| Changes when | Protocol changes | Business rules change | Infrastructure changes |
| Test with | Integration test | Fake adapters | Integration test |