Skip to content
Back to Blog

Hexagonal Architecture in Go, Without the Ceremony

Ports and adapters keep business logic independent of databases and frameworks. Here's a practical, idiomatic way to apply it in Go: small interfaces, defined where they're used, and no enterprise folder maze.

Sun8 min read

Search for "clean architecture in Go" and you'll find repositories with a dozen top-level folders, interfaces for every struct, and a usecase package that forwards calls to a service package, which forwards them to a repository package. Then someone replies that Go should be simple and you should just put everything in main.go.

Both reactions make sense. The first over-applies a good idea. The second throws the idea away. The idea in question is hexagonal architecture, also called ports and adapters, which Alistair Cockburn described in 2005. Stripped of ceremony, it fits Go very naturally.

The idea in one paragraph

Your application has a core, the business logic, which should be the most stable and most tested part of the system. Around it sit the things that change for reasons unrelated to the business: databases, HTTP frameworks, message brokers, third-party APIs. The core talks to the outside world only through ports, which are interfaces the core defines in its own terms. Adapters implement those ports for a specific technology. Dependencies always point inward: adapters know about the core, and the core knows nothing about adapters.

The "hexagon" shape is just a drawing convention. The number of sides means nothing. What matters is inside versus outside.

Two kinds of ports

  • Driving (inbound) ports are how the outside world uses your application: "place an order", "cancel a subscription". HTTP handlers, CLI commands and queue consumers are driving adapters that call these.
  • Driven (outbound) ports are what your application needs from the outside world: "save this order", "charge this card". A Postgres repository or a Stripe client is a driven adapter that implements these.

A worked example

Let's build a small order service. First the domain, which is plain Go with no imports from infrastructure:

package order

import (
	"errors"
	"time"
)

var ErrEmptyOrder = errors.New("order has no items")

type Item struct {
	SKU      string
	Quantity int
	Cents    int64
}

type Order struct {
	ID        string
	Customer  string
	Items     []Item
	Status    string
	CreatedAt time.Time
}

func (o Order) Total() int64 {
	var total int64
	for _, it := range o.Items {
		total += int64(it.Quantity) * it.Cents
	}
	return total
}

Driven ports: interfaces defined by the consumer

Here's the Go-specific part that many "clean architecture in Go" templates get backwards. In Java, you usually define an interface next to its implementation. In Go, interfaces belong to the package that uses them. A type satisfies an interface implicitly, so the Postgres package doesn't need to import or even know about the interface.

So the core declares exactly what it needs, and nothing more:

package order

import "context"

// Repository is a driven port: what the core needs from storage.
type Repository interface {
	Save(ctx context.Context, o Order) error
}

// Payments is a driven port: what the core needs from a payment provider.
type Payments interface {
	Charge(ctx context.Context, customer string, cents int64) error
}

Note what isn't here: no FindAll, no Update, no Delete "just in case". A port with one method is easy to fake, easy to implement and easy to understand. Add methods only when a use case actually needs them.

The application service: the driving port

package order

import (
	"context"
	"fmt"
	"time"
)

type Service struct {
	repo     Repository
	payments Payments
	now      func() time.Time
	newID    func() string
}

func NewService(repo Repository, payments Payments, newID func() string) *Service {
	return &Service{repo: repo, payments: payments, now: time.Now, newID: newID}
}

func (s *Service) PlaceOrder(ctx context.Context, customer string, items []Item) (Order, error) {
	if len(items) == 0 {
		return Order{}, ErrEmptyOrder
	}

	o := Order{
		ID:        s.newID(),
		Customer:  customer,
		Items:     items,
		Status:    "paid",
		CreatedAt: s.now(),
	}

	if err := s.payments.Charge(ctx, customer, o.Total()); err != nil {
		return Order{}, fmt.Errorf("charge customer %s: %w", customer, err)
	}
	if err := s.repo.Save(ctx, o); err != nil {
		return Order{}, fmt.Errorf("save order %s: %w", o.ID, err)
	}
	return o, nil
}

The service holds the business rules: an order needs items, the total comes from the items, payment happens before the order is saved. It doesn't know whether storage is Postgres or a file, or whether it's being called from HTTP or gRPC. Even the clock and ID generator are injected, which makes the service fully deterministic in tests.

(In a real system, "charge and then save" is a distributed transaction problem. Idempotency keys and an outbox would come into it. I covered those in a separate post, so I'll keep this example simple.)

Driven adapters: the outside world

The Postgres adapter lives in its own package and imports the domain, never the other way round:

package postgres

import (
	"context"
	"database/sql"
	"fmt"

	"example.com/shop/internal/order"
)

type OrderRepo struct{ db *sql.DB }

func NewOrderRepo(db *sql.DB) *OrderRepo { return &OrderRepo{db: db} }

func (r *OrderRepo) Save(ctx context.Context, o order.Order) error {
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO orders (id, customer, status, total_cents, created_at)
		 VALUES ($1, $2, $3, $4, $5)`,
		o.ID, o.Customer, o.Status, o.Total(), o.CreatedAt)
	if err != nil {
		return fmt.Errorf("insert order: %w", err)
	}
	return nil
}

*postgres.OrderRepo satisfies order.Repository without mentioning it anywhere. If you'd like the compiler to confirm that, add one line at package level: var _ order.Repository = (*OrderRepo)(nil).

Driving adapter: HTTP

package httpapi

import (
	"encoding/json"
	"errors"
	"net/http"

	"example.com/shop/internal/order"
)

type placeOrderRequest struct {
	Customer string       `json:"customer"`
	Items    []order.Item `json:"items"`
}

func PlaceOrder(svc *order.Service) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req placeOrderRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "invalid JSON", http.StatusBadRequest)
			return
		}
		o, err := svc.PlaceOrder(r.Context(), req.Customer, req.Items)
		if errors.Is(err, order.ErrEmptyOrder) {
			http.Error(w, err.Error(), http.StatusUnprocessableEntity)
			return
		}
		if err != nil {
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated)
		json.NewEncoder(w).Encode(o)
	}
}

The handler translates HTTP into a use case call and the result back into HTTP. It contains no business rules.

Wiring it up in main

main is the one place that knows about everything. It builds the adapters and plugs them into the core:

func main() {
	db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}

	svc := order.NewService(
		postgres.NewOrderRepo(db),
		stripe.NewClient(os.Getenv("STRIPE_KEY")),
		uuid.NewString,
	)

	http.Handle("POST /orders", httpapi.PlaceOrder(svc))
	log.Fatal(http.ListenAndServe(":8080", nil))
}

No DI framework. Constructor calls in main are the dependency injection. (The "POST /orders" pattern uses the method-aware routing added to net/http in Go 1.22.)

The payoff: testing the core with fakes

Because the ports are tiny, test fakes are a few lines each. No mocking library needed:

type fakeRepo struct{ saved []order.Order }

func (f *fakeRepo) Save(_ context.Context, o order.Order) error {
	f.saved = append(f.saved, o)
	return nil
}

type failingPayments struct{}

func (failingPayments) Charge(context.Context, string, int64) error {
	return errors.New("card declined")
}

func TestPlaceOrder_PaymentFailureSavesNothing(t *testing.T) {
	repo := &fakeRepo{}
	svc := order.NewService(repo, failingPayments{}, func() string { return "o-1" })

	_, err := svc.PlaceOrder(context.Background(), "c-1", []order.Item{{SKU: "A", Quantity: 1, Cents: 500}})

	if err == nil {
		t.Fatal("expected an error when payment fails")
	}
	if len(repo.saved) != 0 {
		t.Fatalf("expected no saved orders, got %d", len(repo.saved))
	}
}

This test runs in microseconds, needs no Docker, and checks a real business rule: a failed payment must not create an order. Tests for the adapters themselves, like "does the SQL actually work?", are integration tests against a real database. You'll have far fewer of those.

A package layout that stays small

shop/
├── cmd/api/main.go            # wiring only
└── internal/
    ├── order/                 # domain + service + ports (the core)
    ├── postgres/              # driven adapter
    ├── stripe/                # driven adapter
    └── httpapi/               # driving adapter

That's it. No domain/entities/, application/usecases/ or infrastructure/persistence/repositories/. Organise by what the code is about, and let the direction of imports enforce the architecture. Go makes an import cycle a compile error, so if order ever tries to import postgres, the build fails.

When not to bother

Hexagonal architecture pays off when the business logic is worth protecting: real rules, a long expected lifetime, and several ways in or out. It doesn't pay off for:

  • A thin CRUD service where the "business logic" is "store this JSON". Adding ports there only adds layers.
  • Scripts, prototypes and one-off tools.
  • Code where the database is the logic, like reporting queries. Hiding SQL behind a repository interface there just makes it harder to read.

Start simple. When you notice business rules mixed into HTTP handlers or SQL, that's the time to pull out a core and put ports around it. The pattern is a way to manage change, and it's worth the extra structure only when you actually expect change.


Want to talk architecture? Drop me a message on the contact page.