- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a Go file called pricing.go. You expect math. The first line of the function reads tx, err := db.BeginTx(ctx, nil). The second line is an HTTP call to a tax service. Buried thirty lines down, after a time.Now() and a feature-flag lookup, is the bit you came for: a multiplication.
The math is two lines. Everything around it is wiring. To unit-test the math, you have to spin up Postgres, mock an HTTP server, freeze the clock, and stub a feature-flag SDK. You give up and write an integration test. The integration test takes 800ms. You write four. The CI run goes from forty seconds to four minutes. Six months later nobody runs the suite locally.
This is the problem Gary Bernhardt named in his talk "Boundaries": I/O and decisions tangled in the same function. His fix is the split this post is about: Functional Core, Imperative Shell. Pure code in the middle. Side effects at the edges. A clean line between them.
It maps onto Go beautifully, and it does something hexagonal architecture alone does not: it tells you what should live inside the hexagon.
The pattern in one paragraph
The functional core is code that takes data in and returns data out. No time.Now(), no rand, no database, no HTTP, no logging. Same input, same output, every time. The imperative shell is everything else: it reads from the world, calls the core with the data it gathered, and writes the result back to the world. The shell is dirty and short. The core is pure and where the rules live.
The split is not a Go-specific idea. It comes from functional programming, and the version Bernhardt landed on is pragmatic: the program does not have to be Haskell; the decisions just have to be kept out of the I/O. Go gives you no help enforcing it. There is no IO monad, no pure keyword. But the compiler does enforce one thing that matters: the import graph. If your core package never imports database/sql, net/http, or time, the compiler will tell you the moment someone tries to add I/O to the core.
What this is not
This post is the sibling of an earlier one on main() as the composition root. The composition-root post is about where wiring lives: every dependency assembled in one function. This one is about where decisions live: pure functions, isolated from effects. They are different cuts. You can have a clean composition root and still have a domain package full of time.Now() calls. You can have a pure domain and still wire it through a tangled main(). Do both.
The pricing example, before
Here is a pricing service the way most Go codebases write it. One function, end to end:
package pricing
func (s *Service) PriceCart(
ctx context.Context,
cartID string,
) (Quote, error) {
cart, err := s.carts.Load(ctx, cartID)
if err != nil {
return Quote{}, err
}
rate, err := s.tax.Rate(ctx, cart.Region)
if err != nil {
return Quote{}, err
}
promo, _ := s.flags.PromoActive(ctx, cart.UserID)
var subtotal int64
for _, item := range cart.Items {
subtotal += item.PriceCents * int64(item.Qty)
}
if promo {
subtotal = subtotal * 90 / 100
}
tax := subtotal * int64(rate*100) / 10000
quote := Quote{
Subtotal: subtotal,
Tax: tax,
Total: subtotal + tax,
IssuedAt: time.Now(),
ExpiresAt: time.Now().Add(15 * time.Minute),
}
if err := s.quotes.Save(ctx, quote); err != nil {
return Quote{}, err
}
return quote, nil
}
To test that a 10% promo applied on top of an 8% tax produces the right total, you need: a cart store, a tax client, a feature-flag client, a quote store, and a frozen clock. Five fakes for two arithmetic rules.
The pricing example, after
The split is mechanical. The pure pieces (totals, discounts, tax math, expiry) go into a pricing package that imports nothing from the project and nothing dirty from the standard library. The dirty pieces (loading a cart, calling tax services, saving the quote) go into pricing/handler (or pricing/shell if you prefer that name).
The core:
package pricing
type Item struct {
SKU string
Qty int
PriceCents int64
}
type Cart struct {
UserID string
Region string
Items []Item
}
type Inputs struct {
Cart Cart
TaxRate float64
PromoActive bool
Now time.Time
}
type Quote struct {
Subtotal int64
Tax int64
Total int64
IssuedAt time.Time
ExpiresAt time.Time
}
func Price(in Inputs) Quote {
var subtotal int64
for _, it := range in.Cart.Items {
subtotal += it.PriceCents * int64(it.Qty)
}
if in.PromoActive {
subtotal = subtotal * 90 / 100
}
tax := subtotal * int64(in.TaxRate*100) / 10000
return Quote{
Subtotal: subtotal,
Tax: tax,
Total: subtotal + tax,
IssuedAt: in.Now,
ExpiresAt: in.Now.Add(15 * time.Minute),
}
}
Price takes a struct and returns a struct. It calls no method, opens no connection, reads no clock. time.Time is in the signature, but the package never calls time.Now(). The moment is passed in. That is the line. Values from the outside come in as parameters; results go out as return values.
The shell:
package handler
type CartLoader interface {
Load(ctx context.Context, id string) (pricing.Cart, error)
}
type TaxClient interface {
Rate(ctx context.Context, region string) (float64, error)
}
type FlagClient interface {
PromoActive(ctx context.Context, userID string) (bool, error)
}
type QuoteStore interface {
Save(ctx context.Context, q pricing.Quote) error
}
type Pricing struct {
carts CartLoader
tax TaxClient
flags FlagClient
quotes QuoteStore
now func() time.Time
}
func (p *Pricing) PriceCart(
ctx context.Context,
cartID string,
) (pricing.Quote, error) {
cart, err := p.carts.Load(ctx, cartID)
if err != nil {
return pricing.Quote{}, fmt.Errorf("loading cart: %w", err)
}
rate, err := p.tax.Rate(ctx, cart.Region)
if err != nil {
return pricing.Quote{}, fmt.Errorf("tax rate: %w", err)
}
promo, _ := p.flags.PromoActive(ctx, cart.UserID)
quote := pricing.Price(pricing.Inputs{
Cart: cart,
TaxRate: rate,
PromoActive: promo,
Now: p.now(),
})
if err := p.quotes.Save(ctx, quote); err != nil {
return pricing.Quote{}, fmt.Errorf("saving quote: %w", err)
}
return quote, nil
}
The shell does four things and one of them is calling the core. Read it top to bottom: gather inputs, hand them to pricing.Price, persist the result. There are no decisions in here. No if branches on business rules, no math, no rounding. If you find yourself adding a conditional to the shell that depends on the cart contents, that conditional belongs in the core.
The testing pyramid that falls out
The split changes what your tests look like.
For the core, no fakes:
func TestPrice_AppliesPromoBeforeTax(t *testing.T) {
in := pricing.Inputs{
Cart: pricing.Cart{
Region: "DE",
Items: []pricing.Item{
{SKU: "A", Qty: 2, PriceCents: 5000},
},
},
TaxRate: 0.19,
PromoActive: true,
Now: time.Unix(0, 0),
}
q := pricing.Price(in)
if q.Subtotal != 9000 {
t.Fatalf("subtotal = %d, want 9000", q.Subtotal)
}
if q.Tax != 1710 {
t.Fatalf("tax = %d, want 1710", q.Tax)
}
}
No *testing.T setup. No httptest. No mock generator. The test runs in microseconds and there are dozens of them — one per pricing rule, edge case, and rounding boundary. When the product owner asks "what happens with a 100% promo on a tax-exempt region", you write three lines, run them, and find out.
For the shell, you mock the four interfaces and assert wiring, not math:
func TestPriceCart_Wiring(t *testing.T) {
h := &handler.Pricing{
carts: fakeCarts{cart: testCart},
tax: fakeTax{rate: 0.19},
flags: fakeFlags{promo: true},
quotes: &recordingQuotes{},
now: func() time.Time { return frozen },
}
q, err := h.PriceCart(context.Background(), "cart-1")
// assert that the cart was loaded, the quote saved,
// the right region went to the tax client. Not the math.
}
You stop testing arithmetic against four mocks. The shell test asserts that loaders are called and outputs are persisted. The math is the core's responsibility. The core has its own tests, and that's where most of the assertions live.
The pyramid that falls out: a fat base of pure-function tests that boot nothing, a thin middle of shell tests with mocks, and a handful of integration tests at the top that hit a real database and tax sandbox. CI gets fast. You start writing tests you would have skipped before because they were not worth the boilerplate.
Where the line goes
Three rules keep the split honest as the codebase grows.
The core never imports time, os, net, database, or crypto/rand. If a function in the core needs the current time, the time gets passed in. If it needs randomness, an id string gets passed in (the shell generated it). The package's import block is the audit trail.
The core returns; the shell decides what to do with the return. A pure function cannot log, cannot retry, cannot send to a queue. If it wants to express "this is suspicious, you should alert", it returns a value or an error that says so. The shell reads that and acts. This is the part that surprises people: the core does not raise alerts, it computes the alertable condition.
Effectful steps belong in the shell, even when they look like math. "Generate an order ID" feels like data, but it is randomness, so it is shell. "Stamp the created-at timestamp" feels like data, but it is the clock, so it is shell. "Round to the nearest cent" is math, so it is core. The test is whether the function is deterministic when you call it with the same arguments — if yes, core; if no, shell.
Why Go fits
You don't get the help a Haskell programmer gets, but you get the pieces that matter. Interfaces are implicit, so the shell defines CartLoader next to the handler that consumes it without the core ever knowing. Passing Inputs and returning Quote costs nothing because structs are values. And a now func() time.Time is the entire abstraction over a clock: first-class functions doing the work a Reader monad would do elsewhere.
The compiler enforces the boundary the only way it can: through imports. Add import "net/http" to your core package and the next person to read the diff sees it immediately. Add a CI check that fails if the core imports anything dirty:
go list -f '{{.Imports}}' ./pricing/... \
| grep -qE "database/sql|net/http" \
&& echo "FAIL: core has effects" \
&& exit 1
go list only emits import paths, not symbol uses, so this catches forbidden packages but not a smuggled time.Now() call once time is in the import list (it has to be, for time.Time in the signature). Catching the symbol needs a separate AST scan — go vet with a custom analyzer, or a quick grep -r 'time\.Now(' ./pricing step alongside this one.
That is the discipline. A pure middle, an effectful edge, and a check that catches anyone smuggling I/O across the line.
The hexagon tells you to push I/O outward. Functional Core, Imperative Shell tells you what shape the inside should take when you get there. Use both.
If this was useful
The full split is one of the chapters in Hexagonal Architecture in Go: core packages, shell packages, the testing pyramid that comes with them, and where this pattern starts to break (long-running workflows, sagas, anything where the next decision depends on the result of the last effect). The Complete Guide to Go Programming is the companion when you want the language fundamentals that make this style cheap to write.

Top comments (0)