- 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're three days into a new Go service. Someone on the team posts a link to a hexagonal architecture tutorial in Slack. You open it and the first thing on the page is a glossary: ubiquitous language, aggregate root, bounded context, anti-corruption layer, repository as collection. You scroll past it. The first code sample defines an AbstractAggregateRoot[T] and a DomainEventDispatcher. You close the tab.
You didn't want a ten-week reading list. You wanted to know where the SQL goes.
That instinct is correct. Hexagonal architecture and Domain-Driven Design got bundled together in the literature because the same people wrote about both, and now most tutorials assume you want both. You don't have to. Ports and adapters work fine without aggregates, value objects, or a domain expert in the room. This post shows the minimum viable hex layout in Go: three packages, a domain that imports nothing infrastructural, and adapters that wrap the boring parts.
What hexagonal actually is, separated from DDD
Hexagonal architecture is a 2005 idea from Alistair Cockburn. The whole thing is one rule:
Your business code talks to the outside world through interfaces it owns. Concrete drivers, databases, queues, and HTTP clients sit on the outside and implement those interfaces.
That's it. The "hexagon" is a drawing convention. The "ports" are interfaces. The "adapters" are structs that implement them. You can write a hexagonal service without ever using the word aggregate.
DDD is a different idea, also from the early 2000s, by Eric Evans. It says the structure and language of your code should match the business domain, with specific tactical patterns: entities, value objects, aggregates, domain events, ubiquitous language. DDD is useful when the domain is rich: subscriptions, billing, complex state machines. It is overkill when your service is a checkout that turns a cart into a row in a table.
The two patterns compose well, which is why they appear together. They are not the same pattern. You can pick one and skip the other.
This post is about picking hexagonal and skipping DDD.
The three-package layout
A minimal hex Go service has three packages inside internal/:
checkout/
├── internal/
│ ├── domain/ # behavior + plain types, no infra imports
│ ├── port/ # interfaces the domain consumes
│ └── adapter/ # concrete implementations of the ports
└── main.go
That's the whole thing. No aggregate/, no valueobject/, no application/service/, no bounded_context/. Three directories.
The rule that makes it work is the import graph:
-
domainimports nothing from the project. Standard library only. -
portimportsdomain(so it can name domain types in interface signatures). -
adapterimportsportanddomain(it implements the interfaces and works with the types). -
main.goimports everything and wires it together.
If your domain package ever imports database/sql, net/http, encoding/json, or anything from adapter/, the architecture is leaking. One CI check catches it:
go list -f '{{.Imports}}' ./internal/domain/... \
| grep -qE "database/sql|net/http|encoding/json" \
&& echo "FAIL: domain imports infrastructure" \
&& exit 1
A worked example: a checkout that doesn't need DDD
The service has one job: a customer submits a cart, and it gets persisted as an order with a computed total. There are no state machines, no consistency boundaries across multiple entities, no business rules that need to talk to a domain expert. A row goes into a table.
The DDD-flavored version of this would have:
- An
Orderaggregate root with unexported fields. - A
LineItemvalue object with a constructor. - A
Moneyvalue object with currency validation. - A
CustomerIDvalue object wrapping a string. - A repository interface defined in the domain.
- An application service that orchestrates the aggregate.
- Optionally, a
CartConfirmeddomain event.
That's seven types, five files, and a constructor for every primitive. For a service that does INSERT INTO orders. The ceremony pays back when the domain has real rules. Here it doesn't.
The hex-only version of the same checkout looks like this:
internal/domain/checkout.go
package domain
import "time"
type Item struct {
SKU string
Quantity int
PriceCents int64
}
type Order struct {
ID string
CustomerID string
Items []Item
TotalCents int64
CreatedAt time.Time
}
func TotalFor(items []Item) int64 {
var total int64
for _, it := range items {
total += it.PriceCents * int64(it.Quantity)
}
return total
}
Plain structs with exported fields. No constructor that returns (Item, error), because there is no invariant that needs guarding at construction time. The single piece of behavior, TotalFor, is a free function. There is no aggregate to hang it off, and forcing one would be theater.
internal/port/port.go
package port
import (
"context"
"time"
"example.com/checkout/internal/domain"
)
type OrderRepository interface {
Save(ctx context.Context, order domain.Order) error
}
type Clock interface {
Now() time.Time
}
One interface for what the domain needs from the database. Defined on the consumer side: the domain decides the shape of the persistence call, the adapter does not. That is the only DDD-ism worth keeping: interfaces belong with the caller, not with the implementation.
internal/adapter/postgres.go
package adapter
import (
"context"
"database/sql"
"fmt"
"example.com/checkout/internal/domain"
)
type PostgresOrders struct {
db *sql.DB
}
func NewPostgresOrders(db *sql.DB) *PostgresOrders {
return &PostgresOrders{db: db}
}
func (p *PostgresOrders) Save(
ctx context.Context,
o domain.Order,
) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO orders
(id, customer_id, total_cents, created_at)
VALUES ($1, $2, $3, $4)`,
o.ID, o.CustomerID, o.TotalCents, o.CreatedAt,
)
if err != nil {
return fmt.Errorf("inserting order: %w", err)
}
return nil
}
The adapter is a thin SQL wrapper that satisfies port.OrderRepository. Notice the import direction: adapter knows about domain, but domain doesn't know adapter exists. Swap PostgreSQL for DynamoDB and only this file changes.
The handler — also an adapter
package adapter
import (
"encoding/json"
"net/http"
"example.com/checkout/internal/domain"
"example.com/checkout/internal/port"
)
type CheckoutHandler struct {
repo port.OrderRepository
clock port.Clock
}
func NewCheckoutHandler(
repo port.OrderRepository,
clock port.Clock,
) *CheckoutHandler {
return &CheckoutHandler{repo: repo, clock: clock}
}
func (h *CheckoutHandler) Submit(
w http.ResponseWriter,
r *http.Request,
) {
var req struct {
CustomerID string `json:"customer_id"`
Items []domain.Item `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
order := domain.Order{
ID: newID(),
CustomerID: req.CustomerID,
Items: req.Items,
TotalCents: domain.TotalFor(req.Items),
CreatedAt: h.clock.Now(),
}
if err := h.repo.Save(r.Context(), order); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(order)
}
The HTTP layer is an inbound adapter. It owns the JSON shape, calls a domain free function for the math, and delegates persistence to the port. Three lines do the work: parse, compute, save.
Tests without a database, still without DDD
Every hex-with-DDD tutorial sells you on the same payoff: fast tests with no infrastructure. That works fine without aggregates. You write a fake that satisfies port.OrderRepository and call the handler with a httptest server. That's it.
package adapter_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"example.com/checkout/internal/adapter"
"example.com/checkout/internal/domain"
)
type fakeRepo struct{ saved []domain.Order }
func (f *fakeRepo) Save(_ context.Context, o domain.Order) error {
f.saved = append(f.saved, o)
return nil
}
type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }
func TestSubmit_ComputesTotal(t *testing.T) {
repo := &fakeRepo{}
h := adapter.NewCheckoutHandler(
repo,
fixedClock{t: time.Unix(0, 0)},
)
body := strings.NewReader(`{
"customer_id": "cust-1",
"items": [{"sku": "A", "quantity": 2, "price_cents": 1500}]
}`)
req := httptest.NewRequest("POST", "/checkout", body)
rec := httptest.NewRecorder()
h.Submit(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d", rec.Code)
}
if len(repo.saved) != 1 || repo.saved[0].TotalCents != 3000 {
t.Fatalf("saved = %+v", repo.saved)
}
}
No mock framework. No fixture builder. No OrderAggregateBuilder().WithItems(...).Build(). The fake is six lines because the port has one method.
When tests are this small, you find yourself writing more of them. That is the actual win of hexagonal. You can test behavior without booting a container.
When to add DDD on top, and when not to
Skip DDD when:
- The state machine has fewer than three states (
pending→confirmed, done). - The "rules" the product owner mentions are all
NOT NULLandUNIQUEconstraints. - Every method on the type is a getter, a setter, or a CRUD verb.
- A domain expert would describe the feature as a form, not as a behavior.
The checkout above fits all four. Forcing aggregates onto it would replace domain.Order{ID: ...} with three constructor calls and a private-field discipline that buys nothing because nothing is mutating the order after creation.
Reach for DDD when:
- The aggregate has invariants that span multiple fields and must hold inside one transaction.
- Methods on the type have business names (
Confirm,Cancel,Resume) and the rules for each transition are nontrivial. - The domain expert in the room would push back on
SetStatus("cancelled")and insist you call itCancel(reason).
A subscription with Pause, Resume, Upgrade, Expire, and Reactivate deserves an aggregate. A checkout that turns a cart into a row does not.
Mix freely inside the same service. The user-management package can be a thin handler over a query, and the subscription package next to it can be a full aggregate with invariants and events. They share the same port/ and adapter/ layout.
What you keep, what you drop
Keep:
- The three-package import graph:
domainclean,portnext todomain,adapteroutside. - Interfaces named for what the domain needs (
OrderRepository,Clock,EmailSender), defined where the domain lives. -
main.goas the only place that knows about concrete types. - One CI check that fails if
domainimports infrastructure.
Drop, until you actually need them:
- Aggregate base classes and unexported-field discipline.
- Per-primitive value objects.
- A
domain/eventsubpackage when no one is consuming the events yet. - Ubiquitous language ceremony when the team and the spec already use the same words.
Hexagonal earns its keep on day one. It makes your tests fast and your dependencies legible. DDD earns its keep on the day a domain expert tells you the third rule about subscription cancellation. Wait for that day before paying the tax.
If this was useful
The full hex layout is the spine of Hexagonal Architecture in Go: bigger services, multiple adapters per port, error translation at boundaries, the parts where DDD does start to earn its keep. It walks the same three-package shape from a hello-world checkout up to a multi-module service with queues, caches, and observability. The Complete Guide to Go Programming is the companion when the language itself is what's slowing you down.

Top comments (0)