DEV Community

Cover image for Go's Implicit Interfaces Are Its Best Architecture Feature
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go's Implicit Interfaces Are Its Best Architecture Feature


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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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},
    )
}
Enter fullscreen mode Exit fullscreen mode

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},
    )
}
Enter fullscreen mode Exit fullscreen mode

Swapping them is a one-line change in main.go:

// Before
pub := &rabbitmq.Publisher{Ch: ch}

// After
pub := &kafka.Publisher{Writer: w}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)