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.
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:
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.
+-------------------------------------------------------------+
| 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) 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 has a one-page summary.
A Go service, laid out this way
Here is the same order placement rebuilt. The layout:
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 appDomain — pure rules, trivially testable:
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:
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:
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:ordb: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:
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.According to the dependency rule, which import is a violation?
2.In hexagonal architecture, what is a Midtrans or Stripe payment client that implements the application's PaymentGateway interface?
3.Where should the rule 'orders over Rp 10,000,000 are rejected as possible fraud' live?
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.
- Dependency inversion principleBusiness logic owns the interfaces it needs and infrastructure implements them, so dependencies point toward policy.
- Test doubles: stubs, spies, mocks and fakesThe five kinds of stand-in for a real dependency, and why outcome-based fakes usually beat interaction mocks.
- Ports and adaptersHexagonal architecture: the application defines ports and the outside world plugs in through adapters.
Finished reading?
Mark it complete to fill in your roadmap.