You've seen it happen. Maybe you caused it.
A Go service starts as a clean main.go with a few handlers. Six months later, the handler that was 40 lines is 400. The SQL queries live inside HTTP handlers. The business logic imports database/sql. The test suite takes six minutes because every test needs a running PostgreSQL.
Someone suggests a refactor. A Jira ticket gets created. Nobody assigns it.
This isn't because Go developers are bad. It's because Go gives you freedom without structure. No mandatory base classes. No framework telling you where things go. Freedom without a plan leads to spaghetti.
The One Rule That Fixes It
Hexagonal architecture (ports and adapters) comes down to one rule:
Dependencies always point inward. The domain knows nothing about infrastructure.
Your Order entity doesn't know it's stored in PostgreSQL. Your OrderService doesn't know it's called from an HTTP handler. Your discount calculation doesn't know the coupon code came from a JSON body.
// The domain defines what it needs — a port (interface)
type OrderRepository interface {
Save(ctx context.Context, order Order) error
FindByID(ctx context.Context, id string) (Order, error)
}
// The domain service depends on the interface, not the database
type OrderService struct {
repo OrderRepository
}
func NewOrderService(repo OrderRepository) *OrderService {
return &OrderService{repo: repo}
}
The adapter (PostgreSQL, in-memory, whatever) implements the interface. The domain never knows which one is on the other side.
Why Go Is Perfect for This
In Java, hexagonal architecture requires dependency injection frameworks, annotation processors, and configuration hell. In Go, you need interfaces and a main() function. That's it.
Go's implicit interfaces are the killer feature. The adapter doesn't need to declare implements OrderRepository. It just needs the right methods:
// This automatically satisfies OrderRepository
// No "implements" keyword. No import of the domain package needed.
type PostgresOrderRepository struct {
db *sql.DB
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order Order) error {
// SQL INSERT
return nil
}
func (r *PostgresOrderRepository) FindByID(ctx context.Context, id string) (Order, error) {
// SQL SELECT, translate sql.ErrNoRows to domain.ErrOrderNotFound
return Order{}, nil
}
And main() is your composition root — the one place where everything gets wired:
func main() {
repo := postgres.NewOrderRepository(db)
notifier := email.NewNotifier(smtpClient)
service := domain.NewOrderService(repo, notifier)
handler := httphandler.New(service)
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", handler.Create())
log.Fatal(http.ListenAndServe(":8080", mux))
}
No framework. No reflection. No magic. Five lines of wiring and you can see the entire architecture.
The Before and After
Before (spaghetti):
- Business logic mixed with HTTP parsing and SQL queries
- Can't test without a running database
- Can't add gRPC without copy-pasting the handler
- New developers take weeks to understand the codebase
After (hexagonal):
- Domain tests run in microseconds — no Docker, no database
- Swap PostgreSQL for DynamoDB by changing one line in
main() - Add gRPC, CLI, or Kafka consumer without touching business logic
- New developers read
main()and understand the architecture
The Book
I wrote a full book on this: 22 chapters, 5 parts, from spaghetti code to production-ready hexagonal services in Go.
📖 Hexagonal Architecture in Go: Ports, Adapters, and Services That Last
It covers everything: domain modeling, port design, HTTP and outbound adapters, dependency injection in main(), testing at every layer, error handling across boundaries, transactions (Unit of Work), event-driven adapters, observability via decorators, when to skip hexagonal entirely, and migrating existing services incrementally.
Every code example is tested in a companion repository.
Book 2 in the "Thinking in Go" series. Book 1 (The Complete Guide to Go Programming) teaches the language. Book 2 teaches how to architect with it.
This is Part 1 of a 5-part series on Hexagonal Architecture in Go. Next up: how Go interfaces naturally become ports — and why that matters more than you think.