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.