Go Interfaces Are Ports: The Language Feature That Makes Clean Architecture Free

go dev.to

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

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

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

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

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

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

No database. No Docker. No setup. Just Go structs and method calls.

The Rule

  1. Define interfaces in the consumer package (the domain)
  2. Keep them small (1-3 methods)
  3. Compose when needed via embedding
  4. 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.

Source: dev.to

arrow_back Back to Tutorials