Skip to content
Software Atlas

Clean / Hexagonal Architecture

Keep the business rules in the middle and plug HTTP, databases and email in from the outside. The dependency rule, ports and adapters, a full checkout refactor in Go and JavaScript, testing the core without a database — and when all of this is overkill.

⏱ ~45 min read14 sections10 interactive widgets · 1 animated diagram · quizGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentencePut your business rules in the middle of the program, depending on nothing. Put the web server, the database and the email service around them, as pluggable parts that depend on the middle — never the other way round.

Every app does two kinds of work. There's domain logic — "orders over $100 get 10% off", "never sell what's out of stock" — the rules that make the product what it is. And there's plumbing: reading HTTP requests, running SQL, sending email. Clean and hexagonal architecture are both ways to keep those two apart, so you can change one without breaking the other.

In code, the kitchen is your business rules. The waiters and delivery apps are ways in — an HTTP handler, a command-line tool, a background job. The suppliers are things your rules use — a database, an email service. Flip the widget to "code" to see the mapping:

The recipe doesn't change
Add ways for orders to come in, or swap the supplier. Count how often the core has to change.
Front: orders come in
🧑‍🍳 Waiter (dine-in) →
Kitchen: recipes
Margherita
dough · tomato · mozzarella
bake 8 min at 250 °C
recipe changes0
Back: supplies come in
← 🥬 Supplier A (fridge)
← 🧾 Receipt printer
A restaurant. The kitchen knows the recipes. Waiters bring orders in; suppliers deliver ingredients from the back. Add a delivery app or change supplier and watch the recipe card.

The one rule

If you remember a single thing from this page, make it this: imports point inward. The code for the business rules never imports the HTTP package, the database driver or the email library. Instead, the rules declare what they need ("something that can save an order"), and the outer code provides it. That's called the dependency rule.

You may have met the same move at a smaller scale as the "D" in SOLID — dependency inversion. This page applies it to a whole app's structure, which is what people mean by architecture.

What this page covers

  1. The problem: a checkout handler that does everything (§02), and the rule that fixes it (§03).
  2. The vocabulary: ports and adapters, driving and driven (§04), and why hexagonal, onion and Clean are one idea drawn three ways (§05).
  3. A full refactor of the checkout in Go and JavaScript — the core (§06), then the adapters and main (§07) — and testing it without a database (§08).
  4. Where things go: validation, transactions, data shapes and errors (§09).
  5. When it's not worth it (§10), and staff-level topics: enforcing the rule automatically and moving a legacy codebase toward it (§11).
  6. Pitfalls (§12), a quiz (§13) and a cheat sheet (§14).
Foundations§02

The tangled checkout

In one sentenceWhen one function does HTTP, SQL, business rules and email, every change touches all four, and nothing can be tested on its own.

Here's the running example for this whole page: a small online shop. A customer sends their email address and a cart — a list of products, each named by its SKU (a short product code like MUG or LAMP), with a quantity. The shop checks stock, works out the price, saves the order and emails a receipt. The pricing rules are simple:

  • 10% off when the items add up to $100 or more.
  • Shipping is $5, but free once the order (after discount) reaches $50.
  • Never sell more than you have in stock.

The first version

This is how most first versions look — and it works. It's one HTTP handler that does everything, top to bottom. (It's trimmed for space; real ones grow to 200 lines.)

main.go — before
var db *sql.DB // opened in main()

func checkout(w http.ResponseWriter, r *http.Request) {
    var req checkoutRequest // {email, items: [{sku, qty}]}
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad json", 400); return
    }
    tx, _ := db.Begin()
    defer tx.Rollback()
    var subtotal int64 // in cents: 1999 means $19.99
    for _, it := range req.Items {
        var price int64
        err := tx.QueryRow("SELECT price_cents FROM products WHERE sku = $1", it.SKU).Scan(&price)
        if err != nil { http.Error(w, "unknown product", 422); return }
        res, _ := tx.Exec("UPDATE products SET stock = stock - $1 WHERE sku = $2 AND stock >= $1", it.Qty, it.SKU)
        if n, _ := res.RowsAffected(); n == 0 { http.Error(w, it.SKU+" out of stock", 409); return }
        subtotal += price * int64(it.Qty)
    }
    discount := int64(0)
    if subtotal >= 10000 { discount = subtotal / 10 }
    shipping := int64(500)
    if subtotal-discount >= 5000 { shipping = 0 }
    total := subtotal - discount + shipping
    id := rand.Text()
    tx.Exec("INSERT INTO orders (id, email, total_cents) VALUES ($1, $2, $3)", id, req.Email, total)
    tx.Commit()
    smtp.SendMail("mail.example.com:587", nil, "shop@example.com", []string{req.Email}, receipt(id, total))
    w.WriteHeader(201)
    json.NewEncoder(w).Encode(map[string]any{"id": id, "total_cents": total})
}

Look at what the function knows about: the JSON shape of the request, HTTP status codes, table and column names, how transactions work, the pricing rules, the mail server's address. Those are four different jobs, and each one changes for a different reason. Color them in:

The tangled checkout
One handler, four jobs. Color one concern at a time, or pick a change request and see what it touches.
Color:
1func checkout(w http.ResponseWriter, r *http.Request) {
2 var req checkoutRequest // {email, items: [{sku, qty}]}
3 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
4 http.Error(w, "bad json", 400); return
5 }
6 tx, _ := db.Begin()
7 defer tx.Rollback()
8 var subtotal int64 // in cents: 1999 means $19.99
9 for _, it := range req.Items {
10 var price int64
11 err := tx.QueryRow("SELECT price_cents FROM products WHERE sku = $1", it.SKU).Scan(&price)
12 if err != nil { http.Error(w, "unknown product", 422); return }
13 res, _ := tx.Exec("UPDATE products SET stock = stock - $1 WHERE sku = $2 AND stock >= $1", it.Qty, it.SKU)
14 if n, _ := res.RowsAffected(); n == 0 { http.Error(w, it.SKU+" out of stock", 409); return }
15 subtotal += price * int64(it.Qty)
16 }
17 discount := int64(0)
18 if subtotal >= 10000 { discount = subtotal / 10 }
19 shipping := int64(500)
20 if subtotal-discount >= 5000 { shipping = 0 }
21 total := subtotal - discount + shipping
22 id := rand.Text()
23 tx.Exec("INSERT INTO orders (id, email, total_cents) VALUES ($1, $2, $3)", id, req.Email, total)
24 tx.Commit()
25 smtp.SendMail("mail.example.com:587", nil, "shop@example.com", []string{req.Email}, receipt(id, total))
26 w.WriteHeader(201)
27 json.NewEncoder(w).Encode(map[string]any{"id": id, "total_cents": total})
28}
Every color is a different reason this function might change. That is the problem in one picture: four reasons to change, one function. Pick a change request below.

Four reasons to change, one function

Each change request in the widget lands on the same function, for a different reason. That's what tight coupling feels like in practice:

  • The rules can't run alone. To test "free shipping from $50" you need a database, a mail server and an HTTP request. So people stop writing tests.
  • No reuse. The call-centre CLI can't call a function that takes an http.ResponseWriter. So the rules get copied, and the copies drift apart.
  • Changes collide. Swapping the database means editing lines inside the pricing loop. Changing the price means scrolling past SQL.
  • Bugs hide in the noise. The commit's error and the insert's error are ignored, and the email is sent while the customer waits. In 30 lines of mixed concerns, nobody notices.
Foundations§03

The dependency rule

In one sentenceDraw your code as rings, business rules in the center. Imports may only point inward. The center imports nothing from the rings around it.

Three rings

Split the checkout into three layers, drawn as rings:

  1. Business rules (center) — prices, discounts, "never sell what's out of stock". We'll put them in a package called domain.
  2. Use cases — the steps of one thing a user does: "place an order" = load products, price them, take stock, save, send a receipt. Package app.
  3. Adapters and frameworks (outside) — the HTTP handler, the Postgres code, the email sender, and main, which plugs them all together. Plus the libraries they use (net/http, database/sql).

Now the rule. Code A has a source-code dependency on code B when A imports B or mentions B's names — so if B changes, A might have to change too. The dependency rule says those arrows may only point toward the center (or stay inside the outer ring). Switch on the tempting shortcuts and watch:

The dependency rule
Each arrow is an import. Switch imports on and off; any arrow pointing outward turns red.
adapters + frameworksuse casesbusiness rulesdomainapphttpapipostgresemailmainnet/httpdatabase/sql
Importsall point inward ✓
All the green arrows point toward the center or stay in the outer ring. The bottom four imports are the tempting shortcuts — switch one on and see why it breaks the rule.

Why "inward" is the right direction

The inner rings are the things you want to keep stable and the things you most want to test. The outer rings are the things that change for reasons outside your business — a new web framework, a different database, an email vendor that doubled its prices.

When arrows point inward, a change in an outer ring can't break an inner one, because the inner one doesn't know it exists. A pricing test can run without a database, because the pricing code never imports one. That's the whole payoff, in two sentences.

"But the core has to save the order somehow"

Here's the puzzle. When a checkout runs, the use case calls the database code. How can the call go outward while the import goes inward?

The answer is an interface owned by the core. Here is the whole trick in three small files:

app/app.go — the core
package app

import "context"

type Order struct {
    ID    string
    Total int64
}

// The port: declared HERE, by the code that uses it.
type Saver interface {
    SaveOrder(ctx context.Context, o Order) error
}

type Checkout struct{ saver Saver }

func NewCheckout(s Saver) *Checkout { return &Checkout{saver: s} }

func (c *Checkout) Place(ctx context.Context, o Order) error {
    // …business rules…
    return c.saver.SaveOrder(ctx, o) // ← the CALL goes out, to whatever was plugged in
}
postgres/store.go — an adapter
package postgres

import (
    "context"
    "database/sql"

    "shop/app" // ← the IMPORT points in: SaveOrder below names app.Order
)

type Store struct{ db *sql.DB }

func New(db *sql.DB) *Store { return &Store{db: db} }

// Having this method is all it takes to be an app.Saver.
func (s *Store) SaveOrder(ctx context.Context, o app.Order) error {
    _, err := s.db.ExecContext(ctx, "INSERT INTO orders (id, total_cents) VALUES ($1, $2)", o.ID, o.Total)
    return err
}
main.go — plugs one into the other
checkout := app.NewCheckout(postgres.New(db)) // ← the only line that knows both

Notice why postgres imports app: not to "implement" anything (Go doesn't need that), but because its SaveOrder method names a core type, app.Order. An adapter whose methods only used types from domain would import only domain. Either way the arrow points inward. Watch both directions on the same three boxes:

Call vs import
The same three packages, seen at runtime and in the source code.
package mainpackage app (core)Checkout.Place(…)type Saver interface {SaveOrder(ctx, Order)}package postgresimport "shop/app"func (s *Store) SaveOrder(ctx, o app.Order) errorcalls (runtime)
At runtime, a checkout runs Checkout.Place, which calls c.saver.SaveOrder(…). The value in that field is a *postgres.Store, so the call travels outward, from the core to the adapter.

So, for the same two packages:

  • Who calls whom at runtime: app → postgres. (Outward. That's fine.)
  • Who imports whom in the source: postgres → app. (Inward. That's the rule.)

That trick — the interface lives with the code that uses it, not the code that implements it — is the dependency inversion principle, and handing the real implementation in from main is dependency injection. Follow the links for more depth; you don't need them for the rest of this page.

The rule in one table

LayerMay importMust not import
domainthe standard library's basics (errors, fmt, time…)app, adapters, net/http, database/sql, vendors' SDKs
appdomain, context, loggingadapters, net/http, database/sql
adaptersapp, domain, frameworks, drivers, SDKseach other (usually)
maineverything—

main is special: it's the one place that knows every concrete piece, and wires them together. That's called the composition root.

Practitioner§04

Ports and adapters

In one sentenceA port is a conversation the core has with the outside world, described in business words. An adapter connects one specific technology to that port.

Hexagonal architecture — Alistair Cockburn's name for this idea, also called ports and adapters — gives you the vocabulary for §03's rings. Think of the ports on a laptop: the laptop defines a USB-C port; a charger, a monitor and a keyboard each plug in with their own cable. The laptop doesn't change when you buy a new monitor.

Ports

A port belongs to the core and is phrased in the core's language. For the shop there are three:

  • PlaceOrder(email, items) — how the outside world asks the core to do something.
  • Store — how the core reads products and saves orders. It says SaveOrder(order), not INSERT INTO.
  • Mailer — how the core sends a receipt. It says SendReceipt(order), not "SMTP".

Adapters

An adapter is the code that translates between one technology and one port. The HTTP handler turns a JSON request into a PlaceOrder call and the result back into JSON. The Postgres store turns SaveOrder(order) into SQL. A fake store in a test turns it into "append to a slice".

Two sides: driving and driven

Ports come in two kinds, and the difference matters for who owns what:

  • The driving side (also called primary) starts work: an HTTP request, a CLI command, a message from a queue (read by a consumer), a test. Its adapters call into the core.
  • The driven side (also called secondary) is what the core uses to get work done: the database, email, a payment API. The core calls them through an interface it owns; the adapters implement that interface.
Ports and adapters
coredomain + apppricing, stock rules,“place an order”PlaceOrderStoreMailerDRIVING (primary)DRIVEN (secondary)HTTP handlerPOST /checkoutCLI commandshop place-ordertestcalls PlaceOrderpostgres.StoreSQL, transactionsmemStorefake, for testsemail.MailerSMTP or an APIfakeMailerrecords, for testscalls inimplements
The core (violet) owns the ports — the small labelled slots on its edge. Left: driving adapters that call in. Right: driven adapters that the core calls out to, through interfaces it owns. Every solid arrow means “depends on the core”, and every one points at the hexagon: driving adapters import the core to call it; driven adapters import the core types their methods mention (app.Tx, domain.Order) and implement its interfaces just by having the right methods. The moving dot is one request at runtime. On a phone, scroll the drawing sideways.

Notice that on both sides, the adapters depend on the core. Driving adapters import the core so they can call it. Driven adapters import the core types their methods mention — the Postgres store's methods take app.Tx and domain.Order, so it imports app and domain; the email adapter only mentions domain.Order, so it imports only domain. (In Go, implementing an interface needs no import at all, as §03 showed.) The core imports neither.

What a driving port looks like in code

The driving port is the core's public API — in our shop, the PlaceOrder method of a use case type called app.Checkout. In Go it doesn't have to be a declared interface: a plain method on a struct is a fine port. (The HTTP adapter can still declare a one-method interface for its own tests, as §07 shows.) The driven ports are interfaces, because the core needs to accept different implementations — Postgres in production, a fake in tests.

One request, step by step

Here's the PlaceOrder method we'll build in §06, slightly condensed. Step through a checkout for a $120 lamp and two $12 mugs, then the same request when the lamp has sold out:

One checkout through the hexagon
Follow a request from the HTTP adapter, through the core, out to Postgres and email, and back.
Code
1func (c *Checkout) PlaceOrder(ctx context.Context, email string, items []Item) (domain.Order, error) {
2 var order domain.Order
3 err := c.store.InTx(ctx, func(tx Tx) error {
4 products, err := tx.Products(ctx, skusOf(items))
5 if err != nil { return err }
6 lines, err := linesFor(items, products)
7 if err != nil { return err }
8 order, err = domain.NewOrder(c.newID(), email, lines)
9 if err != nil { return err }
10 for _, l := range order.Lines {
11 if err := tx.TakeStock(ctx, l.SKU, l.Qty); err != nil { return err }
12 }
13 return tx.SaveOrder(ctx, order)
14 })
15 if err != nil { return domain.Order{}, err }
16 if err := c.mail.SendReceipt(ctx, order); err != nil {
17 c.log.Warn("receipt email failed", "order", order.ID, "err", err)
18 }
19 return order, nil
20}
Driving sideHTTP adapter
POST /checkout
{email, items: LAMP×1, MUG×2}
Coredomain + app
idle
Driven sidePostgres, email
—
A request arrives. The HTTP adapter (driving side) decodes the JSON into a plain struct shaped like the request body (a DTO, more in §07). Nothing in the core has run yet.
1 / 9
Practitioner§05

Hexagonal, onion, Clean: one idea

In one sentenceHexagonal, onion and Clean architecture are the same rule drawn three ways: the business core in the middle, depending on nothing; everything else outside, depending on it.

You'll hear all three names in code reviews and job interviews, sometimes as if they were rivals. They aren't. Each one was drawn by a different person, at a different time, stressing a different part of the same idea:

Hexagonal (ports & adapters)OnionClean
By, whenAlistair Cockburn, 2005Jeffrey Palermo, 2008Robert C. Martin, 2012 (book 2017)
Picturean inside and an outside, ports on the edgeconcentric ringsconcentric rings with named layers
Center"the application"the domain modelentities
Stressessymmetry: a test and a web UI are both just adaptersthe domain model at the very centerthe dependency rule, by name

Onion architecture and Clean split the inside into more rings; hexagonal keeps just inside and outside. Try placing our packages in each drawing:

Same idea, three drawings
Pick a drawing, then click one of our packages to see which ring it lives in.
the applicationports (the edge)adapters (the outside)
Our packages
In Hexagonal (Cockburn, 2005), app lives in the application.
Only two zones: inside and outside, with ports on the boundary. The six sides mean nothing — Alistair Cockburn just wanted room to draw several ports. Its big idea is the symmetry: a test and a web UI are both just driving adapters.

What they all react against: the classic layered stack

Before these, the usual picture was a stack: presentation → business → data access → database, each layer calling the one below. It looks tidy, but the business layer imports the data-access layer. So the business rules depend on the database code, and you can't test them — or change databases — without it.

✕ Classic layers: business imports data access
// package orders (business)
import "shop/dal" // ← rules depend on SQL code

func Place(o Order) error {
    if len(o.Items) == 0 { return ErrEmpty }
    return dal.InsertOrder(o)
}
✓ Clean/hexagonal: data access imports business
// package app (business) — no SQL import
type Store interface{ SaveOrder(…) error }

// package postgres (data access)
import "shop/app" // ← SQL code depends on rules
func (s *Store) SaveOrder(…) error { … }

That's the single change the three drawings share: flip the arrow between the business code and the data code. Everything else is naming.

Practitioner§06

Refactor, part 1: the core

In one sentenceThe core is two packages: domain holds the rules as plain functions and types, and app holds the steps of each use case plus the interfaces (ports) it needs.

Time to rebuild §02's checkout. Here's the layout we're aiming for, in Go and in JavaScript. Every file shown in this section and the next was compiled and run (Go 1.24, Node 22, Postgres 16); §07 also has the small files you need to run it yourself.

shop/                               shop/
  domain/order.go                     src/domain/order.js
  app/checkout.go                     src/app/checkout.js
  adapters/httpapi/handler.go         src/adapters/http.js
  adapters/postgres/store.go          src/adapters/postgres.js
  adapters/email/log.go               src/adapters/log-mailer.js
  cmd/shop/main.go                    src/main.js

The HTTP adapter's Go package is called httpapi, not http, so it doesn't clash with the standard library's net/http, which it imports.

The domain: rules and nothing else

The domain package holds the types and rules of the shop. It imports only errors, fmt and strings. No HTTP, no SQL, no JSON tags.

domain/order.go
package domain

import (
    "errors"
    "fmt"
    "strings"
)

// Money is an amount in cents, so 1999 means $19.99. Integers avoid float rounding bugs.
type Money int64

func (m Money) String() string { return fmt.Sprintf("$%d.%02d", m/100, m%100) }

type Product struct {
    SKU   string
    Name  string
    Price Money
}

type Line struct {
    SKU       string
    Qty       int
    UnitPrice Money
}

type Order struct {
    ID                                  string
    Email                               string
    Lines                               []Line
    Subtotal, Discount, Shipping, Total Money
}

// Business errors. Outer layers check for them with errors.Is.
var (
    ErrEmptyCart      = errors.New("cart is empty")
    ErrBadQuantity    = errors.New("quantity must be between 1 and 99")
    ErrBadEmail       = errors.New("email address is not valid")
    ErrUnknownProduct = errors.New("unknown product")
    ErrOutOfStock     = errors.New("out of stock")
)

// NewOrder is the only way to build an Order, so every Order obeys the rules.
func NewOrder(id, email string, lines []Line) (Order, error) {
    if !strings.Contains(email, "@") {
        return Order{}, ErrBadEmail
    }
    if len(lines) == 0 {
        return Order{}, ErrEmptyCart
    }
    var sub Money
    for _, l := range lines {
        if l.Qty < 1 || l.Qty > 99 {
            return Order{}, fmt.Errorf("%s: %w", l.SKU, ErrBadQuantity)
        }
        sub += l.UnitPrice * Money(l.Qty)
    }
    o := Order{ID: id, Email: email, Lines: lines, Subtotal: sub}
    if sub >= 100_00 { // 10% off orders of $100 or more
        o.Discount = sub / 10
    }
    if sub-o.Discount < 50_00 { // shipping is free from $50
        o.Shipping = 5_00
    }
    o.Total = sub - o.Discount + o.Shipping
    return o, nil
}

The rules in NewOrder are exactly §02's rules — but now they're a function you can call with three lines and no database. That's an invariant kept in one place: if the only way to build an Order is NewOrder, every order in the system obeys the rules.

The use case: steps and ports

The app package describes one thing a user can do, step by step, and declares the interfaces (driven ports) it needs. It imports domain and nothing from the adapters.

app/checkout.go
package app

import (
    "context"
    "fmt"
    "log/slog"

    "shop/domain"
)

// ---- Driven ports: what the use case needs from the outside world. ----

// Store runs a piece of work inside one transaction.
type Store interface {
    InTx(ctx context.Context, fn func(tx Tx) error) error
}

// Tx is everything checkout reads and writes, bound to one transaction.
type Tx interface {
    Products(ctx context.Context, skus []string) (map[string]domain.Product, error)
    TakeStock(ctx context.Context, sku string, qty int) error // domain.ErrOutOfStock if too few
    SaveOrder(ctx context.Context, o domain.Order) error
}

type Mailer interface {
    SendReceipt(ctx context.Context, o domain.Order) error
}

// ---- The use case. ----

type Item struct {
    SKU string
    Qty int
}

type Checkout struct {
    store Store
    mail  Mailer
    newID func() string
    log   *slog.Logger
}

func NewCheckout(store Store, mail Mailer, newID func() string, log *slog.Logger) *Checkout {
    return &Checkout{store: store, mail: mail, newID: newID, log: log}
}

func (c *Checkout) PlaceOrder(ctx context.Context, email string, items []Item) (domain.Order, error) {
    var order domain.Order
    err := c.store.InTx(ctx, func(tx Tx) error {
        products, err := tx.Products(ctx, skusOf(items))
        if err != nil {
            return err
        }
        lines, err := linesFor(items, products)
        if err != nil {
            return err
        }
        order, err = domain.NewOrder(c.newID(), email, lines) // ← the rules live in domain
        if err != nil {
            return err
        }
        for _, l := range order.Lines {
            if err := tx.TakeStock(ctx, l.SKU, l.Qty); err != nil {
                return err
            }
        }
        return tx.SaveOrder(ctx, order)
    })
    if err != nil {
        return domain.Order{}, err
    }
    // After the commit: a failed email must not undo a paid order.
    if err := c.mail.SendReceipt(ctx, order); err != nil {
        c.log.Warn("receipt email failed", "order", order.ID, "err", err)
    }
    return order, nil
}

func skusOf(items []Item) []string {
    skus := make([]string, len(items))
    for i, it := range items {
        skus[i] = it.SKU
    }
    return skus
}

// linesFor prices each item from the catalog. An unknown SKU is a business error.
func linesFor(items []Item, products map[string]domain.Product) ([]domain.Line, error) {
    lines := make([]domain.Line, len(items))
    for i, it := range items {
        p, ok := products[it.SKU]
        if !ok {
            return nil, fmt.Errorf("%s: %w", it.SKU, domain.ErrUnknownProduct)
        }
        lines[i] = domain.Line{SKU: it.SKU, Qty: it.Qty, UnitPrice: p.Price}
    }
    return lines, nil
}

Read it as a checklist

Look at what app contains and what it doesn't:

  • It owns its ports. Store, Tx and Mailer are declared here, by the code that uses them — so app never needs to import an adapter. (The Postgres adapter will import app, because its InTx method mentions app.Tx; never the reverse.) In Go they match by method set alone (implicit interfaces), so no implements keyword appears anywhere.
  • The ports speak business. TakeStock(sku, qty), not Exec("UPDATE …"). A port that mirrored SQL would just be the database's API with an interface in front.
  • It decides what happens together, not how. "Load, price, take stock and save as one unit" is a business decision, so the use case asks for InTx. BEGIN/COMMIT is the adapter's problem (§09 has more on this).
  • It orders the side effects. The receipt goes out after the commit, so a rollback can never leave a customer with an email for an order that doesn't exist.
  • Everything it needs comes in through NewCheckout. That's constructor injection, covered in depth in Dependency Injection.
Practitioner§07

Refactor, part 2: adapters and main

In one sentenceAdapters are translators. Each one knows one technology and the core's language, and converts between them — in both directions.

§06 built the core. Now the outside: one driving adapter (HTTP), two driven adapters (Postgres and email), and main to plug them in.

The HTTP adapter: JSON in, status codes out

adapters/httpapi/handler.go
package httpapi

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

    "shop/app"
    "shop/domain"
)

// What this adapter needs from the core. *app.Checkout fits.
type Checkouter interface {
    PlaceOrder(ctx context.Context, email string, items []app.Item) (domain.Order, error)
}

// DTOs: the JSON shapes of this HTTP API. They are not the domain types.
type checkoutRequest struct {
    Email string `json:"email"`
    Items []struct {
        SKU string `json:"sku"`
        Qty *int   `json:"qty"` // a pointer, so "missing" (nil) differs from 0
    } `json:"items"`
}

// formatError checks the request's shape, not business rules. Wrong shape → 400.
func (req checkoutRequest) formatError() string {
    if req.Email == "" {
        return "email is required"
    }
    if req.Items == nil {
        return "items must be an array"
    }
    for _, it := range req.Items {
        if it.SKU == "" || it.Qty == nil {
            return "every item needs a sku and a qty"
        }
    }
    return ""
}

type orderResponse struct {
    ID    string `json:"id"`
    Total string `json:"total"`
}

func Routes(c Checkouter) http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /checkout", func(w http.ResponseWriter, r *http.Request) {
        var req checkoutRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
            return
        }
        if msg := req.formatError(); msg != "" {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
            return
        }
        items := make([]app.Item, len(req.Items)) // DTO → the core's input type
        for i, it := range req.Items {
            items[i] = app.Item{SKU: it.SKU, Qty: *it.Qty}
        }
        order, err := c.PlaceOrder(r.Context(), req.Email, items)
        if err != nil {
            status := statusFor(err)
            msg := err.Error()
            if status == http.StatusInternalServerError {
                msg = "internal error" // never leak SQL errors to clients
            }
            writeJSON(w, status, map[string]string{"error": msg})
            return
        }
        writeJSON(w, http.StatusCreated, orderResponse{ID: order.ID, Total: order.Total.String()})
    })
    return mux
}

// statusFor translates business errors into HTTP. Only this adapter knows HTTP exists.
func statusFor(err error) int {
    switch {
    case errors.Is(err, domain.ErrEmptyCart),
        errors.Is(err, domain.ErrBadQuantity),
        errors.Is(err, domain.ErrBadEmail),
        errors.Is(err, domain.ErrUnknownProduct):
        return http.StatusUnprocessableEntity // 422
    case errors.Is(err, domain.ErrOutOfStock):
        return http.StatusConflict // 409
    default:
        return http.StatusInternalServerError // 500
    }
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

Notice the two kinds of "bad request". formatError asks whether the body has the right shape — an email string, an array of items, each with a SKU and a whole-number quantity. Getting that wrong is the client's mistake in using the API, so it's a 400, decided right here. Whether the quantity is between 1 and 99, or the SKU exists, is a business rule: the adapter passes it to the core, and the core's error becomes a 422. (§09 comes back to this split.)

The request and response structs are DTOs: they describe the HTTP API's shapes, not the domain's. That's deliberate — §09 explains why the JSON and domain.Order should be free to differ. The error mapping is the other half of the adapter's job: the core says what went wrong in business terms (ErrOutOfStock); the adapter decides what that means in HTTP (HTTP status code 409).

The Go adapter depends on a one-method interface it declares itself, Checkouter, rather than on *app.Checkout directly. That's optional; it lets the handler's own tests pass a stub (§08). The driving port is still PlaceOrder — the interface just names it.

The Postgres adapter: SQL and transactions

adapters/postgres/store.go
package postgres

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

    "shop/app"
    "shop/domain"
)

type Store struct{ db *sql.DB }

func New(db *sql.DB) *Store { return &Store{db: db} }

// InTx implements app.Store: BEGIN, run fn, then COMMIT — or ROLLBACK if fn failed.
func (s *Store) InTx(ctx context.Context, fn func(app.Tx) error) error {
    sqlTx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer sqlTx.Rollback() // does nothing after a successful Commit
    if err := fn(&tx{sqlTx}); err != nil {
        return err
    }
    return sqlTx.Commit()
}

type tx struct{ q *sql.Tx }

func (t *tx) Products(ctx context.Context, skus []string) (map[string]domain.Product, error) {
    rows, err := t.q.QueryContext(ctx,
        `SELECT sku, name, price_cents FROM products WHERE sku = ANY($1)`, skus)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    out := map[string]domain.Product{}
    for rows.Next() {
        var p domain.Product
        if err := rows.Scan(&p.SKU, &p.Name, &p.Price); err != nil {
            return nil, err
        }
        out[p.SKU] = p
    }
    return out, rows.Err()
}

func (t *tx) TakeStock(ctx context.Context, sku string, qty int) error {
    res, err := t.q.ExecContext(ctx,
        `UPDATE products SET stock = stock - $1 WHERE sku = $2 AND stock >= $1`, qty, sku)
    if err != nil {
        return err
    }
    if n, _ := res.RowsAffected(); n == 0 {
        return fmt.Errorf("%s: %w", sku, domain.ErrOutOfStock) // SQL result → business error
    }
    return nil
}

func (t *tx) SaveOrder(ctx context.Context, o domain.Order) error {
    _, err := t.q.ExecContext(ctx,
        `INSERT INTO orders (id, email, total_cents) VALUES ($1, $2, $3)`, o.ID, o.Email, o.Total)
    if err != nil {
        return err
    }
    for _, l := range o.Lines {
        _, err := t.q.ExecContext(ctx,
            `INSERT INTO order_lines (order_id, sku, qty, unit_price_cents) VALUES ($1, $2, $3, $4)`,
            o.ID, l.SKU, l.Qty, l.UnitPrice)
        if err != nil {
            return err
        }
    }
    return nil
}

The adapter's job is translation in both directions: domain types become rows (SaveOrder), rows become domain types (Products), and a database fact — "the UPDATE matched zero rows" — becomes a business error the core understands (ErrOutOfStock). The core never sees sql.ErrNoRows, a column name or a rowCount.

The email adapter is the same idea, smaller: a type with a SendReceipt(ctx, order) method. For this demo it just logs; a real one would call SMTP or an email API — and app wouldn't change. Its code, and the other files you need to run the example, are below.

Show the email adapter, go.mod and the SQL schema
adapters/email/log.go
package email

import (
    "context"
    "log/slog"

    "shop/domain"
)

// LogMailer "sends" receipts by logging them. A real SMTP adapter would have the same method.
type LogMailer struct{ Log *slog.Logger }

func (m LogMailer) SendReceipt(ctx context.Context, o domain.Order) error {
    m.Log.Info("receipt sent", "to", o.Email, "order", o.ID, "total", o.Total.String())
    return nil
}

The email adapter imports only domain — its one method mentions domain.Order and no app type — yet it still satisfies app.Mailer.

go.mod
module shop

go 1.24

require github.com/jackc/pgx/v5 v5.7.5

(go mod tidy adds pgx's own dependencies. For Node: "type": "module" in package.json and npm install pg.)

schema.sql
CREATE TABLE products (
  sku         text PRIMARY KEY,
  name        text NOT NULL,
  price_cents bigint NOT NULL,
  stock       int NOT NULL CHECK (stock >= 0)
);
CREATE TABLE orders (
  id          text PRIMARY KEY,
  email       text NOT NULL,
  total_cents bigint NOT NULL
);
CREATE TABLE order_lines (
  order_id         text REFERENCES orders(id),
  sku              text REFERENCES products(sku),
  qty              int NOT NULL,
  unit_price_cents bigint NOT NULL
);
INSERT INTO products VALUES ('MUG', 'Mug', 1200, 10), ('LAMP', 'Lamp', 12000, 1);

main: the one place that knows everything

cmd/shop/main.go
package main

import (
    "crypto/rand"
    "database/sql"
    "log/slog"
    "net/http"
    "os"

    _ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver

    "shop/adapters/email"
    "shop/adapters/httpapi"
    "shop/adapters/postgres"
    "shop/app"
)

func main() {
    log := slog.New(slog.NewTextHandler(os.Stderr, nil))

    db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Error("open db", "err", err)
        os.Exit(1)
    }

    // The composition root: the only place that knows every concrete piece.
    checkout := app.NewCheckout(postgres.New(db), email.LogMailer{Log: log}, rand.Text, log)
    addr := os.Getenv("ADDR") // e.g. ":8080"
    log.Info("listening", "addr", addr)
    if err := http.ListenAndServe(addr, httpapi.Routes(checkout)); err != nil {
        log.Error("server stopped", "err", err)
        os.Exit(1)
    }
}

main is the composition root: it builds the real pieces and plugs them in. rand.Text (Go 1.24+) and randomUUID are passed as functions to fill the newID slot. Swapping Postgres for anything else changes one line here, plus a new adapter file — and nothing in domain or app.

Run it

With the three tables created (products, orders, order_lines) and a lamp ($120, 1 in stock) and mugs ($12, 10 in stock) loaded, start the Go server and place three orders:

$ ADDR=:8080 DATABASE_URL=postgres://… go run ./cmd/shop &
$ curl -i -X POST localhost:8080/checkout \
    -d '{"email":"ana@example.com","items":[{"sku":"LAMP","qty":1},{"sku":"MUG","qty":2}]}'
HTTP/1.1 201 Created
Content-Type: application/json

{"id":"TJMIUFSMTBEYEWZUSVGMJSBW5N","total":"$129.60"}

$ curl -i -X POST localhost:8080/checkout -d '{"email":"bo@example.com","items":[{"sku":"LAMP","qty":1}]}'
HTTP/1.1 409 Conflict
Content-Type: application/json

{"error":"LAMP: out of stock"}

$ curl -i -X POST localhost:8080/checkout -d '{"email":"bo@example.com","items":[]}'
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{"error":"cart is empty"}

Now the bad inputs. Here is every case run against both servers — Go on port 8080, Node on 8081 — condensed into one table (status code, then the error text from the JSON body):

$ curl -s -w ' %{http_code}\n' -X POST localhost:8080/checkout -d '<body>'    # Go
$ curl -s -w ' %{http_code}\n' -X POST localhost:8081/checkout -d '<body>'    # Node

body                                                    Go                                         Node
{nope                                                   400 invalid JSON                           400 invalid JSON
null                                                    400 email is required                      400 email is required
{"items":[{"sku":"MUG","qty":1}]}                       400 email is required                      400 email is required
{"email":"bo@example.com","items":{"sku":"MUG"}}        400 invalid JSON                           400 items must be an array
{"email":"bo@example.com","items":[null]}               400 every item needs a sku and a qty       400 every item needs a sku and a qty
{"email":"bo@example.com","items":[{"sku":"MUG","qty":"two"}]}
                                                        400 invalid JSON                           400 every item needs a sku and a qty
{"email":"bo@example.com","items":[{"sku":"MUG","qty":0}]}
                                                        422 MUG: quantity must be between 1 and 99 422 (same)
{"email":"bo","items":[{"sku":"MUG","qty":1}]}          422 email address is not valid             422 (same)
{"email":"bo@example.com","items":[{"sku":"TOASTER","qty":1}]}
                                                        422 TOASTER: unknown product               422 (same)

Every status code matches between the two servers; a few 400 messages differ in wording, because Go's JSON decoder rejects wrong types itself ("qty":"two" can't go into an int), while the Node adapter checks them by hand. Shape problems → 400 from the adapter; rule violations → 422 from the core.

The server's log shows the email adapter at work (one line per successful order):

time=2026-10-30T15:35:54.726Z level=INFO msg="receipt sent" to=ana@example.com order=TJMIUFSMTBEYEWZUSVGMJSBW5N total=$129.60

The Node server answers the three good-path requests above with the same status codes and bodies (its IDs are UUIDs). $120 + 2 × $12 = $144; 10% off = $129.60; that's over $50, so shipping is free.

Who knows what

Here's the finished structure, with each package's imports exactly as go list reports them. Notice the arrows: every shop/… import points toward domain.

Who knows what
The refactored shop, package by package. Imports come from running go list on the real code.
shop/
app/checkout.gocore · use cases
imports: contextfmtlog/slogshop/domain
imported by: adapters/httpapiadapters/postgrescmd/shop
knows about:the steps of checkoutwhat it needs (ports)
knows nothing about:HTTPSQLwhich email service
changes when the steps of a use case change (e.g. “reserve stock before payment”).
Practitioner§08

Testing the core without a database

In one sentenceBecause the core only talks to interfaces, a test can plug in in-memory fakes and run the real business logic with no database, no network and no waiting.

This is the payoff you were promised in §02. Remember: to test "free shipping from $50" in the tangled handler, you needed Postgres, a mail server and an HTTP request. Now there are two easier layers to test, and neither needs any of that.

Level 1: the domain is just functions

domain.NewOrder takes values and returns values. Testing it needs nothing at all — a table of cases is enough:

domain/order_test.go (excerpt)
func TestNewOrderPricing(t *testing.T) {
    tests := []struct {
        name                      string
        lines                     []Line
        discount, shipping, total Money
    }{
        {"small order pays shipping", []Line{{"MUG", 2, 12_00}}, 0, 5_00, 29_00},
        {"free shipping from $50", []Line{{"MUG", 5, 12_00}}, 0, 0, 60_00},
        {"10% off from $100", []Line{{"LAMP", 1, 120_00}}, 12_00, 0, 108_00},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            o, err := NewOrder("o1", "ana@example.com", tt.lines)
            if err != nil {
                t.Fatal(err)
            }
            if o.Discount != tt.discount || o.Shipping != tt.shipping || o.Total != tt.total {
                t.Errorf("got discount=%v shipping=%v total=%v", o.Discount, o.Shipping, o.Total)
            }
        })
    }
}

Level 2: the use case, with fakes

The use case needs a Store and a Mailer. In the test, we plug in fakes: simple, working implementations that keep data in memory. The fake store even pretends to have transactions — it works on a copy and only keeps the copy if the function succeeded.

app/checkout_test.go — the fakes
package app_test

// memStore is a fake: a real, working store that keeps everything in maps.
type memStore struct {
    products map[string]domain.Product
    stock    map[string]int
    orders   []domain.Order
}

func (m *memStore) InTx(ctx context.Context, fn func(app.Tx) error) error {
    tx := &memTx{m: m, stock: map[string]int{}}
    for k, v := range m.stock { // work on a copy…
        tx.stock[k] = v
    }
    if err := fn(tx); err != nil {
        return err // …and throw it away on error: that's our "rollback"
    }
    m.stock, m.orders = tx.stock, append(m.orders, tx.saved...) // "commit"
    return nil
}

type memTx struct {
    m     *memStore
    stock map[string]int
    saved []domain.Order
}

func (t *memTx) Products(_ context.Context, skus []string) (map[string]domain.Product, error) {
    out := map[string]domain.Product{}
    for _, s := range skus {
        if p, ok := t.m.products[s]; ok {
            out[s] = p
        }
    }
    return out, nil
}

func (t *memTx) TakeStock(_ context.Context, sku string, qty int) error {
    if t.stock[sku] < qty {
        return domain.ErrOutOfStock
    }
    t.stock[sku] -= qty
    return nil
}

func (t *memTx) SaveOrder(_ context.Context, o domain.Order) error {
    t.saved = append(t.saved, o)
    return nil
}

type fakeMailer struct{ sent []string }

func (f *fakeMailer) SendReceipt(_ context.Context, o domain.Order) error {
    f.sent = append(f.sent, o.Email)
    return nil
}

Now the tests themselves. They set up a shop with 10 mugs and 1 lamp, call PlaceOrder, and check the results — including the case where the second item fails and nothing should have changed:

app/checkout_test.go — the tests
func newShop() (*memStore, *fakeMailer, *app.Checkout) {
    store := &memStore{
        products: map[string]domain.Product{
            "MUG":  {SKU: "MUG", Name: "Mug", Price: 12_00},
            "LAMP": {SKU: "LAMP", Name: "Lamp", Price: 120_00},
        },
        stock: map[string]int{"MUG": 10, "LAMP": 1},
    }
    mail := &fakeMailer{}
    ids := func() string { return "order-1" }
    return store, mail, app.NewCheckout(store, mail, ids, slog.New(slog.DiscardHandler))
}

func TestCheckoutPlacesOrder(t *testing.T) {
    store, mail, checkout := newShop()

    o, err := checkout.PlaceOrder(context.Background(), "ana@example.com",
        []app.Item{{SKU: "LAMP", Qty: 1}, {SKU: "MUG", Qty: 2}})

    if err != nil {
        t.Fatal(err)
    }
    if o.Total != 129_60 { // $144 - 10% = $129.60, free shipping
        t.Errorf("total = %v, want $129.60", o.Total)
    }
    if store.stock["LAMP"] != 0 || store.stock["MUG"] != 8 {
        t.Errorf("stock = %v", store.stock)
    }
    if len(mail.sent) != 1 {
        t.Errorf("sent %d receipts, want 1", len(mail.sent))
    }
}

func TestCheckoutOutOfStockChangesNothing(t *testing.T) {
    store, mail, checkout := newShop()

    _, err := checkout.PlaceOrder(context.Background(), "ana@example.com",
        []app.Item{{SKU: "MUG", Qty: 2}, {SKU: "LAMP", Qty: 2}}) // only 1 lamp left

    if !errors.Is(err, domain.ErrOutOfStock) {
        t.Fatalf("err = %v, want ErrOutOfStock", err)
    }
    if store.stock["MUG"] != 10 || len(store.orders) != 0 || len(mail.sent) != 0 {
        t.Errorf("a failed checkout changed state: stock=%v orders=%d mails=%d",
            store.stock, len(store.orders), len(mail.sent))
    }
}

What it looks like when you run it

$ go test -v ./app
=== RUN   TestCheckoutPlacesOrder
--- PASS: TestCheckoutPlacesOrder (0.00s)
=== RUN   TestCheckoutOutOfStockChangesNothing
--- PASS: TestCheckoutOutOfStockChangesNothing (0.00s)
PASS
ok      shop/app        0.002s

$ go test ./domain
ok      shop/domain     0.002s

$ node --test --test-reporter=spec
✔ places an order: 10% off, free shipping, stock taken, receipt sent (1.306718ms)
✔ out of stock changes nothing (0.499254ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
ℹ duration_ms 56.898356

Two milliseconds for the Go package (most of the Node run's 57 ms is starting the test runner). No Docker, no DATABASE_URL, nothing to clean up between tests. How much does that matter? Pick a suite size:

How long does the test suite take?
The same checkout test, run N times, with the core plugged into fakes vs a real database.
Tests:
core + fakes
core + local Postgres
+ start a Postgres container
Per checkout, measured on this site's build machine: Go with fakes ≈ 2.2 µs, with a local Postgres 16 ≈ 0.76 ms. The third lane adds a typical container start-up (an estimate). Press run.

Fakes can lie: keep them honest

A fake is only useful if it behaves like the real thing. What if the fake store forgets to roll back, but Postgres doesn't? Your use case tests would pass, and production would behave differently.

The fix is a contract test: one set of tests that runs against every implementation of a port. Write it once, in a shared package, and call it from the fake's tests and the Postgres adapter's tests:

storetest/contract.go (excerpt)
// Run needs a fresh store holding 10 "MUG"s at $12.00.
func Run(t *testing.T, newStore func() app.Store) {
    ctx := context.Background()

    t.Run("taking too much stock is ErrOutOfStock", func(t *testing.T) {
        err := newStore().InTx(ctx, func(tx app.Tx) error { return tx.TakeStock(ctx, "MUG", 11) })
        if !errors.Is(err, domain.ErrOutOfStock) {
            t.Fatalf("err = %v, want ErrOutOfStock", err)
        }
    })

    t.Run("an error rolls everything back", func(t *testing.T) {
        s := newStore()
        boom := errors.New("boom")
        err := s.InTx(ctx, func(tx app.Tx) error {
            if err := tx.TakeStock(ctx, "MUG", 10); err != nil {
                return err
            }
            return boom // ← fail after taking all the stock
        })
        if !errors.Is(err, boom) {
            t.Fatalf("InTx returned %v, want boom", err)
        }
        err = s.InTx(ctx, func(tx app.Tx) error { return tx.TakeStock(ctx, "MUG", 10) })
        if err != nil {
            t.Fatalf("stock was not restored: %v", err)
        }
    })
}
$ TEST_DATABASE_URL=postgres://… go test -v -run TestPostgresStore ./adapters/postgres
--- PASS: TestPostgresStore (0.01s)
    --- PASS: TestPostgresStore/taking_too_much_stock_is_ErrOutOfStock (0.01s)
    --- PASS: TestPostgresStore/an_error_rolls_everything_back (0.00s)
ok      shop/adapters/postgres  0.019s

The same storetest.Run passes against memStore too. Now the fake and the real store are held to the same promises.

The shape of the test suite

WhatPlugs inHow manySpeed
Domain rulesnothinglots — every rule and edge casemicroseconds
Use casesfakesone per path through each use casemicroseconds
Driven adapters (Postgres, email)the real thing: a test database, a sandbox accounta few per adapter, plus the contract testsmilliseconds each, plus setup
Driving adapters (HTTP)a stub core that returns a fixed answer or errorone per status code you mapfast: Go's net/http/httptest runs the handler in memory, no real server
End-to-endeverything reala handful of smoke testsseconds

Most of your tests live in the top two rows, so the suite stays fast as the app grows. The real-database tests are still essential — they're the only ones that catch a typo in an SQL string — but you need far fewer of them. These are the integration tests.

Practitioner§09

Where things go

In one sentenceAsk one question of every line: would it change if we swapped the database or the web framework? If yes, it belongs in an adapter. If it changes only when the business changes, it belongs in the core.

The diagram says "rules in the middle, plumbing outside". Real code is messier. Here are the four things teams argue about most, with the answer this page's shop uses and why.

1. Validation: two kinds, two places

"Validate the input" is really two different jobs:

  • Is this well-formed? Is the body valid JSON? Is qty a number, not "two"? Is the email field present? These are about the format of one way in, so they belong in the adapter. The HTTP adapter answers them with 400; a CLI adapter would print usage help.
  • Is this allowed? Quantity between 1 and 99, at least one item, an email that looks like an address, enough stock. These are business rules. They belong in the domain, so that every way in — HTTP, CLI, a queue consumer — obeys them, and you test them once.

A useful test: if a second adapter would need the same check, it's a business rule and it moves inward.

2. Transactions: the core decides what, the adapter decides how

Checkout touches two tables (stock and orders), and they must change together. Who owns the transaction?

  • BEGIN/COMMIT is SQL, so the mechanics belong to the Postgres adapter.
  • But which steps must be all-or-nothing is a business decision: "taking stock and saving the order are one step". So the use case must be able to ask for it.

The shop's answer is the Store.InTx(fn) port from §06: the use case says "run these steps together"; the adapter wraps them in a transaction. That's a small unit of work. It's also why the Tx port bundles all three operations — products, stock, orders — instead of separate repositories that each open their own connection.

✕ Two repositories, two transactions
// Each call runs on its own; if SaveOrder
// fails, the stock is already gone.
err := c.stock.Take(ctx, "LAMP", 1)
// …
err = c.orders.Save(ctx, order)
✓ One unit of work
// Both inside one transaction: both
// happen, or neither does.
err := c.store.InTx(ctx, func(tx Tx) error {
    if err := tx.TakeStock(ctx, "LAMP", 1); err != nil {
        return err
    }
    return tx.SaveOrder(ctx, order)
})

Two traps to avoid:

  • Don't pass *sql.Tx into the core. A Tx parameter of type *sql.Tx puts database/sql in the use case's imports and breaks the rule. The core's Tx is an interface; only the adapter knows it's backed by *sql.Tx.
  • Don't do slow or irreversible things inside the transaction. An email can't be rolled back, and an HTTP call to a payment provider holds the transaction (and its row locks) open while you wait. Send after commit, as the shop does — or, if the message must never be lost, write it to an outbox table inside the same transaction and send it from a background worker (Message Queues → exactly-once covers how).

Isolation levels, locking and retries are their own subject — see Transactions.

3. DTOs, domain models and rows: three shapes, on purpose

The same order exists in three shapes in our shop:

ShapeLives inLooks likeChanges when
Request/response DTOHTTP adapter{"email": …, "items": [{"sku", "qty"}]}, "total": "$129.60"the public API changes
Domain modeldomainOrder{Lines, Subtotal, Discount, Shipping, Total Money}the business changes
RowPostgres adapterorders(id, email, total_cents) + order_lines(…)the schema changes

Keeping them separate means each can change on its own schedule. You can rename a column without breaking API clients, or add a field to the domain without exposing it in JSON. The cost is mapping code — the small loops that copy one shape into another, like the items loop in the HTTP handler.

4. Errors: the core names them, each adapter translates them

The core reports problems in business words: ErrOutOfStock, ErrEmptyCart. It never mentions status codes. Each driving adapter translates:

  • The HTTP adapter maps ErrOutOfStock → 409 Conflict, the validation errors → 422, anything else → 500 (and hides the message, so SQL errors don't leak to clients).
  • A CLI adapter would map the same errors to an exit code and a message on stderr.

Driven adapters translate in the other direction: the Postgres adapter turns "0 rows updated" into ErrOutOfStock, so the core never has to recognise a database-specific result. Every translation happens at an edge, which keeps the core readable and each adapter replaceable. How to design the error bodies of a public API is covered in API Design.

Practice

Where does it go?
Ten bits of checkout code. Pick the layer each one belongs in. Score: 0 / 0
“Orders of $100 or more get 10% off”
Ask: would this change if we switched databases or web frameworks? If yes, it's an adapter. If it would change only when the business changes, it's the core.
Practitioner§10

Costs, and when it's overkill

In one sentenceThe dependency rule is cheap. The ceremony people build around it — a layer, a DTO and an interface for everything — is not. Spend it where the business rules are.

Everything so far made the checkout better. But count the files: §02's one function became six files, four interfaces and two sets of mapping code. For checkout, with real rules and two ways in, that's a good trade. For many other parts of an app, it isn't.

What it costs

  • Mapping boilerplate. DTO → input → domain → row, and back. When the shapes are identical — a "product" form that saves a "product" row — the mapping is pure copying, and every new field must be added in four places.
  • Indirection. "Go to definition" on c.store.InTx lands on an interface, not code. New teammates have to learn where the real thing lives.
  • Interfaces with one implementation. An interface you never fake and never swap is just a second place to update when a method changes. Building it "in case we need it" goes against YAGNI.
  • More decisions. Every new function prompts "which layer?". In a small app, that discussion costs more than it saves.

When it's overkill

A CRUD screen — an admin page that lists, edits and deletes products — has no rules to protect. Its "domain" would be a struct identical to the row and the JSON, and its "use case" would be one line that calls the repository. Wrapping that in ports and adapters adds files and protects nothing. The same goes for prototypes, one-off scripts and anything with a lifespan of weeks.

The usual sign you've gone too far is an anemic domain model: a domain package full of structs with no behavior, a service layer that only forwards calls, and a repository that only forwards those. Three layers, zero rules.

Is it worth it here?
Describe the project. The recommendation updates as you go.
Business rules
Ways in
Outside systems
Lifespan
Keep it simple. Handlers that call SQL directly, one package per feature. If one rule is tricky, pull it into a pure function and test that. Layers here would be mostly copying data between identical structs.

Screaming architecture: name folders after the business

Robert Martin's screaming architecture idea: open the project, and the top-level folders should "scream" what the app does — checkout/, catalog/, payments/ — not which framework it uses — controllers/, models/, services/.

Our layout from §06 (domain/, app/, adapters/) is fine for one feature. With twenty features, it becomes a domain/ folder holding twenty unrelated files, and every change touches three distant folders. The fix is package by feature — each feature gets its own core, and the adapters sit next to it:

✕ Package by layer (grows badly)
shop/
  domain/    order.go product.go refund.go user.go …
  app/       checkout.go refund.go signup.go …
  adapters/
    httpapi/   checkout.go refund.go signup.go …
    postgres/  orders.go products.go users.go …
✓ Package by feature
shop/
  checkout/              ← core: rules + use case + ports
    postgres/            ← its driven adapter
    httpapi/             ← its driving adapter
  catalog/               ← plain CRUD: handlers + SQL, no layers
  refunds/
  cmd/shop/main.go

The dependency rule still holds inside each feature: checkout/postgres imports checkout, never the reverse. And each feature can choose its own amount of structure — catalog stays simple.

Go-specific advice

Go's design pushes against some habits imported from Java-style Clean Architecture. Lean into it:

  • Packages by feature, named for what they provide. checkout, not services. Go package names appear at every call site (checkout.NewService), so they should read well. See Package design and naming.
  • Small interfaces, declared by the consumer. One to three methods, in the package that calls them — exactly what app.Tx and httpapi.Checkouter are. See Accept interfaces, return structs.
  • Don't create an interface until a second implementation exists. A test fake counts as the second one. If you never fake the logger, take the concrete *slog.Logger, as our Checkout does.
  • No interface in front of pure code. domain.NewOrder is a plain function; nobody needs to swap it, so nobody needs a Pricer interface.
  • Avoid a shared interfaces or models package. Everything ends up importing it, and it becomes a god package that couples features to each other.

In JavaScript the same advice applies with less friction, since Duck typing means a fake never needs a declared interface. The trap there is the opposite one: with no compiler checks, nothing stops app/checkout.js from importing pg. §11 shows how to add that check.

More on spotting over-engineering in general: Anti-patterns → Premature abstraction and SOLID → When SOLID becomes over-engineering.

Staff-level§11

Enforcing boundaries and evolving legacy code

In one sentenceA rule that only lives in a diagram erodes one "quick fix" at a time. Make the build fail when it's broken, and move old code toward it in small, shippable steps.

Why rules erode

Nobody breaks the dependency rule on purpose. Someone needs the request's user ID deep in the pricing code, and importing net/http is the fastest way to get it. The review is busy; it merges. Six months later the domain imports four infrastructure packages and its tests need Docker. A boundary that isn't checked by a machine isn't a boundary.

The fix is an architecture test: an automated check, run in CI on every change, that encodes "which package may import which".

Go: a 30-line test with go list

go list prints every package and its imports. A normal Go test can run it and check the result:

arch_test.go
package shop_test

import (
    "os/exec"
    "strings"
    "testing"
)

// Inner layers may not import outer ones. key = layer, value = forbidden prefixes.
var forbidden = map[string][]string{
    "shop/domain": {"shop/app", "shop/adapters", "database/sql", "net/http"},
    "shop/app":    {"shop/adapters", "database/sql", "net/http"},
}

func TestDependencyRule(t *testing.T) {
    out, err := exec.Command("go", "list", "-f", `{{.ImportPath}} {{join .Imports " "}}`, "./...").Output()
    if err != nil {
        t.Fatal(err)
    }
    for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
        pkg, imports, _ := strings.Cut(line, " ")
        for _, imp := range strings.Fields(imports) {
            for _, bad := range forbidden[pkg] {
                if strings.HasPrefix(imp, bad) {
                    t.Errorf("%s imports %s: inner layers must not depend on outer ones", pkg, imp)
                }
            }
        }
    }
}

Now someone adds net/http to the domain "so errors can carry a status code":

$ go test -run DependencyRule .
--- FAIL: TestDependencyRule (0.09s)
    arch_test.go:25: shop/domain imports net/http: inner layers must not depend on outer ones
FAIL

And if someone makes app import the Postgres adapter, Go itself refuses before the test even runs, because postgres already imports app:

$ go build ./...
package shop/adapters/httpapi
	imports shop/app from handler.go
	imports shop/adapters/postgres from checkout.go
	imports shop/app from store.go: import cycle not allowed

Go has one more built-in tool: a directory named internal. Packages under checkout/internal/… can be imported only by code inside checkout/. Use it to keep other features from reaching into a feature's private pieces — the compiler enforces it, no test needed. For bigger codebases, linters such as depguard (part of golangci-lint) express the same rules as configuration.

JavaScript: ESLint and dependency-cruiser

ESLint's built-in no-restricted-imports rule can forbid imports per folder (ESLint 9+ "flat config", checked here with ESLint 10.14):

eslint.config.js
export default [
  {
    files: ["src/domain/**/*.js"],
    rules: {
      "no-restricted-imports": ["error", {
        patterns: [{ group: ["**/app/**", "**/adapters/**", "pg", "node:http"],
                     message: "domain is the core: it may not import outer layers or infrastructure." }],
      }],
    },
  },
  {
    files: ["src/app/**/*.js"],
    rules: {
      "no-restricted-imports": ["error", {
        patterns: [{ group: ["**/adapters/**", "pg", "node:http"],
                     message: "app may use domain only; talk to the outside through ports." }],
      }],
    },
  },
];
$ npx eslint src
src/app/checkout.js
  1:1  error  '../adapters/postgres.js' import is restricted from being used by a pattern. app may use domain only; talk to the outside through ports  no-restricted-imports

✖ 1 problem (1 error, 0 warnings)

A deny-list has a weakness: it only forbids what you thought of. dependency-cruiser (v18 here) lets you write the rule as an allow-list — "domain may import only domain" — which also catches packages nobody listed:

.dependency-cruiser.cjs
module.exports = {
  forbidden: [
    { name: "domain-imports-only-domain", severity: "error",
      from: { path: "^src/domain" }, to: { pathNot: "^src/domain" } },
    { name: "app-imports-only-app-and-domain", severity: "error",
      from: { path: "^src/app" }, to: { pathNot: "^src/(app|domain)" } },
  ],
  options: { doNotFollow: { path: "node_modules" } }, // check our code, not our dependencies
};
$ npx depcruise src --config .dependency-cruiser.cjs

  error domain-imports-only-domain: src/domain/order.js → http

x 1 dependency violations (1 errors, 0 warnings). 9 modules, 12 dependencies cruised.

Evolving a legacy codebase

You'll rarely start fresh. More often there's a §02-style handler in production, earning money, and a rewrite would freeze features for months — then ship new bugs. The safe path is a series of small refactors, each one deployable:

From tangled handler to ports & adapters
Five small refactors, each one shippable on its own.
Legacy handler
checkout() — 200 lines
HTTP + SQL + rules + email
Core
—
Adapters
—
Safety net
no tests
Start: the tangled handler from §02, in production, earning money. A rewrite would stop features for weeks. Instead, move one piece at a time, shipping after each step.
1 / 6

Three techniques carry this:

  • Characterization tests first. They capture what the code does today — even the quirks — so you know when a refactor changed behavior.
  • Extract the pure parts before adding interfaces. Pricing math needs no port; moving it into a function is a safe, quick first win.
  • Branch by abstraction for the rest: put a port in front of the old code, move callers onto the port, then replace what's behind it. At every step, the old path still works.

Move code when you touch it. Don't schedule a "migrate everything" project. Each new feature starts in the new structure; old code moves when a change takes you there anyway. That way the code that changes most often migrates first — which is exactly the code that benefits — and code nobody touches may never need to move at all.

How this relates to modular monoliths and DDD

Ports and adapters is about the structure inside one part of a system. Two neighbouring ideas work at a larger scale:

  • A Modular monolith splits one deployable app into modules with strict boundaries — checkout, catalog, payments — each owning its data and exposing a small API. Package-by-feature from §10, with architecture tests between features, is a modular monolith. Each module can use ports and adapters inside, or not. And if one module is later split into its own service, its ports make that much easier: the HTTP or queue adapter changes, the core doesn't. The trade-offs of that split are in Monolith vs Microservices.
  • Domain-driven design answers the question this page skipped: what goes in the core, and where one module ends and the next begins. Its building blocks — entities, value objects, aggregates — are how you design domain, and its bounded contexts are how you draw the module lines. See Domain-Driven Design.

A short way to hold all three: DDD decides what the core is; ports and adapters keeps it clean; the modular monolith keeps the cores apart.

Practitioner§12

Pitfalls gallery

In one sentenceMost failures aren't "we didn't use clean architecture". They're the right boxes with the wrong things inside — ports that leak, cores with no rules, and ceremony where none was needed.
1

A port that mirrors the database

type Store interface { Exec(query string, args ...any) error } is an interface, but the core still writes SQL — it's the database's API with a new name. That's a Leaky abstraction: swap databases and every use case changes. Fix: name ports in business words — TakeStock(sku, qty), SaveOrder(order) — and keep the SQL inside the adapter.

2

Framework types in the core

A use case that takes *http.Request, returns *sql.Rows, or accepts a *gin.Context has quietly imported the outer ring. It can't be called from a CLI, and its tests need the framework. Fix: the core takes plain values (email string, items []Item) and returns domain types. The adapter does the converting (§07).

3

Side effects inside the transaction

Sending the receipt, charging a card or publishing a message inside InTx means a rollback can't undo it — the customer gets an email for an order that never saved. It also holds the transaction open while a slow server answers. Fix: do it after the commit, or record it in an Outbox pattern table in the same transaction (§09).

4

An interface for everything

IOrderService, IOrderRepository, IOrderMapper, IPriceCalculator — each with exactly one implementation and no fake. Every change touches twice the files, and "go to definition" always lands on an interface. Fix: add an interface when a second implementation exists (a fake counts). Pure code like pricing needs none (§10).

5

The anemic core

The domain package holds structs with no methods; the app service does every check inline; the result is the old tangled handler split across three files. That's an anemic domain model. Fix: move rules into domain functions and constructors like NewOrder, so an invalid order can't be built at all — and test them there.

6

Mocking every call

Tests that assert "Products was called once with [LAMP, MUG], then TakeStock twice…" break whenever you reorder harmless steps, while proving little about the result. Fix: prefer working fakes and check outcomes (stock went down, order saved, one receipt). Keep the fakes honest with contract tests (§08). More in Testing seams.

7

Layer-per-folder sprawl

Top-level controllers/, services/, repositories/, dtos/, mappers/: adding one field to checkout touches five folders, and nothing says which files belong together. Fix: package by feature, with layers inside each feature only where they're earned (§10).

8

An adapter calling another adapter

The HTTP handler calls postgres.Store directly "because it's just a read". Now the read path skips the core's rules (say, hiding discontinued products), and two places know the schema. Fix: go through the core — even a one-line use case — or, if it's honestly plain CRUD with no rules, make that a deliberate choice for that feature, not a shortcut inside a layered one.

9

A rule nobody checks

The diagram is in the wiki; the code imports database/sql from domain since last spring. Fix: an architecture test in CI — go list in Go, ESLint or dependency-cruiser in JS (§11).

Practitioner§13

Check yourself

Decide on your answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
Which import breaks the dependency rule?
QUESTION 2 / 7
What does this print when you build it?
// package app
import "shop/adapters/postgres" // to call postgres.Store directly

// package postgres (already there)
import "shop/app"               // to implement app.Tx
QUESTION 3 / 7
The Postgres adapter's UPDATE affected 0 rows (not enough stock). What should it return to the core?
QUESTION 4 / 7
Which one is a driven (secondary) adapter?
QUESTION 5 / 7
What's wrong with sending the receipt email inside the transaction?

store.InTx(ctx, func(tx Tx) error { …; mail.SendReceipt(ctx, order); return tx.SaveOrder(ctx, order) })

QUESTION 6 / 7
A small internal admin app: one Postgres, one HTTP API, forms that edit 6 tables, no real rules. Best structure?
QUESTION 7 / 7
In Go, where should the Store interface used by app.Checkout be declared?
Answered 0 / 7
Foundations§14

Cheat sheet

🍳
Kitchen in the middleBusiness rules in the center; the web, the database and email plug in around them. New ways in don't change the recipe (§01).
➡️
Imports point inwardThe dependency rule: domain imports nothing outer; app imports domain; adapters import both; main imports everything (§03).
🔌
The core owns the portsInterfaces live with the code that uses them, phrased in business words. Driving adapters call in; driven adapters implement (§04).
🔄
Translate at the edgesJSON → input, rows → domain types, "0 rows" → ErrOutOfStock, ErrOutOfStock → 409 (§07, §09).
🧪
Fakes for the coreDomain and use-case tests run in microseconds with in-memory fakes; contract tests keep the fakes honest (§08).
🧾
Core decides what, adapter decides howThe use case asks for "these steps together" (InTx); the adapter does BEGIN/COMMIT. Side effects after commit (§09).
⚖️
Only where it paysRules that change and many ways in: yes. Plain CRUD and prototypes: no. Package by feature (§10).
🚦
Make the build enforce itgo list test, ESLint no-restricted-imports, dependency-cruiser in CI; migrate legacy code in small steps (§11).
I want to…Do this
Decide where a line of code goesAsk: would it change if we swapped the database or web framework? Yes → adapter. Only when the business changes → core
Let the core save dataDeclare a small interface in app (SaveOrder(ctx, order)); implement it in adapters/postgres; plug it in from main
Make several writes all-or-nothingA unit-of-work port: store.InTx(ctx, func(tx Tx) error { … }); the adapter wraps it in BEGIN/COMMIT
Report a business failureA domain error (ErrOutOfStock, or a JS DomainError subclass); the HTTP adapter maps it to a status code
Validate inputFormat (valid JSON, right types) in the adapter → 400; business rules (qty 1–99) in the domain → 422
Test pricingCall the domain function directly — no fakes, no database
Test a use casePlug in-memory fakes into its ports; check outcomes, not call sequences
Test an adapterAgainst the real thing (test DB, sandbox account) plus a shared contract test
Add a CLI / queue consumerWrite a new driving adapter that calls the same use case
Keep the rule from erodingArchitecture test in CI; Go internal/ packages; dependency-cruiser allow-lists in JS
Start on a legacy codebaseCharacterization tests → extract pure functions → port in front of the DB → move steps into a use case → arch test
Skip itCRUD screens, prototypes, scripts: plain handlers + SQL, grouped by feature
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Software ArchitectureMonolith vs Microservices
Read →