Hexagonal architecture, also called ports and adapters, puts your business logic at the center of the application and pushes everything else (HTTP, databases, message queues, email providers) to the edges. The core defines small interfaces, called ports, for what it offers and what it needs. Adapters plug into those ports to connect the core to real technology. The core never imports a web framework or a database driver.
The payoff is practical: you can test business rules without a database, replace Postgres without touching the domain, and add a new entry point, such as a CLI or a queue consumer, without duplicating logic. This guide explains the pattern, builds a small example in Go, and covers when it's worth the extra structure.
The shape of hexagonal architecture
Alistair Cockburn described the pattern in 2005. The hexagon shape has no special meaning. It simply leaves room to draw many ports, and moves away from the top-to-bottom picture of layered architecture.
There are three parts:
- The core (domain and application logic). Entities, business rules and use cases. Plain code with no framework dependencies.
- Ports. Interfaces owned by the core.
- Adapters. Code outside the core that implements or calls a port using a specific technology.
Ports come in two directions:
| Driving (primary) side | Driven (secondary) side | |
|---|---|---|
| Also called | API, inbound | SPI, outbound |
| Who starts the interaction | The outside world calls the core | The core calls the outside world |
| Port defines | What the application can do (use cases) | What the application needs (storage, messaging, payment) |
| Example adapters | HTTP handler, gRPC server, CLI, queue consumer, test | Postgres repository, SMTP client, payment API client, in-memory fake |
All dependencies point inward. Adapters depend on the core. The core depends only on its own port interfaces.
Building it in Go: an account signup service
The example is a small service that registers user accounts, stores them, and sends a welcome email.
internal/
account/ # the hexagon: domain, use cases, ports
account.go
ports.go
service.go
adapter/
httpapi/ # driving adapter
postgres/ # driven adapter
email/ # driven adapter
cmd/api/main.go # wiringThe domain
// internal/account/account.go
package account
import (
"errors"
"strings"
"time"
)
var (
ErrInvalidEmail = errors.New("invalid email")
ErrInvalidName = errors.New("name is required")
ErrEmailTaken = errors.New("email already registered")
ErrNotFound = errors.New("account not found")
)
type Account struct {
ID string
Email string
Name string
CreatedAt time.Time
}
func New(id, email, name string, now time.Time) (Account, error) {
email = strings.ToLower(strings.TrimSpace(email))
name = strings.TrimSpace(name)
if !strings.Contains(email, "@") {
return Account{}, ErrInvalidEmail
}
if name == "" {
return Account{}, ErrInvalidName
}
return Account{ID: id, Email: email, Name: name, CreatedAt: now}, nil
}No JSON tags, no SQL, no HTTP status codes. The business rules live here and nowhere else.
The ports
// internal/account/ports.go
package account
import "context"
// API: the driving port. What the outside world can ask the core to do.
type API interface {
Register(ctx context.Context, email, name string) (Account, error)
Get(ctx context.Context, id string) (Account, error)
}
// SPI: driven ports. What the core needs from the outside world.
type Repository interface {
// Create returns ErrEmailTaken if the email already exists.
Create(ctx context.Context, a Account) error
FindByID(ctx context.Context, id string) (Account, error)
}
type Notifier interface {
SendWelcome(ctx context.Context, a Account) error
}The ports speak the language of the domain: Repository has no Exec or Query, and Notifier says "send welcome", not "send SMTP message". The error contract (ErrEmailTaken, ErrNotFound) is part of the port, so every adapter must translate its own errors into these.
This suits Go well. Interfaces are satisfied implicitly, and Go convention is to define an interface where it's used, which is exactly what a driven port is. The Postgres adapter satisfies Repository without the core ever importing it (more on this in Go structs, methods and interfaces).
The use cases
// internal/account/service.go
package account
import (
"context"
"fmt"
"log/slog"
"time"
)
type Service struct {
repo Repository
notify Notifier
newID func() string
now func() time.Time
}
var _ API = (*Service)(nil) // compile-time check
func NewService(repo Repository, notify Notifier, newID func() string, now func() time.Time) *Service {
return &Service{repo: repo, notify: notify, newID: newID, now: now}
}
func (s *Service) Register(ctx context.Context, email, name string) (Account, error) {
a, err := New(s.newID(), email, name, s.now())
if err != nil {
return Account{}, err
}
if err := s.repo.Create(ctx, a); err != nil {
return Account{}, fmt.Errorf("create account: %w", err)
}
if err := s.notify.SendWelcome(ctx, a); err != nil {
// The account exists. A failed email shouldn't fail the signup.
slog.WarnContext(ctx, "welcome email failed", "account", a.ID, "err", err)
}
return a, nil
}
func (s *Service) Get(ctx context.Context, id string) (Account, error) {
return s.repo.FindByID(ctx, id)
}Even the clock and ID generator are injected, so tests are fully deterministic.
A driving adapter: HTTP
// internal/adapter/httpapi/handler.go
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/acme/signup/internal/account"
)
type Handler struct{ accounts account.API }
func NewHandler(accounts account.API) *Handler { return &Handler{accounts: accounts} }
func (h *Handler) Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("POST /accounts", h.register)
mux.HandleFunc("GET /accounts/{id}", h.get)
return mux
}
type accountResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
a, err := h.accounts.Register(r.Context(), req.Email, req.Name)
switch {
case errors.Is(err, account.ErrInvalidEmail), errors.Is(err, account.ErrInvalidName):
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
case errors.Is(err, account.ErrEmailTaken):
http.Error(w, err.Error(), http.StatusConflict)
case err != nil:
http.Error(w, "internal error", http.StatusInternalServerError)
default:
writeJSON(w, http.StatusCreated, accountResponse{a.ID, a.Email, a.Name})
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}The get handler follows the same pattern and maps ErrNotFound to 404. The adapter's only job is translation: JSON in, domain call, domain errors mapped to HTTP status codes, DTO out. Swapping net/http for Gin would change this file and nothing else. The Gin and GORM REST API guide shows what that adapter would look like.
A driven adapter: Postgres
// internal/adapter/postgres/accounts.go
package postgres
import (
"context"
"database/sql"
"errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/acme/signup/internal/account"
)
type AccountRepo struct{ db *sql.DB }
func NewAccountRepo(db *sql.DB) *AccountRepo { return &AccountRepo{db: db} }
func (r *AccountRepo) Create(ctx context.Context, a account.Account) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO accounts (id, email, name, created_at) VALUES ($1, $2, $3, $4)`,
a.ID, a.Email, a.Name, a.CreatedAt)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "accounts_email_key" {
return account.ErrEmailTaken
}
return err
}The database's unique constraint enforces "email must be unique" safely under concurrency, and the adapter translates PostgreSQL error 23505 into the domain's ErrEmailTaken. FindByID (omitted) maps sql.ErrNoRows to ErrNotFound the same way. The core never sees a driver type.
Wiring it together
// cmd/api/main.go (imports omitted, including the
// blank import of github.com/jackc/pgx/v5/stdlib)
func main() {
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
svc := account.NewService(
postgres.NewAccountRepo(db),
email.NewSMTPNotifier(os.Getenv("SMTP_ADDR")),
uuid.NewString,
time.Now,
)
log.Fatal(http.ListenAndServe(":8080", httpapi.NewHandler(svc).Routes()))
}main is the only place that knows every concrete type. It picks the adapters and plugs them in.
Testing is where hexagonal architecture pays off
Because driven ports are interfaces, you can test every use case with in-memory adapters, with no containers or network.
// internal/account/service_test.go
package account_test
type memRepo struct{ byID map[string]account.Account }
func (m *memRepo) Create(_ context.Context, a account.Account) error {
for _, existing := range m.byID {
if existing.Email == a.Email {
return account.ErrEmailTaken
}
}
m.byID[a.ID] = a
return nil
}
func (m *memRepo) FindByID(_ context.Context, id string) (account.Account, error) {
a, ok := m.byID[id]
if !ok {
return account.Account{}, account.ErrNotFound
}
return a, nil
}
type spyNotifier struct{ sent []string }
func (s *spyNotifier) SendWelcome(_ context.Context, a account.Account) error {
s.sent = append(s.sent, a.Email)
return nil
}
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
ctx := context.Background()
notifier := &spyNotifier{}
n := 0
svc := account.NewService(
&memRepo{byID: map[string]account.Account{}},
notifier,
func() string { n++; return fmt.Sprintf("acc-%d", n) },
func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) },
)
if _, err := svc.Register(ctx, "[email protected]", "Ana"); err != nil {
t.Fatalf("first register: %v", err)
}
_, err := svc.Register(ctx, " [email protected] ", "Ana again")
if !errors.Is(err, account.ErrEmailTaken) {
t.Fatalf("want ErrEmailTaken, got %v", err)
}
if len(notifier.sent) != 1 {
t.Fatalf("want 1 welcome email, got %d", len(notifier.sent))
}
}The test covers normalization, the duplicate rule and the email side effect. You still need a few integration tests for the Postgres adapter, but they only test translation, not business rules.
Hexagonal vs layered vs clean architecture
| Layered (N-tier) | Hexagonal | Clean / Onion | |
|---|---|---|---|
| Dependency direction | Top down, business layer often depends on data layer | Inward, core depends on nothing | Inward, same rule |
| Database position | At the bottom, a foundation | An adapter at the edge | An outer ring |
| Main idea | Separate by technical role | Separate inside from outside | Same as hexagonal, with more named layers |
Clean and onion architecture refine the same idea, so ports and adapters is a good mental model for all three.
Common mistakes
- Leaking adapter types into the core. A
gorm.Modelembedded in an entity, orjsontags on domain structs, ties the core to a library. Keep DTOs and row types in the adapters. - Ports shaped like the database. A generic
Query(sql string)port isn't a port. Name ports after what the domain needs. - An interface for everything. Abstract only at the boundary, not for helpers inside the core.
- Ignoring transactions. If a use case must update two repositories atomically, design for it: a single repository method for the aggregate, or a unit-of-work port that the adapter implements with a database transaction.
When hexagonal architecture is overkill
- Thin CRUD services where the "business logic" is validation and a single insert. The mapping code may outweigh the logic.
- Prototypes and scripts you expect to throw away.
You don't have to adopt it all at once. Start by isolating the dependencies that make testing painful, usually the database and third-party APIs. This fits well with drawing clear service boundaries with DDD: each bounded context gets its own small hexagon.
FAQ
What is the difference between a port and an adapter?
A port is an interface owned by the core that describes an interaction, such as Repository or Notifier. An adapter is a concrete implementation that connects that port to a technology, such as a Postgres repository or an SMTP client.
Is hexagonal architecture the same as clean architecture?
They share the core rule that dependencies point inward toward the business logic. Clean architecture adds named concentric layers inside the core. Hexagonal architecture only separates inside from outside.
Where should interfaces live in a Go hexagonal project?
In the core package that uses them. The core declares the ports, and the adapters in other packages satisfy them implicitly. See Go project layout for how this fits into internal/ and cmd/.
Takeaways
- Put business rules in a core that imports no frameworks or drivers.
- Define driving ports for what the app does and driven ports for what it needs, in domain language.
- Keep adapters thin: translate data and errors, nothing more.
- Wire everything in
main, and test use cases with in-memory adapters. - Skip the full pattern for thin CRUD. Apply it where testing and replaceability matter.
If you're weighing an architecture like this for a new system, the Vectorkub team can help design and build it.
