Production engineering guide for hexagonal architecture deep covering patterns, implementation strategies, and operational best practices.
Hexagonal Architecture, also known as Ports and Adapters, is a software design pattern that structures an application around its business logic and the interactions it needs to support. This approach separates domain logic from external dependencies like databases, UIs, or message brokers, making systems more maintainable, testable, and adaptable to change.
This reference focuses on how to implement and manage hexagonal architecture in practice, including configuration, dependency management, testing strategies, and failure modes that arise when the pattern is misapplied. It assumes you are working with a system that has multiple external integrations and need to decouple business logic from infrastructure concerns.
The foundation of hexagonal architecture is the domain layer, which contains the core business rules and entities. This layer should not depend on any external libraries or frameworks.
// Example: Domain Entity in Go
type User struct {
ID int
Name string
Email string
Created time.Time
}
func (u *User) Validate() error {
if u.Email == "" {
return errors.New("email is required")
}
return nil
}
In this example, User has no dependency on any framework or database driver. Its methods are pure business logic functions.
Ports are interfaces that define how your application communicates with the outside world. They separate the core domain from external systems.
// Example: Port Interface in Go
type UserRepository interface {
Save(user *User) error
FindByID(id int) (*User, error)
FindByEmail(email string) (*User, error)
}
The UserRepository interface is a port that defines how the domain interacts with persistence. The actual implementation can be swapped out without affecting the business logic.
Adapters are concrete implementations of ports that connect to external systems. They translate between the external system's API and your internal port interfaces.
// Example: Adapter Implementation in Go
type PostgreSQLUserRepository struct {
db *sql.DB
}
func (r *PostgreSQLUserRepository) Save(user *User) error {
_, err := r.db.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
user.Name, user.Email)
return err
}
func (r *PostgreSQLUserRepository) FindByID(id int) (*User, error) {
row := r.db.QueryRow("SELECT id, name, email FROM users WHERE id = ?", id)
var u User
err := row.Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
return nil, err
}
return &u, nil
}
The adapter uses sql.DB directly but exposes only the methods defined in the UserRepository interface.
You must wire up your application components so that the domain logic uses the correct port implementations. This is typically done through constructor injection or a dependency injection container.
// Example: Application Setup in Go
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) CreateUser(name, email string) (*User, error) {
user := &User{Name: name, Email: email, Created: time.Now()}
if err := user.Validate(); err != nil {
return nil, err
}
if err := s.repo.Save(user); err != nil {
return nil, err
}
return user, nil
}
When creating the service, inject the appropriate repository implementation:
// Example: Wiring in Main Function
func main() {
db, err := sql.Open("postgres", "user=postgres dbname=test")
if err != nil {
panic(err)
}
defer db.Close()
repo := &PostgreSQLUserRepository{db: db}
service := NewUserService(repo)
// Use the service...
}
When you have multiple implementations of a port, such as a real database repository and a mock for testing, it's important to test both.
// Example: Test with Mock Repository
type MockUserRepository struct {
users map[int]*User
}
func (m *MockUserRepository) Save(user *User) error {
m.users[user.ID] = user
return nil
}
func (m *MockUserRepository) FindByID(id int) (*User, error) {
user, exists := m.users[id]
if !exists {
return nil, errors.New("user not found")
}
return user, nil
}
In tests, you can inject the mock repository to isolate business logic from database calls.
The key failure mode in hexagonal architecture is when an adapter fails to implement a port correctly. This can silently cause runtime errors or incorrect behavior.
When using a dependency injection framework, make sure all required interfaces are registered properly. If you forget to register one, the system will fail at runtime with a nil pointer dereference.
Ports and adapters should be configured through external configuration files or environment variables. The application should not hardcode connection strings or implementation details.
# Example: Configuration File
database:
url: "postgres://user:pass@localhost/mydb"
max_connections: 10
In code, read this configuration and pass it to your adapter constructors:
// Example: Reading Configuration
type Config struct {
Database struct {
URL string `yaml:"url"`
MaxConnections int `yaml:"max_connections"`
} `yaml:"database"`
}
func NewPostgreSQLUserRepository(cfg *Config) (*PostgreSQLUserRepository, error) {
db, err := sql.Open("postgres", cfg.Database.URL)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.Database.MaxConnections)
return &PostgreSQLUserRepository{db: db}, nil
}
A single port can have multiple implementations. For example, you might have both a PostgreSQL adapter and a MongoDB adapter for the same UserRepository interface.
// Example: MongoDB Adapter
type MongoDBUserRepository struct {
client *mongo.Client
collection *mongo.Collection
}
func (r *MongoDBUserRepository) Save(user *User) error {
_, err := r.collection.InsertOne(context.TODO(), user)
return err
}
Choose the appropriate adapter at startup based on configuration or environment.
External systems are integrated via adapters that implement ports. For example, a REST API might use an HTTP adapter to expose domain services:
// Example: HTTP Adapter for User Service
func (s *UserService) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
var input struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user, err := s.CreateUser(input.Name, input.Email)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
One common mistake is implementing a port in the domain layer rather than in an adapter. This creates a dependency cycle where business logic depends on external systems.
Another pitfall is not properly managing the lifecycle of adapters. Adapters that manage connections or resources should be closed properly to avoid leaks.
Ports should be versioned carefully. Changes to port interfaces can break implementations. Keep ports stable and introduce new versions when necessary.
The indirection through ports adds minimal overhead. However, poorly designed adapters can introduce unnecessary complexity or performance bottlenecks. Avoid unnecessary allocations or blocking calls in adapter implementations.
When debugging hexagonal architecture, ensure that all dependencies are wired correctly. Use logging at the boundaries between layers to trace data flow. Pay attention to error propagation through ports and adapters.
In production systems, monitor connection pools and resource usage of adapters. Misconfigured adapters can cause cascading failures across the system.
The key is ensuring that every external interaction goes through a well-defined port interface, with concrete implementations that can be swapped without affecting the core business logic.
This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy