# 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.

Software Engineer roadmap · Architecture · 19 min read · https://levelcode.org/roadmaps/software-engineer/clean-architecture

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](https://levelcode.org/concepts/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) side | Driven (secondary) side |
| --- | --- | --- |
| Who starts the conversation | The outside world calls the app | The app calls the outside world |
| Port | The use case's API (`PlaceOrder`) | An interface the app needs (`OrderRepository`, `PaymentGateway`) |
| Adapters | HTTP handler, gRPC server, CLI, queue consumer, test | Postgres 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](https://levelcode.org/concepts/ports-and-adapters) 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](https://levelcode.org/roadmaps/software-engineer/modular-design) 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.

**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.

## Quiz

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

   A. The Postgres adapter imports the app package to implement its Orders interface
   B. The HTTP handler imports the app package to call a use case
   C. main imports every adapter
   D. The domain package imports the Postgres driver to load orders

   **Answer: D.** The domain is the innermost layer and must not know about outer layers such as databases. The other imports point inward or are the wiring in main.

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

   A. A driving adapter
   B. A driven (secondary) adapter
   C. A domain entity
   D. A primary port

   **Answer: B.** The application initiates calls to the payment provider through a port it owns; the client implementing that port is a driven adapter.

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

   A. In the domain or use case, so every entry point (HTTP, gRPC, queue consumer) enforces it
   B. In the HTTP handler, next to status codes
   C. In a database trigger
   D. In the frontend form validation only

   **Answer: A.** It is a business rule. Putting it in the core means every adapter gets it and it can be unit-tested without HTTP or a database.

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

   A. A payments service with complex rules and HTTP, gRPC and event entry points
   B. A core ride-matching service expected to live for years
   C. An internal admin tool that only lists and edits rows in three tables
   D. An order service whose payment provider is being replaced

   **Answer: C.** With no real business logic to protect and one entry point, the layers become pure mapping code. A simple handler-plus-queries design is clearer there.
