The problem with tangled code
You write a service. It works. Then the database schema shifts. Then the client demands a gRPC endpoint instead of REST. You refactor, and suddenly your user validation logic is scattered across SQL queries and HTTP handlers. The core rules are glued to the infrastructure. You need a boundary that keeps the business logic independent of the frameworks that deliver it.
Hexagonal architecture solves this by drawing a hard line around the domain. The core business logic sits in the center. It defines interfaces called ports. External systems like databases, HTTP servers, or message queues connect through adapters that implement those ports. The core never imports net/http or database/sql. It only knows about its own interfaces.
Think of a USB-C hub. The laptop doesn't care if you plug in an HDMI cable, a USB-A drive, or an Ethernet adapter. The hub has ports. The laptop talks to the ports. The adapters handle the messy details of the outside world. The laptop is the domain. The ports are interfaces. The adapters are the concrete code that talks to hardware.
The domain owns the truth. Everything else is just plumbing.
Minimal example: domain, service, adapter
Here's the skeleton. The domain defines an interface. The service uses the interface. An adapter implements the interface. The wiring happens at the edge.
package main
import "fmt"
// User represents the core entity.
type User struct {
ID string
Name string
}
// UserRepository defines how the service finds users.
// The service depends on this interface, not a concrete database.
type UserRepository interface {
GetByID(id string) (*User, error)
}
// UserService contains business logic.
// It accepts the interface in its constructor.
type UserService struct {
repo UserRepository
}
// NewUserService creates a service with the provided repository.
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
// GetUser retrieves a user and applies validation.
func (s *UserService) GetUser(id string) (*User, error) {
if id == "" {
return nil, fmt.Errorf("id cannot be empty")
}
return s.repo.GetByID(id)
}
// InMemoryRepo is a test adapter.
// It implements UserRepository without touching a real database.
type InMemoryRepo struct{}
func (r *InMemoryRepo) GetByID(id string) (*User, error) {
return &User{ID: id, Name: "Test User"}, nil
}
func main() {
// Wire the adapter to the service.
repo := &InMemoryRepo{}
svc := NewUserService(repo)
u, err := svc.GetUser("123")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(u.Name)
}
The compiler enforces the contract. If InMemoryRepo misses the GetByID method, the assignment to repo fails. You get cannot use &InMemoryRepo{} (value of type *InMemoryRepo) as UserRepository value in argument: *InMemoryRepo does not implement UserRepository (missing GetByID method). This catches interface mismatches at compile time, not in production.
Go interfaces are implicit. You don't declare that a struct implements an interface. The compiler checks the method set. This means you can define UserRepository in the domain package, or you can define it in the service package. The convention is to define the interface where it is used, not where it is implemented. This keeps the domain package clean and prevents circular dependencies.
Accept interfaces, return structs. Keep the core pure.
Realistic example: HTTP, Postgres, and context
Real services need context for cancellation and deadlines. They also need to handle errors explicitly. Here's how the pieces fit together in a production-like setup.
The domain package defines the entity and the port. Methods take context.Context as the first parameter. This is a Go convention. Context flows from the handler down to the infrastructure.
package domain
import "context"
// User is the domain entity.
type User struct {
ID string
Name string
}
// UserRepository is the port for data access.
// Methods take context to support cancellation.
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
}
// UserService holds the business rules.
type UserService struct {
repo UserRepository
}
// NewUserService injects the repository dependency.
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
// GetUser fetches a user and validates the result.
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
if id == "" {
return nil, fmt.Errorf("invalid id")
}
return s.repo.GetByID(ctx, id)
}
The infrastructure package implements the port. It imports the domain but never the other way around. The receiver name is short, matching the type. r for PostgresUserRepo.
package infrastructure
import (
"context"
"database/sql"
"your/module/domain"
)
// PostgresUserRepo implements domain.UserRepository.
// It handles SQL queries and row scanning.
type PostgresUserRepo struct {
db *sql.DB
}
// GetByID queries the database for a user.
// It respects context cancellation via QueryRowContext.
func (r *PostgresUserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
var u domain.User
err := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name)
if err != nil {
return nil, err
}
return &u, nil
}
The main package is the composition root. It wires the concrete adapters to the services. It's the only place that knows about all the concrete types. Trust gofmt here. The wiring code should be flat and readable.
package main
import (
"database/sql"
"net/http"
"your/module/domain"
"your/module/infrastructure"
)
func main() {
db := mustOpenDB()
// Create the concrete adapter.
repo := &infrastructure.PostgresUserRepo{db: db}
// Inject the adapter into the service.
svc := domain.NewUserService(repo)
// Handler uses the service, not the repo.
http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
u, err := svc.GetUser(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Respond with user data
})
http.ListenAndServe(":8080", nil)
}
// mustOpenDB opens the database connection or panics.
// This is a helper for the example; real code returns errors.
func mustOpenDB() *sql.DB {
db, err := sql.Open("postgres", "dsn")
if err != nil {
panic(err)
}
return db
}
The if err != nil boilerplate is verbose by design. The community accepts it because it makes the unhappy path visible. Every error is handled explicitly. There are no hidden exceptions.
Wire at the edge. The center should never import the periphery.
Pitfalls and compiler errors
Hexagonal architecture adds structure. Structure adds complexity. Watch for these traps.
If you forget to pass the context through the call chain, the adapter can't cancel long-running queries. The compiler won't stop you. You'll get slow requests and resource leaks. Always thread ctx through every function that does I/O.
If the adapter spawns a goroutine that waits on a channel, and the context doesn't signal cancellation, you have a goroutine leak. The goroutine waits forever. The worst goroutine bug is the one that never logs. Always have a cancellation path. Use select with ctx.Done().
If you define the interface in the infrastructure package instead of the domain, you create a dependency cycle. The domain can't import the infrastructure, and the infrastructure can't import the domain if the interface lives there. The compiler rejects this with import cycle not allowed. Define interfaces in the package that uses them.
If you return an interface from the service, you leak implementation details. The caller might type-assert to a concrete type. Return structs. The caller only needs the data, not the behavior.
If you inject dependencies via global variables, you lose testability. You can't swap the adapter in tests. Use constructor injection. Pass dependencies explicitly.
Complexity is a tax. Pay it only when the domain demands it.
When to use hexagonal architecture
Use hexagonal architecture when your business logic is complex and needs to survive changes in databases, APIs, or frameworks.
Use a simple layered structure when the app is small and the domain rules are trivial: CRUD operations don't need a hexagon.
Use dependency injection when you need to swap implementations for testing or configuration without changing the core code.
Use plain functions when the logic is stateless and doesn't require a service object.
Use hexagonal architecture when you have multiple adapters for the same port, like a primary database and a cache, or a REST API and a CLI.
Keep the domain pure. Let the adapters handle the mess.