Hexagonal Architecture in Go: Why Your Service's Business Logic Should Know Nothing About HTTP

go dev.to

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}
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials