The biggest insight that changed how I structure Go services:
Define interfaces where you USE them, not where you implement them.
This one idea — consumer-defined interfaces — is what makes hexagonal architecture feel native in Go instead of bolted on.
The Java Way vs The Go Way
In Java, the class that implements an interface must explicitly declare it:
public class PostgresOrderRepository implements OrderRepository {
// Must import OrderRepository. Must name it. Coupled at the declaration.
}
In Go, a type satisfies an interface just by having the right methods. No keyword. No import.
// Domain package defines what it needs
type OrderRepository interface {
Save(ctx context.Context, order Order) error
FindByID(ctx context.Context, id string) (Order, error)
}
// Adapter package — doesn't even need to know the interface exists
type PostgresOrderRepository struct {
db *sql.DB
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order Order) error {
// SQL INSERT
return nil
}
func (r *PostgresOrderRepository) FindByID(ctx context.Context, id string) (Order, error) {
// SQL SELECT
return Order{}, nil
}
PostgresOrderRepository never mentions OrderRepository. The dependency arrow points inward — from infrastructure to domain — exactly as hexagonal architecture demands.
Small Interfaces Win
The Go proverb: "The bigger the interface, the weaker the abstraction."
Don't do this:
// 12 methods. Every test double implements all 12.
type OrderRepository interface {
Save(ctx context.Context, order Order) error
FindByID(ctx context.Context, id string) (Order, error)
FindByCustomer(ctx context.Context, customerID string) ([]Order, error)
List(ctx context.Context, limit, offset int) ([]Order, error)
Count(ctx context.Context) (int, error)
Delete(ctx context.Context, id string) error
// ... six more methods nobody uses together
}
Do this:
type OrderSaver interface {
Save(ctx context.Context, order Order) error
}
type OrderFinder interface {
FindByID(ctx context.Context, id string) (Order, error)
}
// Compose when a service genuinely needs both
type OrderRepository interface {
OrderSaver
OrderFinder
}
Now a service that only writes depends on OrderSaver. Test double: 5 lines. A service that only reads depends on OrderFinder. Test double: 5 lines.
The Testing Payoff
With small, consumer-defined interfaces, test doubles are trivial:
type inMemoryRepo struct {
orders map[string]Order
}
func (r *inMemoryRepo) Save(_ context.Context, order Order) error {
r.orders[order.ID] = order
return nil
}
func (r *inMemoryRepo) FindByID(_ context.Context, id string) (Order, error) {
order, ok := r.orders[id]
if !ok {
return Order{}, ErrOrderNotFound
}
return order, nil
}
No mocking framework. No code generation. Your domain tests run in microseconds.
func TestPlaceOrder(t *testing.T) {
repo := &inMemoryRepo{orders: make(map[string]Order)}
svc := NewOrderService(repo)
order, err := svc.PlaceOrder(ctx, req)
// assert...
// Verify it was saved
saved, _ := repo.FindByID(ctx, order.ID)
// assert...
}
No database. No Docker. No setup. Just Go structs and method calls.
The Rule
- Define interfaces in the consumer package (the domain)
- Keep them small (1-3 methods)
- Compose when needed via embedding
- Let Go's implicit satisfaction handle the wiring
That's it. No framework needed. The language does the work.
📖 This is Chapter 5 of my book Hexagonal Architecture in Go — which goes deep into port design, naming conventions, when to split vs compose, and the IDGenerator pattern for deterministic testing.
Part 2 of 5 in the Hexagonal Architecture in Go series. Next: testing your hexagonal service at every layer — domain, adapters, and integration.