- 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 have seen this before. A Go service with a clean folder structure. internal/domain, internal/adapter, internal/port. README mentions hexagonal architecture. The team is proud of it.
Then you open internal/domain/order.go and read the imports:
package domain
import (
"context"
"database/sql"
"encoding/json"
"net/http"
)
Three infrastructure packages sitting in the domain. Hexagonal architecture as a folder convention, not an architectural boundary. Testing requires a real database. Swapping PostgreSQL for DynamoDB means rewriting the domain. Those "ports" are just interfaces that happen to live in a separate file.
Two days of refactoring later: a domain package with zero infrastructure imports and a test suite that runs in milliseconds.
The Rule, in One Sentence
Your domain package should never import anything that ties it to infrastructure.
In Go, dependency means import. No annotations, no classpath magic, no hidden wiring. If package A imports package B, A depends on B. You can read the dependency graph by reading the import blocks. The compiler is your architecture linter.
The dependency rule says: adapters import the domain. The domain never imports adapters. Arrows point inward, always.
adapter/postgres ──imports──▶ domain ✅
adapter/http ──imports──▶ domain ✅
domain ──imports──▶ database/sql ❌
domain ──imports──▶ net/http ❌
Three Violations, Dissected
Back to that broken service. Each violation seemed harmless on its own. Together they turned the domain into a thin wrapper around infrastructure.
Violation 1: database/sql in a Domain Struct
// domain/order.go — BROKEN
package domain
import "database/sql"
type Order struct {
ID string
CustomerID string
TotalCents int64
Notes sql.NullString // violation
CreatedAt sql.NullTime // violation
}
sql.NullString and sql.NullTime are database-driver concerns. They exist because SQL has NULL and Go does not have nullable types by default. But your domain should not care about SQL's type system.
The domain version uses pointers or custom types:
// domain/order.go — FIXED
package domain
import "time"
type Order struct {
ID string
CustomerID string
TotalCents int64
Notes *string
CreatedAt *time.Time
}
The translation between *string and sql.NullString happens in the adapter, where it belongs:
// adapter/postgres/order.go
func (r *OrderRepo) toRow(o domain.Order) orderRow {
row := orderRow{
ID: o.ID,
CustomerID: o.CustomerID,
TotalCents: o.TotalCents,
}
if o.Notes != nil {
row.Notes = sql.NullString{
String: *o.Notes,
Valid: true,
}
}
return row
}
More code in the adapter. Zero infrastructure in the domain. That is the trade-off.
Violation 2: encoding/json Tags on Domain Types
// domain/order.go — BROKEN
package domain
type Order struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
TotalCents int64 `json:"total_cents"`
}
This compiles without importing encoding/json — struct tags are just strings. But it is still a violation of intent. Your domain type now dictates how it looks over the wire. If your API needs customerId (camelCase) but your domain uses CustomerID (Go convention), you either fight the tag or give up.
The fix: a separate DTO in the HTTP adapter.
// adapter/http/dto.go
type CreateOrderResponse struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
TotalCents int64 `json:"total_cents"`
}
func toResponse(o domain.Order) CreateOrderResponse {
return CreateOrderResponse{
ID: o.ID,
CustomerID: o.CustomerID,
TotalCents: o.TotalCents,
}
}
// domain/order.go — FIXED
package domain
type Order struct {
ID string
CustomerID string
TotalCents int64
}
Clean. The domain struct has no opinion about JSON, gRPC protobuf, or CSV export. Each adapter maps to and from the domain type using its own representation.
Violation 3: net/http in a Service Method
// domain/service.go — BROKEN
package domain
import "net/http"
type OrderService struct {
repo OrderRepository
}
func (s *OrderService) CreateOrder(
r *http.Request,
) (Order, error) {
customerID := r.URL.Query().Get("customer_id")
// ...
}
The service method takes an *http.Request. You cannot call it from a CLI tool, a gRPC handler, or a Kafka consumer without constructing a fake HTTP request. The domain is handcuffed to one transport.
The fix: the service method takes domain values, not transport objects.
// domain/service.go — FIXED
package domain
import (
"context"
"fmt"
)
type OrderService struct {
repo OrderRepository
}
The method signature takes plain Go types, not transport objects:
func (s *OrderService) CreateOrder(
ctx context.Context,
customerID string,
totalCents int64,
) (Order, error) {
if customerID == "" {
return Order{}, fmt.Errorf(
"customer ID is required",
)
}
order := Order{
ID: generateID(),
CustomerID: customerID,
TotalCents: totalCents,
}
if err := s.repo.Save(ctx, order); err != nil {
return Order{}, fmt.Errorf(
"saving order: %w", err,
)
}
return order, nil
}
The HTTP adapter is responsible for extracting values from the request and calling the service with plain Go types:
// adapter/http/handler.go
func (h *Handler) CreateOrder() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
customerID := r.URL.Query().Get("customer_id")
totalStr := r.URL.Query().Get("total_cents")
total, err := strconv.ParseInt(totalStr, 10, 64)
if err != nil {
http.Error(
w,
"invalid total_cents",
http.StatusBadRequest,
)
return
}
The handler extracts values and validates, then delegates to the domain service:
order, err := h.svc.CreateOrder(
r.Context(), customerID, total,
)
if err != nil {
http.Error(
w,
err.Error(),
http.StatusInternalServerError,
)
return
}
w.Header().Set(
"Content-Type", "application/json",
)
json.NewEncoder(w).Encode(toResponse(order))
}
}
Now the domain is callable from anywhere. Adding a gRPC adapter means writing a new handler that calls the same CreateOrder method. No duplication of business logic.
Prove It: Audit Your Import Graph
You do not need to trust your eyes. Go tooling can tell you exactly what your domain depends on.
Before the refactor:
$ go list -f '{{join .Imports "\n"}}' \
./internal/domain/...
context
database/sql
encoding/json
fmt
net/http
Five imports. Three are infrastructure. The domain depends on the database, the wire format, and the transport layer.
After the refactor:
$ go list -f '{{join .Imports "\n"}}' \
./internal/domain/...
context
fmt
time
Three imports. All standard library. No infrastructure. The domain is portable.
You can automate this check in CI:
#!/bin/bash
# scripts/check-domain-imports.sh
VIOLATIONS=$(go list \
-f '{{join .Imports "\n"}}' \
./internal/domain/... \
| grep -E "database/sql|net/http|encoding/json")
if [ -n "$VIOLATIONS" ]; then
echo "Domain import violations found:"
echo "$VIOLATIONS"
exit 1
fi
echo "Domain imports are clean."
Add that to your CI pipeline. A single grep catches violations before they reach main.
The Full Before/After
Here is the project layout after the refactor:
internal/
├── domain/
│ ├── order.go # struct + business methods
│ ├── service.go # OrderService
│ └── repository.go # OrderRepository interface
├── adapter/
│ ├── postgres/
│ │ ├── order.go # implements OrderRepository
│ │ └── row.go # SQL row <-> domain mapping
│ └── http/
│ ├── handler.go # HTTP handlers
│ └── dto.go # JSON request/response types
└── cmd/
└── server/
└── main.go # composition root
Domain package: zero infrastructure imports. Defines interfaces (ports) that adapters implement. Contains business rules, validation, and domain types.
Adapter packages: import the domain. Contain all infrastructure-specific code. The postgres adapter owns sql.NullString. The HTTP adapter owns json:"..." tags. Each adapter translates between its infrastructure format and the domain types.
main.go: the composition root. Imports everything, creates adapters, injects them into services, starts the server. This is the one place where all layers meet.
// cmd/server/main.go
func main() {
db := connectDB()
defer db.Close()
repo := postgres.NewOrderRepository(db)
svc := domain.NewOrderService(repo)
handler := httphandler.New(svc)
mux := http.NewServeMux()
mux.HandleFunc(
"POST /orders", handler.CreateOrder(),
)
log.Fatal(http.ListenAndServe(":8080", mux))
}
main() is allowed to import everything. That is its entire purpose: to be the one file that knows about all layers so that no other file has to.
What You Gain
Tests that run in microseconds. The domain has no infrastructure dependencies. You test it with in-memory stubs that satisfy the repository interface. No Docker, no test database, no network.
func TestCreateOrder(t *testing.T) {
repo := &memRepo{orders: map[string]domain.Order{}}
svc := domain.NewOrderService(repo)
order, err := svc.CreateOrder(
context.Background(), "cust-1", 5000,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if order.CustomerID != "cust-1" {
t.Fatalf(
"got customer %s, want cust-1",
order.CustomerID,
)
}
}
Swappable infrastructure. Move from PostgreSQL to DynamoDB by writing a new adapter and changing one line in main(). The domain, the HTTP handlers, and the tests all stay the same.
Multiple entry points. The same OrderService can be called from an HTTP handler, a gRPC server, a CLI tool, and a Kafka consumer. Each gets its own adapter. None duplicates business logic.
Readable architecture. A new developer opens main() and sees the wiring. They open the domain and see the business rules. They open an adapter and see the infrastructure glue. No guessing.
The Cost
More files. More types. More mapping functions.
A service with three domain types and two adapters will have at least six mapping functions (domain-to-row, row-to-domain, domain-to-response, request-to-domain, and so on). That is real boilerplate.
But the alternative is a domain package that cannot be tested without a running database, cannot be reused across transports, and cannot be understood without tracing through SQL queries mixed into business logic.
The mapping functions are boring. Boring is underrated. Every line in a mapping function does exactly one thing: move a value from one struct to another. When something breaks, you know where to look. When something changes, you know what to update.
The One-Minute Audit
Open a terminal. Run this:
go list -f '{{join .Imports "\n"}}' \
./internal/domain/... \
| sort
If you see database/sql, net/http, encoding/json, or any third-party ORM package in the output, your architecture has a leak. The boundary exists in your folder structure but not in your dependency graph.
Fix the imports. The architecture follows.
If this was useful
This post covers the most common pattern in Go codebases that claim to use hexagonal architecture but break the dependency rule in practice. The full treatment — including how to handle transactions across adapters (Unit of Work), error translation at boundaries, and testing strategies for each layer — is in Hexagonal Architecture in Go.
If you are working on Go projects with Claude Code or similar tools, Hermes IDE is built for that workflow.

Top comments (0)