How to Implement Hexagonal Architecture (Ports and Adapters) in Go

Implement Hexagonal Architecture in Go by defining domain interfaces and injecting concrete infrastructure implementations at the application boundary.

The dependency trap

You write a function to save a user to a database. It works. You write a test. The test needs a real database connection. You spin up a Docker container, wait for it to start, and run the test. It takes ten seconds. You want to mock the database to make the test instant, but your function takes a *sql.DB pointer. You cannot pass a mock. You rewrite the function to take an interface. You write the mock. The test runs in milliseconds.

Six months later, you need to switch from PostgreSQL to MongoDB. You rewrite the function again. Or worse, you add a flag to toggle between drivers, and the function becomes a tangled mess of if statements.

This is the dependency trap. Your business logic is married to your infrastructure. When the infrastructure changes, the logic breaks. Hexagonal architecture, also known as Ports and Adapters, breaks the marriage. It keeps your core logic pure and lets you swap databases, HTTP clients, or message queues without touching the business rules.

Ports and adapters in plain English

Hexagonal architecture is a design pattern that separates an application into two distinct layers: the domain and the infrastructure. The domain contains the business logic. The infrastructure contains everything else: databases, web servers, external APIs, and file systems.

The "hexagon" is just a metaphor for the core. The core sits in the middle. It defines ports, which are interfaces that describe what the core needs. The outside world provides adapters, which are concrete implementations of those interfaces.

Think of a universal power adapter. Your laptop (the domain) has a specific power input. It doesn't care if the electricity comes from a wall outlet in Japan, a car charger in the US, or a battery pack in the UK. It just needs the correct voltage and current. The power adapter (the infrastructure) converts the external source into something the laptop can use. The laptop defines the port. The adapter implements the connection.

In Go, this pattern is natural because interfaces are implicit. You do not need to declare that a struct implements an interface. You just write the methods. This allows the domain package to define an interface without ever importing the infrastructure package. The dependency flows inward. The infrastructure depends on the domain. The domain never knows the infrastructure exists.

The minimal example

Here is the simplest form of hexagonal architecture. We define a user entity, a repository interface, and a service that uses the interface.

package domain

// User represents a domain entity.
type User struct {
    ID   string
    Name string
}

// UserRepository defines the port for persistence.
// The domain does not know about SQL, NoSQL, or files.
type UserRepository interface {
    Save(user User) error
    FindByID(id string) (User, error)
}

// UserService contains business logic.
// It depends on the port, not the adapter.
type UserService struct {
    repo UserRepository
}

// NewUserService creates the service with a wired repository.
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// CreateUser validates and saves a user.
func (s *UserService) CreateUser(name string) error {
    // Business rule: names cannot be empty.
    if name == "" {
        return fmt.Errorf("name is required")
    }
    
    user := User{ID: generateID(), Name: name}
    return s.repo.Save(user)
}

The UserService only knows about UserRepository. It calls Save. It does not care if Save writes to a database, prints to stdout, or stores data in memory. The convention here is to accept interfaces and return structs. The service accepts the UserRepository interface. It returns a *UserService struct. This keeps the public API stable while allowing internal flexibility.

How the compiler enforces the boundary

Go's type system enforces the hexagonal boundary at compile time. If you try to pass a type that does not satisfy the interface, the compiler rejects the program.

Imagine you write a FakeRepository for testing but forget to implement FindByID. You pass it to NewUserService. The compiler complains with fakeRepository does not implement UserRepository (missing FindByID method). This error happens before the code runs. It forces you to complete the implementation.

This strictness is a feature. It prevents runtime panics where a method is missing. It also documents the contract. If you look at the UserRepository interface, you know exactly what the infrastructure must provide.

The dependency rule is enforced by Go packages. The domain package defines the interface. The infra package imports domain and implements the interface. The domain package never imports infra. If you try to import infra in domain, you create a circular dependency. The compiler rejects this with import cycle not allowed. This error is your friend. It stops you from leaking infrastructure details into the core.

A realistic service with wiring

Real applications need context for cancellation and deadlines. They also need error handling. Here is how a realistic service looks with context.Context and proper error wrapping.

package domain

import "context"

// UserRepository now accepts context.
// Context allows cancellation and timeout propagation.
type UserRepository interface {
    Save(ctx context.Context, user User) error
    FindByID(ctx context.Context, id string) (User, error)
}

type UserService struct {
    repo UserRepository
}

func (s *UserService) CreateUser(ctx context.Context, name string) error {
    if name == "" {
        return fmt.Errorf("name is required")
    }

    user := User{ID: generateID(), Name: name}
    
    // Pass context to the repository.
    // If the request is cancelled, the DB call stops.
    err := s.repo.Save(ctx, user)
    if err != nil {
        // Wrap the error to add domain context.
        return fmt.Errorf("create user: %w", err)
    }
    return nil
}

The infrastructure package implements the interface. Here is a sketch of a PostgreSQL adapter.

package infra

import (
    "context"
    "database/sql"
    "yourapp/domain"
)

type PostgresUserRepository struct {
    db *sql.DB
}

// Save implements domain.UserRepository.
func (r *PostgresUserRepository) Save(ctx context.Context, user domain.User) error {
    // Query the database with context.
    _, err := r.db.ExecContext(ctx, "INSERT INTO users (id, name) VALUES ($1, $2)", user.ID, user.Name)
    return err
}

// FindByID implements domain.UserRepository.
func (r *PostgresUserRepository) FindByID(ctx context.Context, id string) (domain.User, error) {
    var user domain.User
    // Scan the result into the domain struct.
    err := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).Scan(&user.ID, &user.Name)
    return user, err
}

The wiring happens in main.go. This is where the hexagon closes. You create the adapters and inject them into the services.

package main

import (
    "database/sql"
    "yourapp/domain"
    "yourapp/infra"
)

func main() {
    // Open the database connection.
    db, err := sql.Open("postgres", "dsn")
    if err != nil {
        panic(err)
    }
    
    // Create the adapter.
    repo := &infra.PostgresUserRepository{db: db}
    
    // Inject the adapter into the service.
    service := domain.NewUserService(repo)
    
    // Use the service.
    err = service.CreateUser(context.Background(), "Alice")
    if err != nil {
        panic(err)
    }
}

The main package is the only place that knows about both domain and infra. It wires them together. The rest of the application remains decoupled. Trust the wiring. Keep it in one place.

Pitfalls and compiler errors

Hexagonal architecture adds structure. Structure adds complexity. Use it only when the complexity pays off.

The biggest pitfall is interface explosion. You do not need an interface for every struct. If a type has only one implementation and you never plan to swap it, use the concrete type. Defining an interface for a single implementation adds indirection without benefit. The compiler will not stop you, but your future self will thank you for keeping it simple.

Another pitfall is leaking domain entities into the infrastructure. The infra package should import domain types, not the other way around. If your domain package imports infra, you have broken the dependency rule. The compiler catches circular imports, but subtle leaks can happen if you share types across packages. Keep domain types in the domain package.

Common compiler errors include:

  • cannot use repo (type *PostgresUserRepository) as UserRepository value in argument: You passed the wrong type. Check the method signatures.
  • undefined: domain: You forgot to import the domain package.
  • import cycle not allowed: You imported a package that imports you. Refactor the dependency.

Convention aside: receiver names should be short and match the type. Use (r *PostgresUserRepository) not (this *PostgresUserRepository). Go idioms favor brevity in receivers.

When to use hexagonal architecture

Use hexagonal architecture when you have long-lived services with complex business rules and multiple infrastructure dependencies. Use it when you need to swap databases, mock external APIs for testing, or support multiple deployment targets.

Use simple procedural code when you are writing a script, a CLI tool, or a small service with no complex logic. The simplest thing that works is usually the right thing.

Use a standard MVC pattern when you are building a web application with clear views and controllers, and the business logic is thin. Hexagonal architecture shines when the domain is the focus, not the UI.

Use dependency injection frameworks sparingly. Go favors manual wiring in main.go. It is verbose but explicit. You can see exactly what is connected to what.

Hexagonal architecture is a tool, not a religion. Apply it where it solves a problem. Don't apply it to impress interviewers.

Where to go next