Mike Mwita


Hexagonal Architecture in Go

7 min read  #go #architecture #backend #software-design

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.
— Kent Beck

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 one rule: Dependencies point inward. The core knows nothing about the outside world — only the outside world knows about the core.

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 #

Hexagonal architecture adds indirection. Indirection has a cost. Don’t apply it everywhere by default.

The pattern pays off when:

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.
— Robert C. Martin

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)CoreDriven adapters (out)
Knows aboutHTTP, gRPC, KafkaDomain types, portsSQL, Redis, HTTP client
DirectionCalls inward →← defines interfaces →← implements interfaces
Changes whenProtocol changesBusiness rules changeInfrastructure changes
Test withIntegration testFake adaptersIntegration test