- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You started the service in a weekend. One main.go, one db.go, a handful of structs. It was clean. Everyone said so.
Two years later the same service has a models package that imports the Postgres driver, a handlers package that builds SQL strings, and a domain type with a json tag and a db tag and a validate tag fighting for space on every field. Nobody can change the database without touching the business rules. Nobody can test the rules without a database.
Nothing went wrong on any single day. The layout had no rule about which package was allowed to import which, so every shortcut was legal. The architecture was the import graph the whole time. You just never wrote it down.
Hexagonal architecture (ports and adapters) gives you that rule. In Go you do not need a framework to enforce it. You need three things the language already has: package directories, the internal/ mechanic, and the direction your imports point.
The import graph is the architecture
Draw your service as boxes and arrows on a whiteboard. The boxes are packages. The arrows are imports. That diagram is not documentation of your architecture. It is your architecture. Everything else is a comment.
Ports and adapters says the arrows point one way: inward, toward the domain. The domain depends on nothing. The application layer depends on the domain. The adapters depend on the application and the domain. Never the reverse.
adapter ──▶ application ──▶ domain
(http, postgres, kafka) (no imports)
The domain knows nothing about HTTP, SQL, or JSON. It is the part that would survive a rewrite of every other layer. The adapters are the part you expect to replace.
The folder layout
Here is the shape that holds up.
billing/
├── go.mod
├── cmd/
│ └── server/
│ └── main.go // wiring lives here
└── internal/
├── domain/ // entities, value objects, rules
│ └── invoice.go
├── app/ // use cases, defines ports
│ ├── issue_invoice.go
│ └── port.go
└── adapter/ // implements ports
├── http/
│ └── handler.go
└── postgres/
└── invoice_repo.go
Three things to notice.
The whole service lives under internal/. That is deliberate. Go's compiler refuses any import of a package under internal/ from outside the tree rooted at its parent. So nothing in another module can reach into billing/internal/.... Your layers are private to the service by default.
The domain package has no subfolders for infrastructure. There is no domain/postgres. If you ever feel the urge to add one, the dependency arrow is about to flip.
The cmd/server/main.go is the only place the concrete adapters meet the use cases. It is the composition root. Everything above it talks through interfaces.
Domain: the package that imports nothing
The domain holds the types and rules. Standard library only. No driver, no web framework, no ORM.
// internal/domain/invoice.go
package domain
import (
"errors"
"time"
)
type Status string
const (
StatusDraft Status = "draft"
StatusIssued Status = "issued"
StatusPaid Status = "paid"
)
type Invoice struct {
ID string
CustomerID string
AmountCents int64
Status Status
IssuedAt time.Time
}
var ErrAlreadyIssued = errors.New(
"invoice already issued",
)
func (i *Invoice) Issue(at time.Time) error {
if i.Status != StatusDraft {
return ErrAlreadyIssued
}
i.Status = StatusIssued
i.IssuedAt = at
return nil
}
No json tag. No db tag. The rule about when an invoice may be issued lives next to the data it guards, and you can test it with a struct literal and zero infrastructure.
App: the use case defines the ports it needs
The application layer is where a use case lives. It does not know about Postgres. It knows it needs something that can save an invoice. So it states that need as an interface, in its own package. That interface is the port.
// internal/app/port.go
package app
import (
"context"
"billing/internal/domain"
)
type InvoiceRepo interface {
Find(
ctx context.Context, id string,
) (*domain.Invoice, error)
Save(
ctx context.Context, inv *domain.Invoice,
) error
}
The port is defined by the consumer, not the implementer. This is the Go idiom: accept interfaces, and let the package that needs the behavior own the contract. The Postgres adapter will satisfy app.InvoiceRepo; it does not get to define it.
// internal/app/issue_invoice.go
package app
import (
"context"
"time"
"billing/internal/domain"
)
type IssueInvoice struct {
repo InvoiceRepo
clock func() time.Time
}
func NewIssueInvoice(
repo InvoiceRepo, clock func() time.Time,
) *IssueInvoice {
return &IssueInvoice{repo: repo, clock: clock}
}
func (uc *IssueInvoice) Run(
ctx context.Context, id string,
) error {
inv, err := uc.repo.Find(ctx, id)
if err != nil {
return err
}
if err := inv.Issue(uc.clock()); err != nil {
return err
}
return uc.repo.Save(ctx, inv)
}
The use case imports domain and its own port. It imports no driver. You can test IssueInvoice with a fake repo that lives in a _test.go file and a fixed clock. The test runs in microseconds because there is nothing to connect to.
Adapter: the outer ring, where the messy stuff lives
The Postgres adapter imports the driver, builds SQL, and maps rows to domain types. It depends on app (to satisfy the port) and domain (to return the types). Nothing depends on it except main.
// internal/adapter/postgres/invoice_repo.go
package postgres
import (
"context"
"database/sql"
"billing/internal/domain"
)
type InvoiceRepo struct {
db *sql.DB
}
func New(db *sql.DB) *InvoiceRepo {
return &InvoiceRepo{db: db}
}
func (r *InvoiceRepo) Find(
ctx context.Context, id string,
) (*domain.Invoice, error) {
const q = `
SELECT id, customer_id, amount_cents, status
FROM invoices WHERE id = $1
`
var inv domain.Invoice
err := r.db.QueryRowContext(ctx, q, id).Scan(
&inv.ID, &inv.CustomerID,
&inv.AmountCents, &inv.Status,
)
if err != nil {
return nil, err
}
return &inv, nil
}
func (r *InvoiceRepo) Save(
ctx context.Context, inv *domain.Invoice,
) error {
const q = `
UPDATE invoices
SET status = $2, issued_at = $3
WHERE id = $1
`
_, err := r.db.ExecContext(
ctx, q, inv.ID, inv.Status, inv.IssuedAt,
)
return err
}
The SQL strings, the database/sql import, the row scanning — all of it sits in the outer ring. The day you move from Postgres to DynamoDB, this is the only file that changes. The use case and the domain do not know it happened.
Wiring: main is the only place the rings meet
The composition root in main.go is where the concrete adapter gets handed to the use case as the abstract port.
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"time"
"billing/internal/adapter/postgres"
"billing/internal/app"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", dsn())
if err != nil {
log.Fatal(err)
}
repo := postgres.New(db)
uc := app.NewIssueInvoice(repo, time.Now)
// hand uc to the http adapter, start server...
_ = uc
}
postgres.New(db) returns a *postgres.InvoiceRepo. app.NewIssueInvoice accepts an app.InvoiceRepo. The concrete type satisfies the interface, so the assignment compiles, and the dependency points inward the whole way down. The arrow from adapter to app exists only here, at the edge.
Why the layout survives year two
The win is not tidiness. It is that the wrong import does not compile, or does not pass a one-line check.
A new engineer wants to read a database row inside the domain. To do it they would have to import database/sql into internal/domain. The moment they save that file, the diff shows a standard-library database import landing in the package that is supposed to import nothing. A reviewer catches it in one glance, because the rule is simple: domain imports nothing but the standard library.
You can make the reviewer optional. One go list check in CI asserts the domain stays clean.
#!/usr/bin/env bash
set -euo pipefail
bad=$(go list -deps ./internal/domain/... \
| grep -E 'internal/(app|adapter)' || true)
if [ -n "$bad" ]; then
echo "FAIL: domain imports an outer layer:"
echo "$bad"
exit 1
fi
If the domain ever transitively imports app or adapter, the build is red. The arrow can only point inward, and now a machine enforces it.
That is the whole trick. The folder names tell a reader the intent. The internal/ mechanic and the import direction turn the intent into a rule the compiler and CI keep for you. Two years of shortcuts, deadline commits, and new hires cannot bend it, because bending it does not build.
Start the next service with domain, app, adapter, and a composition root in main. Point every arrow inward. The layout costs you three folders on day one and saves you the rewrite on day seven hundred.
If this was useful
The layers, the ports defined by their consumers, and the import graph that keeps them honest are the spine of Hexagonal Architecture in Go. The book takes this same internal/ layout further: how the application service grows, where transactions sit, how to test each ring in isolation, and when to split a package out before it turns into the year-two blob.

Top comments (0)