- 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 order_service.go to fix a small bug. The function
that places an order is 180 lines. It validates the cart,
computes the discount, checks the credit limit, decides whether
the order ships free, opens a transaction, calls the payment
gateway, writes three tables, sends an email, and pushes a
metric. The rule you came to fix — "orders over $500 get free
shipping" — is buried on line 94, next to a SQL string.
You change the threshold. A test breaks. To run that test you
need a database, a fake payment gateway, an SMTP stub, and a
metrics sink. The shipping rule has nothing to do with any of
them, but you cannot test it alone, because it does not live
alone.
That is the smell. The application service grew business logic.
The fix is a split: the service coordinates ports, the domain
holds the rules. Done right, the service stays small enough to
read in one screen.
The two jobs that get confused
There are two different jobs in that 180-line function, and they
have different reasons to change.
Orchestration is the sequence of steps that fulfils a use
case. Load the order, ask the domain a question, save the
result, publish an event. It talks to the outside: databases,
queues, clocks, payment gateways. It changes when the workflow
changes — a new step, a different store, an added notification.
Business logic is the rules. When does an order qualify for
free shipping. What makes a discount valid. Whether a status
transition is legal. It talks to nothing outside itself. It
changes when the business changes.
When both live in the same function, every workflow edit risks a
rule, and every rule edit drags in a database. Separate them and
each side gets a single reason to change.
The rules go in the domain
Start with the rule that was buried on line 94. It belongs on
the domain type, where it can be tested with no infrastructure
at all.
// domain/order.go
package domain
import "time"
type Money int64 // cents
type Order struct {
ID string
Customer string
Lines []Line
Status Status
CreatedAt time.Time
}
type Line struct {
SKU string
Quantity int
Price Money
}
func (o Order) Subtotal() Money {
var sum Money
for _, l := range o.Lines {
sum += l.Price * Money(l.Quantity)
}
return sum
}
func (o Order) QualifiesForFreeShipping() bool {
return o.Subtotal() >= 50000 // $500.00
}
No database, no context, no interface. You test the shipping
rule by building an Order in memory and calling a method.
func TestFreeShipping(t *testing.T) {
o := domain.Order{Lines: []domain.Line{
{SKU: "A", Quantity: 1, Price: 60000},
}}
if !o.QualifiesForFreeShipping() {
t.Fatal("expected free shipping over $500")
}
}
That test runs in microseconds and never flakes. The rule lives
where it can be exercised in isolation. When the threshold
changes, you edit one method and one test.
State transitions belong here too. A domain method enforces the
legal moves so no caller can put an order in an impossible
state.
// domain/order.go (import "fmt" added to the file)
type Status string
const (
StatusPending Status = "pending"
StatusConfirmed Status = "confirmed"
StatusShipped Status = "shipped"
)
func (o *Order) Confirm() error {
if o.Status != StatusPending {
return fmt.Errorf(
"cannot confirm order in status %q", o.Status,
)
}
o.Status = StatusConfirmed
return nil
}
The rule "you can only confirm a pending order" is now
unbreakable from outside the domain. The service never checks
the status by hand.
The ports describe what the service needs
The application service does not import a database driver or an
HTTP client. It depends on interfaces — ports — that say what it
needs in domain terms.
// port/order.go
package port
import (
"context"
"yourapp/domain"
)
type OrderRepository interface {
Save(ctx context.Context, o domain.Order) error
}
type PaymentGateway interface {
Charge(
ctx context.Context, customer string, amount domain.Money,
) error
}
type EventPublisher interface {
Publish(ctx context.Context, name, aggregateID string) error
}
Three small interfaces, each named for what the service wants,
not for the technology behind it. The Postgres adapter, the
Stripe adapter, and the Kafka adapter implement them. The
service never sees those names.
The service coordinates, and stays thin
Now the use case. The application service holds the ports, runs
the steps in order, and asks the domain for every decision. It
makes no decision of its own.
// app/place_order.go
package app
import (
"context"
"yourapp/domain"
"yourapp/port"
)
type PlaceOrder struct {
orders port.OrderRepository
payments port.PaymentGateway
events port.EventPublisher
}
func NewPlaceOrder(
o port.OrderRepository,
p port.PaymentGateway,
e port.EventPublisher,
) *PlaceOrder {
return &PlaceOrder{orders: o, payments: p, events: e}
}
func (s *PlaceOrder) Handle(
ctx context.Context, o domain.Order,
) error {
if err := o.Confirm(); err != nil {
return err
}
amount := o.Subtotal()
if !o.QualifiesForFreeShipping() {
amount += shippingFee
}
if err := s.payments.Charge(
ctx, o.Customer, amount,
); err != nil {
return err
}
if err := s.orders.Save(ctx, o); err != nil {
return err
}
return s.events.Publish(ctx, "OrderPlaced", o.ID)
}
const shippingFee = domain.Money(999)
Read the Handle body top to bottom. Every line is either a
call to a port or a call to a domain method. There is no if
that encodes a rule, no arithmetic that decides anything, no SQL.
The free-shipping rule is o.QualifiesForFreeShipping(). The
status rule is o.Confirm(). The total is o.Subtotal(). The
service knows the order of the steps and nothing about why each
step is what it is.
It fits in well under 30 lines. That is the target: if a use
case method does not fit on a screen, a rule has probably leaked
into it.
How to tell which side a line belongs to
When you add a line and are not sure where it goes, ask one
question: does it need the outside world to run?
If you can decide it from the data already in hand — a
comparison, a sum, a status check, a validation — it is a rule.
Push it onto a domain method.
If it reaches out — reads a row, charges a card, publishes a
message, reads the clock — it is orchestration. It stays in the
service, behind a port.
The clock is the one people miss. "An order expires after 24
hours" has two parts. Whether a given timestamp is expired is a
rule the domain can answer if you pass it the current time. Where
that current time comes from is orchestration.
// domain/order.go (same "time" import as above)
func (o Order) IsExpired(now time.Time) bool {
return now.Sub(o.CreatedAt) > 24*time.Hour
}
The domain takes now as a parameter instead of calling
time.Now(). The service reads the clock through a port and
passes the value in. The rule stays testable without freezing
time.
What the split buys you
The shipping-rule test from the top now runs with no database,
no payment stub, and no SMTP server, because the rule no longer
lives next to them. You build an Order and call a method.
The service gets tested too, but for a different thing: that it
calls the steps in the right order and stops on the first error.
You feed it fake ports — in-memory implementations of the three
interfaces — and assert the sequence. You are not re-testing the
rules there; the domain tests already cover those.
When the workflow changes — add fraud screening before the
charge — you edit the service and leave every domain rule
untouched. When a rule changes — free shipping moves to $300 —
you edit one domain method and its test, and the service does not
move at all. Each side has exactly one reason to change, which is
the whole point of keeping them apart.
The 180-line function did the same work. It just did it where you
could not see the seams, and could not test either half without
the other. The thin service is the same logic with the seams
made visible and the rules pulled into a place that needs nothing
to run.
If this was useful
The split between a thin application service and a domain that
owns its rules is the load-bearing idea in Hexagonal Architecture in Go.
The book works through the full layout — domain, ports, adapters,
and the application services that wire them — and the patterns
for keeping the service thin as the workflow grows. The Complete
Guide to Go Programming covers the language pieces the adapters
lean on, from interfaces to context to error wrapping.

Top comments (0)