Hexagonal Architecture for Go Microservices: Building Maintainable, Testable Systems
Hexagonal Architecture for Go Microservices: Building Maintainable, Testable Systems
What is Hexagonal Architecture?
Hexagonal Architecture (also known as Ports and Adapters) is a design pattern that creates a clear separation between business logic and external dependencies. The core idea: your domain logic sits at the center, isolated from frameworks, databases, and external services through well-defined interfaces (ports) and their implementations (adapters).
Think of your application as a hexagon where each side represents a port for different types of interactions. This geometry emphasizes that there’s no inherent “top” or “bottom”—all external dependencies are treated equally as adapters plugging into ports.
Core Components
1. Domain (Core)
The innermost layer containing business logic, domain models, and rules. This layer has zero dependencies on external frameworks or libraries.
2. Ports
Interfaces defining how the outside world can interact with your domain:
- Inbound Ports (Driving): Define use cases/services that drive your application (e.g., HTTP handlers, gRPC services)
- Outbound Ports (Driven): Define dependencies your domain needs (e.g., database repositories, external APIs)
3. Adapters
Concrete implementations of ports:
- Inbound Adapters: REST handlers, GraphQL resolvers, CLI commands
- Outbound Adapters: PostgreSQL repositories, Redis cache, external API clients
When to Use Hexagonal Architecture
Use it when:
- Building microservices requiring high testability
- Domain logic is complex and needs protection from infrastructure changes
- Multiple interfaces are needed (REST + gRPC + CLI)
- Long-term maintainability is critical
- Team wants clear boundaries and ownership
Avoid it when:
- Building simple CRUD applications with minimal business logic
- Rapid prototyping is the priority
- Team is unfamiliar with the pattern and lacks time for learning curve
- Application is truly a thin wrapper around a database
Implementation in Go
Project Structure
user-service/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── domain/ # Core domain logic
│ │ ├── user.go
│ │ └── errors.go
│ ├── ports/ # Interfaces
│ │ ├── inbound/
│ │ │ └── user_service.go
│ │ └── outbound/
│ │ ├── user_repository.go
│ │ └── email_sender.go
│ ├── adapters/ # Implementations
│ │ ├── inbound/
│ │ │ ├── http/
│ │ │ │ └── user_handler.go
│ │ │ └── grpc/
│ │ │ └── user_grpc.go
│ │ └── outbound/
│ │ ├── postgres/
│ │ │ └── user_repository.go
│ │ └── smtp/
│ │ └── email_sender.go
│ └── application/ # Use cases/services
│ └── user_service.go
└── go.mod
Domain Layer
// internal/domain/user.go
package domain
import (
"errors"
"time"
)
type User struct {
ID string
Email string
Name string
CreatedAt time.Time
UpdatedAt time.Time
}
func NewUser(email, name string) (*User, error) {
if email == "" {
return nil, errors.New("email is required")
}
if name == "" {
return nil, errors.New("name is required")
}
return &User{
Email: email,
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
func (u *User) UpdateName(name string) error {
if name == "" {
return errors.New("name cannot be empty")
}
u.Name = name
u.UpdatedAt = time.Now()
return nil
}
Ports (Interfaces)
// internal/ports/inbound/user_service.go
package inbound
import (
"context"
"user-service/internal/domain"
)
type UserService interface {
CreateUser(ctx context.Context, email, name string) (*domain.User, error)
GetUser(ctx context.Context, id string) (*domain.User, error)
UpdateUser(ctx context.Context, id, name string) (*domain.User, error)
}
// internal/ports/outbound/user_repository.go
package outbound
import (
"context"
"user-service/internal/domain"
)
type UserRepository interface {
Save(ctx context.Context, user *domain.User) error
FindByID(ctx context.Context, id string) (*domain.User, error)
FindByEmail(ctx context.Context, email string) (*domain.User, error)
}
type EmailSender interface {
SendWelcomeEmail(ctx context.Context, email, name string) error
}
Application Service (Use Cases)
// internal/application/user_service.go
package application
import (
"context"
"fmt"
"user-service/internal/domain"
"user-service/internal/ports/outbound"
"github.com/google/uuid"
)
type UserService struct {
repo outbound.UserRepository
mailer outbound.EmailSender
}
func NewUserService(repo outbound.UserRepository, mailer outbound.EmailSender) *UserService {
return &UserService{
repo: repo,
mailer: mailer,
}
}
func (s *UserService) CreateUser(ctx context.Context, email, name string) (*domain.User, error) {
// Check if user exists
existing, _ := s.repo.FindByEmail(ctx, email)
if existing != nil {
return nil, fmt.Errorf("user with email %s already exists", email)
}
// Create domain object
user, err := domain.NewUser(email, name)
if err != nil {
return nil, err
}
user.ID = uuid.New().String()
// Save to repository
if err := s.repo.Save(ctx, user); err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
}
// Send welcome email (async in production)
if err := s.mailer.SendWelcomeEmail(ctx, email, name); err != nil {
// Log error but don't fail the operation
fmt.Printf("failed to send welcome email: %v\n", err)
}
return user, nil
}
Inbound Adapter (HTTP Handler)
// internal/adapters/inbound/http/user_handler.go
package http
import (
"encoding/json"
"net/http"
"user-service/internal/ports/inbound"
)
type UserHandler struct {
service inbound.UserService
}
func NewUserHandler(service inbound.UserService) *UserHandler {
return &UserHandler{service: service}
}
type CreateUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
user, err := h.service.CreateUser(r.Context(), req.Email, req.Name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
Outbound Adapter (PostgreSQL Repository)
// internal/adapters/outbound/postgres/user_repository.go
package postgres
import (
"context"
"database/sql"
"user-service/internal/domain"
_ "github.com/lib/pq"
)
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Save(ctx context.Context, user *domain.User) error {
query := `
INSERT INTO users (id, email, name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE
SET name = $3, updated_at = $5
`
_, err := r.db.ExecContext(ctx, query,
user.ID, user.Email, user.Name, user.CreatedAt, user.UpdatedAt)
return err
}
func (r *UserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
query := `SELECT id, email, name, created_at, updated_at FROM users WHERE id = $1`
user := &domain.User{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return user, err
}
Wiring It All Together
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"net/http"
"user-service/internal/application"
httpAdapter "user-service/internal/adapters/inbound/http"
"user-service/internal/adapters/outbound/postgres"
"user-service/internal/adapters/outbound/smtp"
)
func main() {
// Initialize outbound adapters
db, err := sql.Open("postgres", "postgres://localhost/userdb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
userRepo := postgres.NewUserRepository(db)
emailSender := smtp.NewEmailSender("smtp.example.com:587", "user", "pass")
// Initialize application service
userService := application.NewUserService(userRepo, emailSender)
// Initialize inbound adapter
userHandler := httpAdapter.NewUserHandler(userService)
// Setup routes
http.HandleFunc("/users", userHandler.CreateUser)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Testing Benefits
The real power of hexagonal architecture shows in testing:
// internal/application/user_service_test.go
package application_test
import (
"context"
"testing"
"user-service/internal/application"
"user-service/internal/domain"
)
// Mock repository
type mockUserRepository struct {
users map[string]*domain.User
}
func (m *mockUserRepository) Save(ctx context.Context, user *domain.User) error {
m.users[user.ID] = user
return nil
}
func (m *mockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
for _, u := range m.users {
if u.Email == email {
return u, nil
}
}
return nil, nil
}
// Mock email sender
type mockEmailSender struct {
sentEmails []string
}
func (m *mockEmailSender) SendWelcomeEmail(ctx context.Context, email, name string) error {
m.sentEmails = append(m.sentEmails, email)
return nil
}
func TestCreateUser(t *testing.T) {
repo := &mockUserRepository{users: make(map[string]*domain.User)}
mailer := &mockEmailSender{}
service := application.NewUserService(repo, mailer)
user, err := service.CreateUser(context.Background(), "test@example.com", "Test User")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if user.Email != "test@example.com" {
t.Errorf("expected email test@example.com, got %s", user.Email)
}
if len(mailer.sentEmails) != 1 {
t.Errorf("expected 1 email sent, got %d", len(mailer.sentEmails))
}
}
Trade-offs
Advantages
- Testability: Easy to test business logic with mocks
- Flexibility: Swap implementations (Postgres → MongoDB) without touching domain
- Maintainability: Clear boundaries reduce cognitive load
- Multiple interfaces: Support REST, gRPC, CLI from same core
- Team scaling: Teams can own specific adapters independently
Disadvantages
- Initial complexity: More files, interfaces, and indirection
- Learning curve: Team needs to understand the pattern
- Potential over-engineering: Overkill for simple CRUD apps
- Performance overhead: Extra indirection can add minimal overhead (usually negligible)
Conclusion
Hexagonal Architecture in Go microservices provides a robust foundation for building maintainable systems where business logic is protected from infrastructure churn. While it requires upfront investment in structure, the payoff comes in testability, flexibility, and long-term maintainability—critical factors for production systems that need to evolve over years.
Start with hexagonal architecture when you’re building something that matters and needs to last. Your future self (and your team) will thank you.