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:

3. Adapters

Concrete implementations of ports:

When to Use Hexagonal Architecture

Use it when:

Avoid it when:

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

Disadvantages

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.