The Hexagonal Architecture, often called Ports and Adapters, is a way to build applications where the core business logic is completely isolated from the outside world.
Let’s see it in action with a simple example: a service that manages user accounts.
// --- Core Business Logic ---
// User represents a user account.
type User struct {
ID string
Username string
Email string
}
// UserRepository defines the interface for data persistence.
type UserRepository interface {
Save(user User) error
FindByID(id string) (*User, error)
}
// UserService handles user-related business operations.
type UserService struct {
repo UserRepository
}
// NewUserService creates a new UserService.
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
// CreateUser registers a new user.
func (s *UserService) CreateUser(username, email string) (*User, error) {
// Simulate generating a unique ID
userID := "user-" + username
user := User{ID: userID, Username: username, Email: email}
err := s.repo.Save(user)
if err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
}
return &user, nil
}
// --- Adapters ---
// UserPersistenceAdapter implements the UserRepository interface for PostgreSQL.
type UserPersistenceAdapter struct {
db *sql.DB
}
// NewUserPersistenceAdapter creates a new UserPersistenceAdapter.
func NewUserPersistenceAdapter(db *sql.DB) *UserPersistenceAdapter {
return &UserPersistenceAdapter{db: db}
}
// Save persists a user to the database.
func (a *UserPersistenceAdapter) Save(user User) error {
_, err := a.db.Exec("INSERT INTO users (id, username, email) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email", user.ID, user.Username, user.Email)
return err
}
// FindByID retrieves a user from the database.
func (a *UserPersistenceAdapter) FindByID(id string) (*User, error) {
row := a.db.QueryRow("SELECT id, username, email FROM users WHERE id = $1", id)
var user User
err := row.Scan(&user.ID, &user.Username, &user.Email)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // User not found
}
return nil, fmt.Errorf("failed to query user: %w", err)
}
return &user, nil
}
// UserAPIAdapter implements a REST API endpoint for user management.
type UserAPIAdapter struct {
userService *UserService
}
// NewUserAPIAdapter creates a new UserAPIAdapter.
func NewUserAPIAdapter(userService *UserService) *UserAPIAdapter {
return &UserAPIAdapter{userService: userService}
}
// RegisterUserHandler handles HTTP POST requests to /users.
func (a *UserAPIAdapter) RegisterUserHandler(w http.ResponseWriter, r *http.Request) {
// ... (parse request body for username and email) ...
username := r.FormValue("username")
email := r.FormValue("email")
user, err := a.userService.CreateUser(username, email)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// ... (write success response) ...
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "User created: %s", user.ID)
}
// --- Main Application Setup ---
func main() {
// Database connection (simplified)
db, err := sql.Open("postgres", "user=postgres dbname=users sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Initialize adapters
userRepoAdapter := NewUserPersistenceAdapter(db)
userAPIAdapter := NewUserAPIAdapter(NewUserService(userRepoAdapter))
// Set up HTTP server (simplified)
http.HandleFunc("/users", userAPIAdapter.RegisterUserHandler)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
In this setup, UserService is the core. It doesn’t know how users are saved or how requests are received. It only knows about the UserRepository interface. This interface is a "port" – a contract for how the outside world can interact with the core. The UserPersistenceAdapter and UserAPIAdapter are "adapters" – they implement those ports to talk to specific technologies (PostgreSQL and HTTP, respectively).
The problem this solves is decoupling. Your business logic, the "what" your application does, is entirely independent of the "how" it interacts with the world. You can swap out the database from PostgreSQL to MongoDB, or change the API from REST to gRPC, without touching the UserService code at all. You just write a new adapter.
The core logic defines ports, which are essentially interfaces. These interfaces represent the intent of the interaction. For example, the UserRepository interface defines the intent to save or find a user, not the specific SQL queries or NoSQL commands. Adapters then implement these ports, translating the generic intent into concrete technology-specific actions.
Inside the core, you’ll often find domain objects and use cases. Domain objects (like User) represent the fundamental concepts of your business. Use cases (like CreateUser) orchestrate these domain objects to fulfill specific business requirements. The key is that these elements only depend on each other and on the ports, never on external technologies.
The benefit here is immense testability. To test UserService, you don’t need a real database or a running web server. You can create a mock UserRepository that simply records method calls and returns predefined values, allowing you to unit test your business logic in isolation.
Most people don’t realize that the adapters are bidirectional. While we often think of adapters as the core calling out to the outside world (e.g., UserService calling UserRepository.Save), the outside world also calls in to the core through adapters. The UserAPIAdapter receives an HTTP request and then calls userService.CreateUser. The core logic is driven by external events or requests, channeled through these inbound adapters.
The next concept you’ll likely explore is how to manage multiple ports and adapters for different concerns, like authentication, logging, or background job processing.