- 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
A team I worked with last quarter ran a one-line audit on their
backend repo:
git grep -nE 'interface\{\}|\bany\b' -- '*.go' | wc -l
The number came back at 213. A backend that had grown from a Go
1.16 codebase, through the 1.18 generics release, into the
any alias era, with nobody ever going back to clean up. Some
of those 213 hits were honest: JSON unmarshal targets,
fmt.Sprintf arguments, a logger that took variadic context
fields. Most were not.
You probably have the same audit waiting in your repo. The
team grouped the 213 hits into three buckets and replaced almost
all of them with code that the compiler can actually check.
Below: three before/after refactors with line counts, and the
21 cases where any is still the right answer.
Bucket 1: collection containers that should be generic
The single biggest category was containers and helpers built
before generics existed. The shape was always the same. A slice
or a map of interface{}, plus a runtime type assertion at every
read site. The team counted 87 hits in this bucket.
The typical "before" was a small in-memory cache used by
the pricing service:
type Cache struct {
mu sync.RWMutex
items map[string]interface{}
}
func (c *Cache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.items[key]
return v, ok
}
func (c *Cache) Set(key string, val interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = val
}
Eighteen lines of cache. Every caller followed the same ritual:
get the value, type-assert, panic on the wrong shape in tests,
ship it, hope.
raw, ok := priceCache.Get(productID)
if !ok { return Price{}, ErrMiss }
p, ok := raw.(Price) // every. single. read.
if !ok { return Price{}, ErrTypeMismatch }
return p, nil
The "after" is one type parameter and zero assertions:
type Cache[T any] struct {
mu sync.RWMutex
items map[string]T
}
func (c *Cache[T]) Get(key string) (T, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.items[key]
return v, ok
}
func (c *Cache[T]) Set(key string, val T) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = val
}
Same shape, same line count. Caller becomes:
priceCache := NewCache[Price]()
// ...
p, ok := priceCache.Get(productID)
if !ok { return Price{}, ErrMiss }
return p, nil
The compiler now refuses any Set that does not pass a Price.
The runtime assertion is gone. The ErrTypeMismatch branch
deletes itself across 14 call sites. Across the 87 hits in this
bucket, this single substitution removed 312 lines of
defensive type-assertion code and surfaced two latent bugs where
a *Price was being stored in what callers assumed was a
Price cache.
Bucket 2: fake polymorphism over 2-3 known types
The second bucket was 64 hits where interface{} was being
used as a sum type. The shape: a function takes "any
notification payload" or "any audit event," then a type switch
inside the function dispatches to the real handler.
func Audit(ctx context.Context, evt interface{}) error {
switch e := evt.(type) {
case OrderPlaced:
return writeOrderRow(ctx, e)
case OrderCancelled:
return writeCancelRow(ctx, e)
case RefundIssued:
return writeRefundRow(ctx, e)
default:
return fmt.Errorf("unknown event %T", evt)
}
}
The function signature lies. It says "any" but accepts exactly
three types. The compiler cannot help the caller pick a valid
shape. The default branch is a runtime trap that exists
because the type system was bypassed.
The team replaced this with a small consumer-side interface,
defined in the package that uses it (the audit package), not in
the package that declares the events:
type AuditEvent interface {
auditRow() row
}
func (e OrderPlaced) auditRow() row { /* ... */ }
func (e OrderCancelled) auditRow() row { /* ... */ }
func (e RefundIssued) auditRow() row { /* ... */ }
func Audit(ctx context.Context, evt AuditEvent) error {
return write(ctx, evt.auditRow())
}
A few things changed in that shape:
- The interface is unexported (
auditRow, lowercase). Only types in this package can implement it. The set of accepted events is closed at compile time. Adding a fourth event type is a deliberate edit in the audit package, not a silent runtime "unknown event" log line. - The interface is defined where it is consumed, not where
the events are declared. The
domainpackage does not depend on theauditpackage. The audit package writes its own shape, asks domain types to satisfy it. - The function signature is now a contract. A caller that
tries to pass
Login{}gets a compile error, not a 500 with a log line.
This refactor cost about 30 lines of method declarations across
the codebase and deleted 47 lines of type-switch / default-trap
code. The line count dropped, the type safety went up.
The same shape worked for "any payment method," "any feature
flag value source," and "any retry decision." If the type
switch has fewer than five arms and the set is closed at
compile time, the answer is a small named interface.
Bucket 3: reflection-y serialization
The third bucket was 41 hits where interface{} was being
used to dodge the encoding layer. Internal events flowing
through Kafka were marshalled as map[string]interface{} and
fished out with reflection on the consumer side.
func handle(ctx context.Context, raw map[string]interface{}) {
typ, _ := raw["type"].(string)
payload, _ := raw["payload"].(map[string]interface{})
switch typ {
case "user.created":
id, _ := payload["id"].(string)
email, _ := payload["email"].(string)
// ... five more fields ...
createUser(ctx, id, email /* ... */)
case "order.placed":
// ... fifteen more lines of payload[...].(string) ...
}
}
Every field a runtime cast. Every typo a silent zero value.
The producer and the consumer agreed on the wire format only by
convention.
The team replaced this with a typed envelope and explicit
unmarshalling per case:
type Envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
type UserCreated struct {
ID string `json:"id"`
Email string `json:"email"`
}
type OrderPlaced struct {
OrderID string `json:"order_id"`
Total float64 `json:"total"`
}
func handle(ctx context.Context, b []byte) error {
var env Envelope
if err := json.Unmarshal(b, &env); err != nil {
return err
}
switch env.Type {
case "user.created":
var p UserCreated
if err := json.Unmarshal(env.Payload, &p); err != nil {
return err
}
return createUser(ctx, p)
case "order.placed":
var p OrderPlaced
if err := json.Unmarshal(env.Payload, &p); err != nil {
return err
}
return placeOrder(ctx, p)
}
return fmt.Errorf("unknown event %s", env.Type)
}
json.RawMessage is the unsung hero here. It defers
unmarshalling of the inner payload until you know the type,
without any reflection on your side. Each case branch
unmarshals into a real struct with real json tags. A
mistyped field name fails at unmarshalling time with a clear
error, not silently as a zero value 200 lines downstream.
This refactor was the most expensive one. About 180 lines of
struct definitions added, 240 lines of payload[...].(string)
deleted, plus a one-time compatibility layer to read both old
and new envelope shapes during the migration window. The line
count moved a little. The class of bug that used to fire at
deserialization time became impossible, which was the actual
win.
Where any is still the right answer
Of the 213 original hits, 21 stayed. The team kept any in
exactly two shapes:
Genuinely heterogeneous trees. A function that walks a
parsed AST or a generic JSON document, where the node value
really can be string, number, bool, array, or object. Forcing
this through a sum-type interface adds five method
declarations per concrete type and a visitor pattern that
nobody wants to read. any plus a type switch at the leaves
is the right answer.
func walk(v any, fn func(string, any)) {
switch x := v.(type) {
case map[string]any:
for k, vv := range x { fn(k, vv); walk(vv, fn) }
case []any:
for _, vv := range x { walk(vv, fn) }
}
}
Structurally typed unmarshal targets. When you cannot
control the shape of incoming JSON (a third-party webhook
that adds fields you do not care about, a config blob you pass
through to a downstream system), map[string]any as the
target is honest. You are not modelling a domain; you are
ferrying bytes.
The rule the team landed on: when the type is genuinely unknown
to your code, any is fine. When you know the type and just
have not told the compiler yet, it is the wrong answer.
The audit, repeated
The same one-liner six months later returned 24 hits: the 21
keepers above plus three new ones a junior added during a
sprint. Code review caught those three the same week. The
codebase had crossed the line where "see an any, raise an
eyebrow" was the default reaction instead of "another one,
shrug."
Your repo probably has its own 213. Run the audit. Sort the
hits into the three buckets. The first two buckets (generic
containers and fake polymorphism) pay back in days. The third
takes a sprint, and the bug class it removes is the one that
fires at 3 a.m. on a payload variation nobody wrote a test
for.
If this was useful
Most of the refactors above are about putting the type system
where the runtime check used to be: small interfaces defined
on the consumer side, typed structs at the boundary, generics
where the container did not need to know what it was carrying.
Hexagonal Architecture in Go covers exactly this discipline:
which package owns which interface, where the boundary
unmarshals into a domain type, and how to keep the inner
hexagon free of any. The Complete Guide to Go Programming
goes deeper on generics, type sets, and the runtime cost of
the patterns above.

Top comments (0)