- 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 hear it every time someone pitches Go. "Goroutines are amazing." "Channels change the way you think about concurrency." "The scheduler is so lightweight you can spin up 100k goroutines on a laptop."
All true. None of it is what makes Go special for building well-structured software.
The feature that actually changes how you design systems is implicit interface satisfaction. It is the reason hexagonal architecture in Go feels like a natural extension of the language rather than a pattern you bolt on with a framework and a prayer.
What Implicit Satisfaction Actually Means
In Java, you declare your allegiance. A class that implements an interface says so explicitly:
public class PostgresOrderRepo
implements OrderRepository {
@Override
public Order findById(String id) {
// SQL query
}
@Override
public void save(Order order) {
// SQL insert
}
}
The implementation imports and names the interface. It is coupled to the interface at the source level. If OrderRepository lives in a different module, you need that module on the classpath at compile time.
In Go, there is no implements keyword. A type satisfies an interface by having the right methods. That is it.
// In your domain package
type OrderRepository interface {
FindByID(
ctx context.Context,
id string,
) (Order, error)
Save(
ctx context.Context,
order Order,
) error
}
// In your infrastructure package — a completely
// separate directory, separate import path
type PostgresOrderRepo struct {
db *sql.DB
}
func (r *PostgresOrderRepo) FindByID(
ctx context.Context,
id string,
) (Order, error) {
// actual SQL query
return Order{}, nil
}
func (r *PostgresOrderRepo) Save(
ctx context.Context,
order Order,
) error {
// actual SQL insert
return nil
}
PostgresOrderRepo never mentions OrderRepository. It imports the domain types it persists but has no dependency on the port interface. The compiler will accept it anywhere an OrderRepository is expected without the adapter ever declaring that relationship.
This is not a minor syntactic convenience. It inverts the direction of the dependency.
Why This Matters for Hexagonal Architecture
Hexagonal architecture (ports and adapters) has one core rule: dependencies point inward. Your domain logic sits at the center. Infrastructure (databases, message queues, HTTP handlers) lives at the edges. The domain defines what it needs through ports (interfaces). Adapters implement those ports.
In Java, implementing this rule takes work. The adapter must import the port interface. That import creates a compile-time dependency from the outer ring to the inner ring, which is what you want. But it also means the adapter has to explicitly declare implements OrderRepository. Rename the interface and you rename every adapter; move it to a different package and every import updates. Add a method and every adapter fails to compile until you add the stub.
This is manageable for a single service. Scale it to a codebase with 20 ports and 40 adapters, and you start reaching for code generation, DI containers like Spring or Guice, and annotation processors.
In Go, none of that ceremony exists.
Your domain package defines the port:
package order
type Repository interface {
FindByID(
ctx context.Context,
id string,
) (Order, error)
Save(ctx context.Context, o Order) error
}
type Service struct {
repo Repository
}
func NewService(r Repository) *Service {
return &Service{repo: r}
}
func (s *Service) Place(
ctx context.Context,
items []Item,
) (Order, error) {
o := NewOrder(items)
if err := s.repo.Save(ctx, o); err != nil {
return Order{}, fmt.Errorf(
"saving order: %w", err,
)
}
return o, nil
}
Your Postgres adapter sits in a separate package. It imports order for the domain types it persists, but it never references order.Repository. The interface does not appear in the adapter's source at all:
package postgres
type OrderRepo struct {
db *sql.DB
}
func (r *OrderRepo) FindByID(
ctx context.Context,
id string,
) (order.Order, error) {
row := r.db.QueryRowContext(
ctx,
`SELECT id, status, total
FROM orders WHERE id = $1`,
id,
)
var o order.Order
err := row.Scan(&o.ID, &o.Status, &o.Total)
if err != nil {
return order.Order{}, fmt.Errorf(
"finding order %s: %w", id, err,
)
}
return o, nil
}
The Save method follows the same pattern. It works with order.Order values but has zero knowledge of the port interface:
func (r *OrderRepo) Save(
ctx context.Context,
o order.Order,
) error {
_, err := r.db.ExecContext(
ctx,
`INSERT INTO orders (id, status, total)
VALUES ($1, $2, $3)`,
o.ID, o.Status, o.Total,
)
if err != nil {
return fmt.Errorf(
"saving order %s: %w", o.ID, err,
)
}
return nil
}
The wiring happens in main, the only place that knows about both packages:
func main() {
db := connectDB()
repo := &postgres.OrderRepo{DB: db}
svc := order.NewService(repo)
// ... start HTTP server, pass svc to handlers
}
No DI container. No annotation scanning. No wire or fx unless you want them. The compiler does the verification for free. If postgres.OrderRepo is missing a method, the assignment order.NewService(repo) fails at compile time with a clear message.
Consumer-Side Interface Ownership
Implicit satisfaction enables something that explicit implements makes awkward: the consumer defines the interface, not the provider.
This is a well-known Go proverb ("accept interfaces, return structs"), but the architectural consequence is underappreciated. When the consumer owns the interface, it declares only the methods it needs.
Say your order service needs to look up users:
package order
type UserLookup interface {
ByID(
ctx context.Context,
id string,
) (User, error)
}
type Service struct {
repo Repository
users UserLookup
}
The user package might have a struct with 15 methods — Create, Delete, ListByOrg, ResetPassword, VerifyEmail. The order service does not care. It defines a one-method interface. The user struct satisfies it without knowing.
In Java, you would need either a separate UserLookup interface in the user package (which means coordinating two packages) or a shared interface module (which means a third package). In Go, the consumer writes what it needs and moves on.
This has a concrete payoff for testing. Your test double for UserLookup is four lines:
type stubUsers struct {
user order.User
err error
}
func (s *stubUsers) ByID(
_ context.Context,
_ string,
) (order.User, error) {
return s.user, s.err
}
You skip the mocking framework entirely. No codegen. No 15-method stub where 14 methods panic with "not implemented."
The Adapter Pattern Without a Framework
Consider what happens when you need to swap your messaging backend from RabbitMQ to Kafka. In a Java codebase with Spring, you are likely changing bean configurations, updating dependency injection bindings, maybe writing an adapter class with @Component and @Qualifier annotations.
In Go, your domain defines what it needs:
package notification
type Publisher interface {
Publish(
ctx context.Context,
event Event,
) error
}
Your RabbitMQ adapter:
package rabbitmq
type Publisher struct {
ch *amqp.Channel
}
func (p *Publisher) Publish(
ctx context.Context,
event notification.Event,
) error {
body, err := json.Marshal(event)
if err != nil {
return fmt.Errorf(
"marshaling event: %w", err,
)
}
return p.ch.PublishWithContext(
ctx, "events", "", false, false,
amqp.Publishing{Body: body},
)
}
Your Kafka adapter:
package kafka
type Publisher struct {
writer *kafka.Writer
}
func (p *Publisher) Publish(
ctx context.Context,
event notification.Event,
) error {
body, err := json.Marshal(event)
if err != nil {
return fmt.Errorf(
"marshaling event: %w", err,
)
}
return p.writer.WriteMessages(
ctx,
kafka.Message{Value: body},
)
}
Swapping them is a one-line change in main.go:
// Before
pub := &rabbitmq.Publisher{Ch: ch}
// After
pub := &kafka.Publisher{Writer: w}
Both satisfy notification.Publisher. Neither imports the other. Neither knows the other exists. The domain package has zero awareness of which message broker is running in production. That is the adapter pattern as described in every architecture textbook, except here you did not need a single framework to make it work.
Compile-Time Port Verification
A common worry with implicit interfaces: "How do I know my adapter actually satisfies the port? There is no explicit declaration."
The compiler tells you. But if you want a belt-and-suspenders check at the declaration site, Go has a well-established idiom:
package postgres
// Compile-time proof that OrderRepo
// satisfies order.Repository.
var _ order.Repository = (*OrderRepo)(nil)
This line allocates nothing at runtime. It is a zero-cost assertion that the compiler evaluates during build. If OrderRepo drifts out of sync with order.Repository (a renamed method, a changed signature), the build breaks immediately with a message that names the missing method.
Some teams add these assertions to every adapter file by convention. Others skip them and rely on the wiring in main to catch mismatches. Either approach works. The point is that implicit does not mean unchecked.
What About Large Interfaces?
A question that comes up often: "My port has 8 methods. Isn't that unwieldy with implicit satisfaction?"
Yes. But that is not a problem with implicit interfaces — it is a signal that your port is too wide.
The Go proverb applies: "The bigger the interface, the weaker the abstraction." If your OrderRepository has 8 methods, ask which callers need all 8. Usually, one service needs Save and FindByID. Another needs ListByCustomer. A third needs Delete.
Split the port:
type OrderWriter interface {
Save(ctx context.Context, o Order) error
}
type OrderReader interface {
FindByID(
ctx context.Context,
id string,
) (Order, error)
ListByCustomer(
ctx context.Context,
customerID string,
) ([]Order, error)
}
type OrderDeleter interface {
Delete(
ctx context.Context,
id string,
) error
}
Each service depends on the narrowest interface it needs. Your Postgres adapter still implements all the methods — it satisfies all three interfaces without declaring any of them.
Compose when a service genuinely needs the full set:
type OrderStore interface {
OrderWriter
OrderReader
OrderDeleter
}
Interface embedding gives you union types for free. And because satisfaction is implicit, the adapter does not change at all when you split or compose interfaces on the consumer side.
The Comparison, Summarized
| Concern | Java (explicit) | Go (implicit) |
|---|---|---|
| Adapter imports interface | Yes — compile-time coupling | No — zero-import satisfaction |
| Interface ownership | Provider-side (convention) | Consumer-side (idiomatic) |
| Adding a new adapter | New class + implements + DI binding |
New struct + matching methods |
| Swapping adapters | DI config change + possible annotations | One-line change in main
|
| Splitting an interface | Every adapter updates its implements clause |
Adapters untouched — they already have the methods |
| Verification | Explicit at declaration | Implicit at use-site (or var _ assertion) |
| Framework required for DI | Practically yes (Spring, Guice, Dagger) | No — constructor injection in main
|
Where the Conversation Gets Real
Goroutines solve a runtime problem: how to handle concurrent work cheaply. Implicit interfaces solve a different problem, one about design time. They let you structure a codebase so that changing one part does not cascade into 15 others. They are the reason you can practice hexagonal architecture in Go with nothing but the standard toolchain. No container, no framework, no build plugin.
The next time someone asks you what makes Go different, try this: it is one of very few statically typed languages where you can define a contract in one package and fulfill it in another without either package knowing about the other. That is not a party trick. That is the foundation of clean architecture in Go.
If you want to go deeper
This post scratched the surface. The full treatment covers the ground you would expect: port design conventions, interface splitting vs. composition, the IDGenerator pattern for deterministic testing, and production adapter patterns for databases, queues, and HTTP clients. It is all in my book Hexagonal Architecture in Go. If you are building Go services and want an architecture that scales with your team instead of against it, that is where to start.
The companion book, The Complete Guide to Go Programming, handles the language-level foundations that make all of this work: the type system, the module system, error handling patterns, and the concurrency model you hear so much about.

Top comments (0)