Step 16 of 24

Clean & hexagonal architecture

Keep business rules independent of frameworks and databases with the dependency rule, ports and adapters, and a Go service laid out that way.

19 min 8 sections

Frameworks, databases and APIs change on their own schedule; the business rules of your product change on another. When the two are tangled together — SQL inside pricing logic, HTTP status codes inside domain functions — every infrastructure change risks the business logic, and the business logic can't be tested without a database and a web server. Clean architecture, hexagonal architecture (ports and adapters) and onion architecture are three names for one idea that fixes this. This lesson explains that idea, builds a small Go service with it, and is honest about when it's too much.

The problem: business logic welded to infrastructure

Here's a typical handler from a codebase that grew fast:

go
func (h *Handler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
	var req struct {
		CustomerID string   `json:"customer_id"`
		Items      []string `json:"items"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	var total int64
	for _, id := range req.Items {
		var price int64
		if err := h.db.QueryRowContext(r.Context(), `SELECT price FROM products WHERE id = $1`, id).Scan(&price); err != nil {
			http.Error(w, "unknown product", http.StatusBadRequest)
			return
		}
		total += price
	}
	if total > 10_000_000 { // fraud rule
		http.Error(w, "order too large", http.StatusUnprocessableEntity)
		return
	}
	// ... INSERT INTO orders, call payment provider, write response
}

Three concerns are fused: HTTP (decoding, status codes), persistence (SQL) and business rules (pricing, the fraud limit). To test the fraud rule you need an HTTP request and a database with products in it. To add a gRPC endpoint or a queue consumer that places orders, you copy the rules. To move prices to a catalogue service, you edit the handler.

The dependency rule

All three architectures arrange code in concentric layers and impose one rule:

Source code dependencies point only inward. Nothing in an inner layer knows anything about an outer layer.

text
+-------------------------------------------------------------+
| Frameworks & drivers: net/http, pgx, Kafka client, React    |
|  +-------------------------------------------------------+  |
|  | Adapters: HTTP handlers, Postgres repositories,       |  |
|  | payment gateway clients                               |  |
|  |  +-------------------------------------------------+  |  |
|  |  | Application (use cases): PlaceOrder, Cancel...  |  |  |
|  |  |  +-------------------------------------------+  |  |  |
|  |  |  | Domain: Order, Money, pricing and fraud   |  |  |  |
|  |  |  | rules -- pure business logic              |  |  |  |
|  |  |  +-------------------------------------------+  |  |  |
|  |  +-------------------------------------------------+  |  |
|  +-------------------------------------------------------+  |
+-------------------------------------------------------------+
        source code dependencies point inward only
  • Domain (Robert Martin calls these "entities"): business types and rules. No imports of HTTP, SQL, JSON tags, or frameworks.
  • Application / use cases: one operation the system performs — place an order, cancel a booking. Orchestrates domain objects and calls out through interfaces it defines.
  • Adapters: translate between the outside world and the use cases: HTTP handlers, database repositories, clients for external APIs.
  • Frameworks and drivers: the libraries and tools themselves.

But the use case must save orders to a database, which lives outside. How can it call outward without depending outward? With dependency inversion: the use case declares an interface — OrderRepository — and the Postgres adapter implements it. Control flows out to the database; the source dependency points in.

Ports and adapters

Alistair Cockburn's hexagonal architecture describes the same structure with two words that are easier to use day to day:

  • A port is an interface owned by the application, describing a conversation with the outside world.
  • An adapter implements a port for one specific technology.

Ports come in two directions:

Driving (primary) sideDriven (secondary) side
Who starts the conversationThe outside world calls the appThe app calls the outside world
PortThe use case's API (PlaceOrder)An interface the app needs (OrderRepository, PaymentGateway)
AdaptersHTTP handler, gRPC server, CLI, queue consumer, testPostgres repo, Stripe/Midtrans client, SMTP mailer, in-memory fake

The "hexagon" shape has no meaning beyond leaving room to draw many ports. The insight is symmetry: tests are just another adapter. A unit test drives the application through its primary port, with fakes plugged into the secondary ports — no web server, no database. The ports and adapters note has a one-page summary.

A Go service, laid out this way

Here is the same order placement rebuilt. The layout:

text
internal/ordering/
  domain/      order.go          # Order, Money, rules — imports only the standard library
  app/         place_order.go    # use case + the ports it needs
  adapters/
    httpapi/   handler.go        # driving adapter
    postgres/  orders.go         # driven adapter
cmd/shop/main.go                 # wires adapters into the app

Domain — pure rules, trivially testable:

go
package domain

var ErrOrderTooLarge = errors.New("order exceeds the fraud limit")

const fraudLimit int64 = 10_000_000

type Order struct {
	ID         string
	CustomerID string
	Lines      []Line
	Status     Status
}

type Line struct {
	ProductID string
	UnitPrice int64
	Quantity  int
}

func (o Order) Total() int64 {
	var t int64
	for _, l := range o.Lines {
		t += l.UnitPrice * int64(l.Quantity)
	}
	return t
}

func NewOrder(id, customerID string, lines []Line) (Order, error) {
	o := Order{ID: id, CustomerID: customerID, Lines: lines, Status: StatusPending}
	if o.Total() > fraudLimit {
		return Order{}, ErrOrderTooLarge
	}
	return o, nil
}

Application — the use case and the ports it owns:

go
package app

var ErrUnknownProduct = errors.New("unknown product")

type Orders interface {
	Save(ctx context.Context, o domain.Order) error
}

type Prices interface {
	UnitPrices(ctx context.Context, productIDs []string) (map[string]int64, error)
}

type PlaceOrder struct {
	orders Orders
	prices Prices
	newID  func() string
}

func NewPlaceOrder(orders Orders, prices Prices, newID func() string) *PlaceOrder {
	return &PlaceOrder{orders: orders, prices: prices, newID: newID}
}

type PlaceOrderInput struct {
	CustomerID string
	Items      map[string]int // productID -> quantity
}

func (uc *PlaceOrder) Execute(ctx context.Context, in PlaceOrderInput) (domain.Order, error) {
	ids := slices.Sorted(maps.Keys(in.Items)) // sorted: deterministic line order
	prices, err := uc.prices.UnitPrices(ctx, ids)
	if err != nil {
		return domain.Order{}, fmt.Errorf("look up prices: %w", err)
	}
	lines := make([]domain.Line, 0, len(ids))
	for _, id := range ids {
		price, ok := prices[id]
		if !ok {
			return domain.Order{}, fmt.Errorf("product %s: %w", id, ErrUnknownProduct)
		}
		lines = append(lines, domain.Line{ProductID: id, UnitPrice: price, Quantity: in.Items[id]})
	}
	order, err := domain.NewOrder(uc.newID(), in.CustomerID, lines)
	if err != nil {
		return domain.Order{}, err
	}
	if err := uc.orders.Save(ctx, order); err != nil {
		return domain.Order{}, fmt.Errorf("save order: %w", err)
	}
	return order, nil
}

Driving adapter — HTTP in, use case call, HTTP out. All HTTP knowledge lives here, including mapping errors to status codes:

go
package httpapi

func (h *Handler) placeOrder(w http.ResponseWriter, r *http.Request) {
	var body struct {
		CustomerID string         `json:"customer_id"`
		Items      map[string]int `json:"items"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeError(w, http.StatusBadRequest, "invalid JSON")
		return
	}
	order, err := h.placeOrder.Execute(r.Context(), app.PlaceOrderInput{CustomerID: body.CustomerID, Items: body.Items})
	switch {
	case errors.Is(err, domain.ErrOrderTooLarge), errors.Is(err, app.ErrUnknownProduct):
		writeError(w, http.StatusUnprocessableEntity, err.Error())
	case err != nil:
		writeError(w, http.StatusInternalServerError, "internal error")
	default:
		writeJSON(w, http.StatusCreated, toOrderResponse(order))
	}
}

Driven adapter — postgres.Orders implements app.Orders with SQL; the JSON tags and DB column names never leak into the domain. main builds the Postgres adapter, a catalogue client implementing app.Prices, the use case, and the HTTP handler, and connects them.

Now the fraud rule is a unit test of domain.NewOrder; the use case is tested with two in-memory fakes; and adding a queue consumer that places orders is a new driving adapter calling the same Execute.

Practical decisions

  • Where does validation go? Shape validation (is it valid JSON, is a field present) belongs in the adapter. Business validation (is the order too large, can this customer order) belongs in the domain or use case, so every adapter gets it.
  • Map at the boundaries. HTTP request types, database rows and domain types are separate structs. It costs some mapping code, and buys the freedom to change the API or schema without touching rules. Don't put json: or db: tags on domain types.
  • Transactions span several repositories but are an infrastructure concern. A common Go approach is a port like type Transactor interface { WithinTx(ctx context.Context, fn func(ctx context.Context) error) error }, implemented by the Postgres adapter, which stores the transaction in the context for repositories to use.
  • Don't over-layer. Not every package needs four sublayers, and a use case that only forwards to a repository is ceremony. In Go, keep it flat and consumer-defined: interfaces live where they're used, and a small feature can be one package with the same dependency rule followed inside it.

When it's worth it — and when it isn't

The architecture pays off when there is real business logic worth protecting, multiple entry points (HTTP, gRPC, jobs, events), external dependencies that are likely to change, and a codebase that will live for years. That describes most core services at a product company.

It's overkill for a CRUD admin tool whose "logic" is reading and writing rows, a prototype, or a small script: there, the layers are pure mapping with nothing to protect, and a straightforward handler-plus-queries design is clearer. You can also adopt it incrementally: start by pulling business rules out of handlers into plain functions, then introduce ports where you first need a fake or a second adapter. The modular design lesson decides where the boundaries between features go; clean architecture decides how each feature is layered inside.

Practice

A notification feature currently lives in one HTTP handler that reads a user's preferences from Postgres, decides whether to send an email or a push notification (users can opt out of each, and nobody gets notifications between 22:00 and 07:00 in their time zone), and calls SendGrid or Firebase directly. Design it with ports and adapters: list the domain rule(s), the use case, the driving and driven ports, the adapters, and how you'd unit-test the quiet-hours rule.

Show solution

Domain: a pure function deciding channels:

go
type Preferences struct {
	EmailOptIn, PushOptIn bool
	TimeZone              *time.Location
}

type Channel string

const (
	Email Channel = "email"
	Push  Channel = "push"
)

// ChannelsAt returns the channels a user may be notified on at instant now.
func ChannelsAt(p Preferences, now time.Time) []Channel {
	h := now.In(p.TimeZone).Hour()
	if h >= 22 || h < 7 {
		return nil // quiet hours
	}
	var out []Channel
	if p.EmailOptIn {
		out = append(out, Email)
	}
	if p.PushOptIn {
		out = append(out, Push)
	}
	return out
}

Use case: Notify.Execute(ctx, userID, message) loads preferences, calls ChannelsAt with the current time, and sends on each channel.

Driving port: the Notify use case itself. Driving adapters: the existing HTTP handler, and later a queue consumer for events like "order shipped".

Driven ports: PreferencesStore (Get(ctx, userID)), EmailSender, PushSender, and a Clock (Now() time.Time) so time is injectable. Driven adapters: Postgres preferences store, SendGrid email sender, Firebase push sender, the real clock.

Testing quiet hours: table-driven unit tests of ChannelsAt with fixed times and time zones — 21:59 and 07:00 allowed, 22:00 and 06:59 blocked, and a case where 23:00 in Jakarta is 16:00 UTC to prove the user's zone is used. The use case is tested with a fake store, spy senders and a fixed clock. No database, network or waiting for 22:00 needed.

Key takeaways

  • Keep business rules independent of HTTP, databases and vendors; the dependency rule says source dependencies point only inward.
  • Layers: domain (rules), application (use cases), adapters (HTTP, SQL, API clients), frameworks.
  • The application owns ports (interfaces); adapters implement them. Driving adapters call in, driven adapters are called out to.
  • Tests are just another adapter: drive use cases directly with fakes on the driven ports.
  • Validate shape at the edges and business rules in the core; map between transport, storage and domain types at the boundaries.
  • Use it where real business logic and multiple entry points exist; skip the ceremony for thin CRUD and prototypes.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.According to the dependency rule, which import is a violation?

  2. 2.In hexagonal architecture, what is a Midtrans or Stripe payment client that implements the application's PaymentGateway interface?

  3. 3.Where should the rule 'orders over Rp 10,000,000 are rejected as possible fraud' live?

  4. 4.For which project is a full clean architecture most likely overkill?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.