45.5 Hexagonal Architecture: Ports and Adapters
Right, let’s talk about Hexagonal Architecture. Forget the fancy name for a second; at its core, it’s a shockingly sane idea: your business logic shouldn’t give a damn about your database, your web framework, or whether your HTTP requests come in via carrier pigeon. It’s about drawing a hard, enforceable line between the what of your application (the domain logic, the juicy bits that make you money) and the how (the boring, often-changing plumbing of databases, APIs, and UIs).
Think of your application as a fortress. The valuable treasure—your core logic—is in the keep. The hexagon is its walls. Nothing gets in or out without going through a very specific, heavily guarded gate. Those gates are your Ports and Adapters.
What Are Ports and Adapters, Really?
A Port is an interface. Full stop. It’s a contract, defined by your core application, that says, “If you want to give me data or ask me to do something, this is the exact shape your request must take.” It’s an incoming port. Or, it says, “If you need me to save something, I will call this method with this signature, and you’d better be ready to handle it.” That’s an outgoing port.
An Adapter is the grunt work that implements the port for a specific technology. The port says “save a user.” The PostgreSQL adapter does the INSERT INTO users.... The MongoDB adapter does the collection.InsertOne(). Your core logic calls the port interface and sleeps soundly, unaware of the SQL or NoSQL nightmare happening on the other side.
The Nuts and Bolts: A Go Example
Let’s model a simple user service. Our core treasure is the ability to create and fetch users. First, we define our domain entity. This lives smack in the center of the hexagon, utterly devoid of any outside concerns.
// domain/user.go
package domain
type User struct {
ID string
Name string
Email string
}
Now, our core logic needs to persist and retrieve these users. But it won’t do the persisting itself. That’s an outgoing dependency. So we define a port—an interface—for it.
// ports/user_repository.go
package ports
import "github.com/yourproject/domain"
// UserRepository is an OUTGOING port.
// Our core logic *drives* this. It says, "I need to save stuff, here's how you must comply."
type UserRepository interface {
Store(user domain.User) error
FindByID(id string) (*domain.User, error)
}
Next, let’s create the actual core service that contains our business logic. This service depends on the port.
// core/user_service.go
package core
import (
"errors"
"github.com/yourproject/domain"
"github.com/yourproject/ports"
)
type UserService struct {
repo ports.UserRepository // Dependency on the port, not a concrete implementation
}
func NewUserService(repo ports.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) RegisterUser(name, email string) (*domain.User, error) {
// Look! Business logic! Validation!
if name == "" {
return nil, errors.New("name cannot be empty")
}
// ... more validation
user := domain.User{
ID: generateID(), // some internal logic
Name: name,
Email: email,
}
err := s.repo.Store(user) // Calls the port. It doesn't know if it's saving to Postgres or a CSV file.
if err != nil {
return nil, err
}
return &user, nil
}
Now, for the adapter. Let’s make one for PostgreSQL. This lives outside the core, in an adapters folder. It implements the port’s interface.
// adapters/postgres/user_repository.go
package postgres
import (
"database/sql"
"github.com/yourproject/domain"
"github.com/yourproject/ports"
_ "github.com/lib/pq" // SQL driver
)
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) ports.UserRepository { // Returns the interface type!
return &UserRepository{db: db}
}
func (r *UserRepository) Store(user domain.User) error {
// This is the ugly, tech-specific implementation.
query := `INSERT INTO users (id, name, email) VALUES ($1, $2, $3)`
_, err := r.db.Exec(query, user.ID, user.Name, user.Email)
return err
}
func (r *UserRepository) FindByID(id string) (*domain.User, error) {
// ... SQL query logic here ...
row := r.db.QueryRow("SELECT id, name, email FROM users WHERE id = $1", id)
var user domain.User
err := row.Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, err
}
return &user, nil
}
Finally, we wire it all together in main.go, which acts as the ultimate puppeteer, injecting the concrete adapter into the core.
// cmd/main.go
package main
import (
"database/sql"
"log"
"github.com/yourproject/adapters/postgres"
"github.com/yourproject/core"
)
func main() {
// Wiring: The only place where the concrete implementation is mentioned.
db, _ := sql.Open("postgres", "connection-string")
pgRepo := postgres.NewUserRepository(db) // This is a ports.UserRepository
userService := core.NewUserService(pgRepo) // Inject the adapter
// Now use the service. The core is blissfully unaware it's talking to PostgreSQL.
user, err := userService.RegisterUser("Alice", "alice@example.com")
if err != nil {
log.Fatal(err)
}
log.Printf("User created: %s\n", user.ID)
}
The Glaringly Obvious Benefit: Testability
See that UserService? To test it, you can mock the UserRepository port with a fake implementation that just stores users in a map in memory. You can test every single business rule—the validation, the error handling—without ever setting up a database. Your unit tests become blisteringly fast and completely isolated. This isn’t a minor perk; it’s the entire point. You’re testing logic, not infrastructure.
The Rough Edges and Pitfalls
Don’t get me wrong, it’s not all rainbows. This adds indirection. You now have more files, more interfaces, and more dependency injection to manage. For a tiny, simple CRUD app that will never change, it’s absolute overkill. It’s a power tool. You use it when the complexity of your application justifies the upfront cost of structuring it this way.
The biggest pitfall is getting the direction of dependencies wrong. The adapters must depend on the core’s ports, never the other way around. If your domain.User struct suddenly imports a pq package, you’ve set the entire hexagon on fire and lost the plot.
Another common mess is letting “real-world” concerns leak back through the port. Your port interface should be in terms of your domain (User), not in terms of the adapter’s world (sql.DB, http.Request). If you feel the urge to pass a *sql.Tx (a database transaction) through your core service, stop. That’s the infrastructure trying to invade the keep. Instead, design a port like WithTransaction(ctx context.Context, fn func(repo UserRepository) error) error that lets the core request a transactional context without knowing what a transaction is.
It forces you to think deeply about your domain first, which is the hardest and most valuable part of software design. The database truly does become a detail.