Go Architecture Patterns Series: ← Clean Architecture | Series Overview | Next: Domain-Driven Design →


What is Hexagonal Architecture?

Hexagonal Architecture, also known as Ports and Adapters pattern, was introduced by Alistair Cockburn. It emphasizes separating the core business logic from external concerns by defining clear boundaries through ports (interfaces) and adapters (implementations).

Key Principles:

  • Core Domain Isolation: Business logic is completely isolated from external dependencies
  • Ports: Interfaces that define how the application communicates with the outside world
  • Adapters: Concrete implementations of ports for specific technologies
  • Symmetry: No distinction between “front-end” and “back-end” - all external systems are treated equally
  • Testability: Core can be tested in isolation without any external dependencies
  • Pluggability: Adapters can be swapped without changing the core

Architecture Overview

graph TB subgraph "Primary Adapters (Driving)" HTTP[HTTP REST API] CLI[CLI Interface] GRPC[gRPC Server] end subgraph "Hexagon (Core Domain)" PORT1[Primary Ports
Driving Interfaces] CORE[Business Logic
Domain Models
Use Cases] PORT2[Secondary Ports
Driven Interfaces] end subgraph "Secondary Adapters (Driven)" DB[PostgreSQL Adapter] CACHE[Redis Adapter] MSG[Message Queue Adapter] EXT[External API Adapter] end HTTP --> PORT1 CLI --> PORT1 GRPC --> PORT1 PORT1 --> CORE CORE --> PORT2 PORT2 --> DB PORT2 --> CACHE PORT2 --> MSG PORT2 --> EXT style CORE fill:#FFD700 style PORT1 fill:#87CEEB style PORT2 fill:#87CEEB style HTTP fill:#90EE90 style DB fill:#FFB6C1

Ports and Adapters Visualization

graph LR subgraph "Primary Side (Driving)" REST[REST Adapter] GraphQL[GraphQL Adapter] end subgraph "Core Application" IP[Input Port
Interface] BL[Business Logic] OP[Output Port
Interface] end subgraph "Secondary Side (Driven)" DBAdapter[Database Adapter] EmailAdapter[Email Adapter] end REST -.->|implements| IP GraphQL -.->|implements| IP IP --> BL BL --> OP OP -.->|implemented by| DBAdapter OP -.->|implemented by| EmailAdapter style BL fill:#FFD700 style IP fill:#87CEEB style OP fill:#87CEEB style REST fill:#90EE90 style DBAdapter fill:#FFB6C1

Complete Hexagonal Flow

sequenceDiagram participant Client participant HTTPAdapter participant InputPort participant Core participant OutputPort participant DBAdapter participant Database Client->>HTTPAdapter: HTTP Request HTTPAdapter->>InputPort: Call Method InputPort->>Core: Execute Business Logic Core->>OutputPort: Request Data OutputPort->>DBAdapter: Interface Call DBAdapter->>Database: SQL Query Database-->>DBAdapter: Data DBAdapter-->>OutputPort: Domain Object OutputPort-->>Core: Result Core-->>InputPort: Response InputPort-->>HTTPAdapter: DTO HTTPAdapter-->>Client: HTTP Response

Real-World Use Cases

  • API Gateways: Multiple protocols (REST, gRPC, GraphQL) with same core logic
  • Multi-tenant Applications: Different adapters for different tenants
  • Legacy System Integration: Adapter for each legacy system
  • Testing-Critical Systems: Easy mocking of all external dependencies
  • Cloud-Native Applications: Easy switching between cloud providers
  • Evolutionary Architecture: System that needs to adapt over time

Project Structure

├── cmd/
│   ├── http/
│   │   └── main.go           # HTTP server entry point
│   └── cli/
│       └── main.go           # CLI entry point
├── internal/
│   ├── core/
│   │   ├── domain/           # Domain entities and value objects
│   │   │   ├── user.go
│   │   │   └── order.go
│   │   ├── port/             # Ports (interfaces)
│   │   │   ├── input.go      # Primary/Driving ports
│   │   │   └── output.go     # Secondary/Driven ports
│   │   └── service/          # Business logic
│   │       └── user_service.go
│   └── adapter/
│       ├── input/            # Primary/Driving adapters
│       │   ├── http/
│       │   │   └── user_handler.go
│       │   └── grpc/
│       │       └── user_server.go
│       └── output/           # Secondary/Driven adapters
│           ├── persistence/
│           │   └── postgres_user_repository.go
│           └── notification/
│               └── email_service.go
└── go.mod

Core Domain Layer

Domain Entities

package domain

import (
    "errors"
    "time"
)

// User represents a user entity in the domain
type User struct {
    ID        string
    Email     Email
    Name      string
    Status    UserStatus
    CreatedAt time.Time
    UpdatedAt time.Time
}

// Email is a value object representing an email address
type Email struct {
    value string
}

// NewEmail creates a new email value object with validation
func NewEmail(email string) (Email, error) {
    if !isValidEmail(email) {
        return Email{}, errors.New("invalid email format")
    }
    return Email{value: email}, nil
}

// String returns the email string value
func (e Email) String() string {
    return e.value
}

// UserStatus represents the status of a user
type UserStatus string

const (
    UserStatusActive   UserStatus = "active"
    UserStatusInactive UserStatus = "inactive"
    UserStatusSuspended UserStatus = "suspended"
)

// NewUser creates a new user with business rule validation
func NewUser(email, name string) (*User, error) {
    emailVO, err := NewEmail(email)
    if err != nil {
        return nil, err
    }

    if name == "" {
        return nil, errors.New("name cannot be empty")
    }

    return &User{
        Email:     emailVO,
        Name:      name,
        Status:    UserStatusActive,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }, nil
}

// Activate activates the user
func (u *User) Activate() {
    u.Status = UserStatusActive
    u.UpdatedAt = time.Now()
}

// Suspend suspends the user
func (u *User) Suspend() {
    u.Status = UserStatusSuspended
    u.UpdatedAt = time.Now()
}

// IsActive returns true if user is active
func (u *User) IsActive() bool {
    return u.Status == UserStatusActive
}

func isValidEmail(email string) bool {
    // Simplified validation
    return len(email) > 3 && contains(email, "@")
}

func contains(s, substr string) bool {
    for i := 0; i <= len(s)-len(substr); i++ {
        if s[i:i+len(substr)] == substr {
            return true
        }
    }
    return false
}

// Order represents an order in the domain
type Order struct {
    ID        string
    UserID    string
    Items     []OrderItem
    Total     Money
    Status    OrderStatus
    CreatedAt time.Time
}

// OrderItem represents a line item in an order
type OrderItem struct {
    ProductID string
    Quantity  int
    Price     Money
}

// OrderStatus represents the status of an order
type OrderStatus string

const (
    OrderStatusPending   OrderStatus = "pending"
    OrderStatusConfirmed OrderStatus = "confirmed"
    OrderStatusShipped   OrderStatus = "shipped"
    OrderStatusDelivered OrderStatus = "delivered"
    OrderStatusCancelled OrderStatus = "cancelled"
)

// Money represents monetary value
type Money struct {
    Amount   int64
    Currency string
}

// NewMoney creates a new money value object
func NewMoney(amount int64, currency string) Money {
    return Money{
        Amount:   amount,
        Currency: currency,
    }
}

// Add adds two money values
func (m Money) Add(other Money) (Money, error) {
    if m.Currency != other.Currency {
        return Money{}, errors.New("cannot add different currencies")
    }
    return Money{
        Amount:   m.Amount + other.Amount,
        Currency: m.Currency,
    }, nil
}

Ports (Interfaces)

Primary Ports (Driving/Input)

package port

import (
    "context"
    "myapp/internal/core/domain"
)

// UserService defines the input port for user operations
// This is what drives the application (called by adapters)
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, email, name string) (*domain.User, error)
    DeleteUser(ctx context.Context, id string) error
    ListUsers(ctx context.Context, offset, limit int) ([]*domain.User, error)
    ActivateUser(ctx context.Context, id string) error
    SuspendUser(ctx context.Context, id string) error
}

// OrderService defines the input port for order operations
type OrderService interface {
    CreateOrder(ctx context.Context, userID string, items []OrderItemInput) (*domain.Order, error)
    GetOrder(ctx context.Context, id string) (*domain.Order, error)
    CancelOrder(ctx context.Context, id string) error
    GetUserOrders(ctx context.Context, userID string) ([]*domain.Order, error)
}

// OrderItemInput represents input for creating an order item
type OrderItemInput struct {
    ProductID string
    Quantity  int
}

Secondary Ports (Driven/Output)

package port

import (
    "context"
    "myapp/internal/core/domain"
)

// UserRepository defines the output port for user persistence
// This is driven by the application (implemented by adapters)
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)
    Update(ctx context.Context, user *domain.User) error
    Delete(ctx context.Context, id string) error
    FindAll(ctx context.Context, offset, limit int) ([]*domain.User, error)
}

// OrderRepository defines the output port for order persistence
type OrderRepository interface {
    Save(ctx context.Context, order *domain.Order) error
    FindByID(ctx context.Context, id string) (*domain.Order, error)
    FindByUserID(ctx context.Context, userID string) ([]*domain.Order, error)
    Update(ctx context.Context, order *domain.Order) error
}

// NotificationService defines the output port for notifications
type NotificationService interface {
    SendWelcomeEmail(ctx context.Context, user *domain.User) error
    SendOrderConfirmation(ctx context.Context, order *domain.Order) error
    SendOrderCancellation(ctx context.Context, order *domain.Order) error
}

// IDGenerator defines the output port for ID generation
type IDGenerator interface {
    GenerateID() string
}

// EventPublisher defines the output port for publishing domain events
type EventPublisher interface {
    PublishUserCreated(ctx context.Context, user *domain.User) error
    PublishUserActivated(ctx context.Context, user *domain.User) error
    PublishOrderPlaced(ctx context.Context, order *domain.Order) error
}

Core Service (Business Logic)

package service

import (
    "context"
    "errors"
    "fmt"

    "myapp/internal/core/domain"
    "myapp/internal/core/port"
)

// userService implements the UserService port
type userService struct {
    repo         port.UserRepository
    notification port.NotificationService
    idGen        port.IDGenerator
    eventPub     port.EventPublisher
}

// NewUserService creates a new user service
func NewUserService(
    repo port.UserRepository,
    notification port.NotificationService,
    idGen port.IDGenerator,
    eventPub port.EventPublisher,
) port.UserService {
    return &userService{
        repo:         repo,
        notification: notification,
        idGen:        idGen,
        eventPub:     eventPub,
    }
}

// CreateUser creates a new user
func (s *userService) CreateUser(ctx context.Context, email, name string) (*domain.User, error) {
    // Check if user already exists
    existingUser, err := s.repo.FindByEmail(ctx, email)
    if err == nil && existingUser != nil {
        return nil, errors.New("user already exists")
    }

    // Create user entity with business rules
    user, err := domain.NewUser(email, name)
    if err != nil {
        return nil, fmt.Errorf("failed to create user: %w", err)
    }

    // Generate ID
    user.ID = s.idGen.GenerateID()

    // Save user
    if err := s.repo.Save(ctx, user); err != nil {
        return nil, fmt.Errorf("failed to save user: %w", err)
    }

    // Send notification (best effort, don't fail on error)
    if err := s.notification.SendWelcomeEmail(ctx, user); err != nil {
        // Log error but don't fail
        fmt.Printf("failed to send welcome email: %v\n", err)
    }

    // Publish event
    if err := s.eventPub.PublishUserCreated(ctx, user); err != nil {
        // Log error but don't fail
        fmt.Printf("failed to publish user created event: %v\n", err)
    }

    return user, nil
}

// GetUser retrieves a user by ID
func (s *userService) GetUser(ctx context.Context, id string) (*domain.User, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("failed to get user: %w", err)
    }
    if user == nil {
        return nil, errors.New("user not found")
    }
    return user, nil
}

// UpdateUser updates a user
func (s *userService) UpdateUser(ctx context.Context, id, email, name string) (*domain.User, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("user not found: %w", err)
    }

    // Update email if changed
    if email != "" && email != user.Email.String() {
        newEmail, err := domain.NewEmail(email)
        if err != nil {
            return nil, err
        }

        // Check if new email is already taken
        existingUser, _ := s.repo.FindByEmail(ctx, email)
        if existingUser != nil && existingUser.ID != id {
            return nil, errors.New("email already exists")
        }

        user.Email = newEmail
    }

    // Update name if changed
    if name != "" && name != user.Name {
        user.Name = name
    }

    // Save updated user
    if err := s.repo.Update(ctx, user); err != nil {
        return nil, fmt.Errorf("failed to update user: %w", err)
    }

    return user, nil
}

// DeleteUser deletes a user
func (s *userService) DeleteUser(ctx context.Context, id string) error {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return errors.New("user not found")
    }

    if !user.IsActive() {
        return errors.New("cannot delete inactive user")
    }

    return s.repo.Delete(ctx, id)
}

// ListUsers lists users with pagination
func (s *userService) ListUsers(ctx context.Context, offset, limit int) ([]*domain.User, error) {
    if limit <= 0 || limit > 100 {
        limit = 20
    }
    if offset < 0 {
        offset = 0
    }

    return s.repo.FindAll(ctx, offset, limit)
}

// ActivateUser activates a user
func (s *userService) ActivateUser(ctx context.Context, id string) error {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return errors.New("user not found")
    }

    user.Activate()

    if err := s.repo.Update(ctx, user); err != nil {
        return fmt.Errorf("failed to activate user: %w", err)
    }

    // Publish event
    if err := s.eventPub.PublishUserActivated(ctx, user); err != nil {
        fmt.Printf("failed to publish user activated event: %v\n", err)
    }

    return nil
}

// SuspendUser suspends a user
func (s *userService) SuspendUser(ctx context.Context, id string) error {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return errors.New("user not found")
    }

    user.Suspend()

    return s.repo.Update(ctx, user)
}

Primary Adapters (Driving/Input)

HTTP Adapter

package http

import (
    "encoding/json"
    "net/http"

    "myapp/internal/core/port"
)

// UserHandler is the HTTP adapter for user operations
type UserHandler struct {
    service port.UserService
}

// NewUserHandler creates a new HTTP user handler
func NewUserHandler(service port.UserService) *UserHandler {
    return &UserHandler{service: service}
}

// CreateUserRequest represents the HTTP request
type CreateUserRequest struct {
    Email string `json:"email"`
    Name  string `json:"name"`
}

// UserResponse represents the HTTP response
type UserResponse struct {
    ID        string `json:"id"`
    Email     string `json:"email"`
    Name      string `json:"name"`
    Status    string `json:"status"`
    CreatedAt string `json:"created_at"`
}

// CreateUser handles POST /users
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "invalid request")
        return
    }

    user, err := h.service.CreateUser(r.Context(), req.Email, req.Name)
    if err != nil {
        respondError(w, http.StatusBadRequest, err.Error())
        return
    }

    respondJSON(w, http.StatusCreated, toUserResponse(user))
}

// GetUser handles GET /users/{id}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    user, err := h.service.GetUser(r.Context(), id)
    if err != nil {
        respondError(w, http.StatusNotFound, "user not found")
        return
    }

    respondJSON(w, http.StatusOK, toUserResponse(user))
}

// UpdateUser handles PUT /users/{id}
func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "invalid request")
        return
    }

    user, err := h.service.UpdateUser(r.Context(), id, req.Email, req.Name)
    if err != nil {
        respondError(w, http.StatusBadRequest, err.Error())
        return
    }

    respondJSON(w, http.StatusOK, toUserResponse(user))
}

// DeleteUser handles DELETE /users/{id}
func (h *UserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    if err := h.service.DeleteUser(r.Context(), id); err != nil {
        respondError(w, http.StatusBadRequest, err.Error())
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

// Helper functions
func toUserResponse(user *domain.User) UserResponse {
    return UserResponse{
        ID:        user.ID,
        Email:     user.Email.String(),
        Name:      user.Name,
        Status:    string(user.Status),
        CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
    }
}

func respondJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func respondError(w http.ResponseWriter, status int, message string) {
    respondJSON(w, status, map[string]string{"error": message})
}

Secondary Adapters (Driven/Output)

Database Adapter

package persistence

import (
    "context"
    "database/sql"
    "errors"
    "time"

    "myapp/internal/core/domain"
    "myapp/internal/core/port"
)

// postgresUserRepository implements the UserRepository port
type postgresUserRepository struct {
    db *sql.DB
}

// NewPostgresUserRepository creates a new Postgres user repository
func NewPostgresUserRepository(db *sql.DB) port.UserRepository {
    return &postgresUserRepository{db: db}
}

// Save saves a user
func (r *postgresUserRepository) Save(ctx context.Context, user *domain.User) error {
    query := `
        INSERT INTO users (id, email, name, status, created_at, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6)
    `

    _, err := r.db.ExecContext(
        ctx, query,
        user.ID,
        user.Email.String(),
        user.Name,
        user.Status,
        user.CreatedAt,
        user.UpdatedAt,
    )

    return err
}

// FindByID finds a user by ID
func (r *postgresUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
    query := `
        SELECT id, email, name, status, created_at, updated_at
        FROM users
        WHERE id = $1
    `

    var (
        userID    string
        email     string
        name      string
        status    string
        createdAt time.Time
        updatedAt time.Time
    )

    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &userID, &email, &name, &status, &createdAt, &updatedAt,
    )

    if err == sql.ErrNoRows {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }

    emailVO, _ := domain.NewEmail(email)

    return &domain.User{
        ID:        userID,
        Email:     emailVO,
        Name:      name,
        Status:    domain.UserStatus(status),
        CreatedAt: createdAt,
        UpdatedAt: updatedAt,
    }, nil
}

// FindByEmail finds a user by email
func (r *postgresUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
    query := `
        SELECT id, email, name, status, created_at, updated_at
        FROM users
        WHERE email = $1
    `

    var (
        userID    string
        emailStr  string
        name      string
        status    string
        createdAt time.Time
        updatedAt time.Time
    )

    err := r.db.QueryRowContext(ctx, query, email).Scan(
        &userID, &emailStr, &name, &status, &createdAt, &updatedAt,
    )

    if err == sql.ErrNoRows {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }

    emailVO, _ := domain.NewEmail(emailStr)

    return &domain.User{
        ID:        userID,
        Email:     emailVO,
        Name:      name,
        Status:    domain.UserStatus(status),
        CreatedAt: createdAt,
        UpdatedAt: updatedAt,
    }, nil
}

// Update updates a user
func (r *postgresUserRepository) Update(ctx context.Context, user *domain.User) error {
    query := `
        UPDATE users
        SET email = $1, name = $2, status = $3, updated_at = $4
        WHERE id = $5
    `

    result, err := r.db.ExecContext(
        ctx, query,
        user.Email.String(),
        user.Name,
        user.Status,
        time.Now(),
        user.ID,
    )

    if err != nil {
        return err
    }

    rows, _ := result.RowsAffected()
    if rows == 0 {
        return errors.New("user not found")
    }

    return nil
}

// Delete deletes a user
func (r *postgresUserRepository) Delete(ctx context.Context, id string) error {
    query := `DELETE FROM users WHERE id = $1`

    result, err := r.db.ExecContext(ctx, query, id)
    if err != nil {
        return err
    }

    rows, _ := result.RowsAffected()
    if rows == 0 {
        return errors.New("user not found")
    }

    return nil
}

// FindAll finds all users with pagination
func (r *postgresUserRepository) FindAll(ctx context.Context, offset, limit int) ([]*domain.User, error) {
    query := `
        SELECT id, email, name, status, created_at, updated_at
        FROM users
        ORDER BY created_at DESC
        LIMIT $1 OFFSET $2
    `

    rows, err := r.db.QueryContext(ctx, query, limit, offset)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var users []*domain.User
    for rows.Next() {
        var (
            userID    string
            email     string
            name      string
            status    string
            createdAt time.Time
            updatedAt time.Time
        )

        if err := rows.Scan(&userID, &email, &name, &status, &createdAt, &updatedAt); err != nil {
            return nil, err
        }

        emailVO, _ := domain.NewEmail(email)

        users = append(users, &domain.User{
            ID:        userID,
            Email:     emailVO,
            Name:      name,
            Status:    domain.UserStatus(status),
            CreatedAt: createdAt,
            UpdatedAt: updatedAt,
        })
    }

    return users, nil
}

Email Notification Adapter

package notification

import (
    "context"
    "fmt"
    "log"

    "myapp/internal/core/domain"
    "myapp/internal/core/port"
)

// emailService implements the NotificationService port
type emailService struct {
    smtpHost string
    smtpPort int
    fromAddr string
}

// NewEmailService creates a new email notification service
func NewEmailService(smtpHost string, smtpPort int, fromAddr string) port.NotificationService {
    return &emailService{
        smtpHost: smtpHost,
        smtpPort: smtpPort,
        fromAddr: fromAddr,
    }
}

// SendWelcomeEmail sends a welcome email to a new user
func (s *emailService) SendWelcomeEmail(ctx context.Context, user *domain.User) error {
    // In production, use actual email service
    log.Printf("Sending welcome email to %s", user.Email.String())
    return s.sendEmail(ctx, user.Email.String(), "Welcome!", "Welcome to our service!")
}

// SendOrderConfirmation sends an order confirmation email
func (s *emailService) SendOrderConfirmation(ctx context.Context, order *domain.Order) error {
    log.Printf("Sending order confirmation for order %s", order.ID)
    return nil
}

// SendOrderCancellation sends an order cancellation email
func (s *emailService) SendOrderCancellation(ctx context.Context, order *domain.Order) error {
    log.Printf("Sending order cancellation for order %s", order.ID)
    return nil
}

func (s *emailService) sendEmail(ctx context.Context, to, subject, body string) error {
    // Simulate email sending
    fmt.Printf("Email sent to %s: %s\n", to, subject)
    return nil
}

Main Application with Dependency Injection

package main

import (
    "database/sql"
    "log"
    "net/http"

    _ "github.com/lib/pq"
    "github.com/google/uuid"

    "myapp/internal/adapter/input/http"
    "myapp/internal/adapter/output/notification"
    "myapp/internal/adapter/output/persistence"
    "myapp/internal/core/port"
    "myapp/internal/core/service"
)

// UUIDGenerator implements IDGenerator port
type UUIDGenerator struct{}

func (g *UUIDGenerator) GenerateID() string {
    return uuid.New().String()
}

// MockEventPublisher implements EventPublisher port
type MockEventPublisher struct{}

func (p *MockEventPublisher) PublishUserCreated(ctx context.Context, user *domain.User) error {
    log.Printf("Event: User created - %s", user.ID)
    return nil
}

func (p *MockEventPublisher) PublishUserActivated(ctx context.Context, user *domain.User) error {
    log.Printf("Event: User activated - %s", user.ID)
    return nil
}

func (p *MockEventPublisher) PublishOrderPlaced(ctx context.Context, order *domain.Order) error {
    log.Printf("Event: Order placed - %s", order.ID)
    return nil
}

func main() {
    // Initialize database
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/hexarch?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Initialize secondary adapters (driven/output)
    userRepo := persistence.NewPostgresUserRepository(db)
    emailService := notification.NewEmailService("smtp.example.com", 587, "[email protected]")
    idGen := &UUIDGenerator{}
    eventPub := &MockEventPublisher{}

    // Initialize core service
    userService := service.NewUserService(userRepo, emailService, idGen, eventPub)

    // Initialize primary adapters (driving/input)
    httpHandler := httpAdapter.NewUserHandler(userService)

    // Setup routes
    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", httpHandler.CreateUser)
    mux.HandleFunc("GET /users/{id}", httpHandler.GetUser)
    mux.HandleFunc("PUT /users/{id}", httpHandler.UpdateUser)
    mux.HandleFunc("DELETE /users/{id}", httpHandler.DeleteUser)

    // Start server
    log.Println("Server starting on :8080")
    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatal(err)
    }
}

Testing with Mock Adapters

package service_test

import (
    "context"
    "testing"

    "myapp/internal/core/domain"
    "myapp/internal/core/service"
)

// Mock repository
type mockUserRepository struct {
    users map[string]*domain.User
}

func newMockUserRepository() *mockUserRepository {
    return &mockUserRepository{
        users: make(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) FindByID(ctx context.Context, id string) (*domain.User, error) {
    return m.users[id], nil
}

func (m *mockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
    for _, user := range m.users {
        if user.Email.String() == email {
            return user, nil
        }
    }
    return nil, nil
}

// Mock notification service
type mockNotificationService struct {
    sentEmails []string
}

func (m *mockNotificationService) SendWelcomeEmail(ctx context.Context, user *domain.User) error {
    m.sentEmails = append(m.sentEmails, user.Email.String())
    return nil
}

// Mock ID generator
type mockIDGenerator struct {
    id int
}

func (m *mockIDGenerator) GenerateID() string {
    m.id++
    return fmt.Sprintf("user-%d", m.id)
}

// Mock event publisher
type mockEventPublisher struct{}

func (m *mockEventPublisher) PublishUserCreated(ctx context.Context, user *domain.User) error {
    return nil
}

func TestCreateUser(t *testing.T) {
    // Arrange
    repo := newMockUserRepository()
    notif := &mockNotificationService{}
    idGen := &mockIDGenerator{}
    eventPub := &mockEventPublisher{}

    service := service.NewUserService(repo, notif, idGen, eventPub)

    // Act
    user, err := service.CreateUser(context.Background(), "[email protected]", "Test User")

    // Assert
    if err != nil {
        t.Fatalf("expected no error, got %v", err)
    }

    if user.Email.String() != "[email protected]" {
        t.Errorf("expected email [email protected], got %s", user.Email.String())
    }

    if len(notif.sentEmails) != 1 {
        t.Errorf("expected 1 welcome email, got %d", len(notif.sentEmails))
    }
}

Best Practices

  1. Port Definition: Define ports (interfaces) in the core domain
  2. Adapter Independence: Adapters should not know about each other
  3. Domain First: Design domain model before thinking about adapters
  4. Single Responsibility: Each adapter handles one external concern
  5. Configuration Injection: Inject configuration into adapters, not core
  6. Error Handling: Let domain define error types
  7. Testing: Use mock adapters for testing core logic

Common Pitfalls

  1. Adapter Coupling: Adapters depending on each other directly
  2. Leaky Abstractions: Infrastructure details leaking into core
  3. Anemic Ports: Ports that are too thin or just data transfer
  4. Adapter in Core: Importing adapter packages in core
  5. Forgetting Symmetry: Treating primary and secondary adapters differently
  6. Over-abstraction: Creating too many small ports

When to Use Hexagonal Architecture

Use When:

  • Multiple interfaces to same business logic (REST, gRPC, CLI)
  • Need to swap implementations frequently
  • Testing is critical priority
  • Building systems that will evolve significantly
  • Team understands and values the pattern

Avoid When:

  • Simple CRUD applications
  • Rapid prototyping phase
  • Team unfamiliar with ports/adapters pattern
  • Project has very stable requirements
  • Overhead doesn’t justify benefits

Advantages

  • True testability without external dependencies
  • Easy to swap implementations
  • Clear separation between business logic and infrastructure
  • Symmetric treatment of all external systems
  • Domain-centric design
  • Framework independence

Disadvantages

  • More initial setup and boilerplate
  • Learning curve for team members
  • Can be overkill for simple applications
  • More files and directories
  • Requires discipline to maintain boundaries

Hexagonal Architecture provides excellent flexibility and testability by treating all external systems symmetrically through ports and adapters, making it ideal for systems that need to adapt to changing requirements.


Go Architecture Patterns Series: ← Clean Architecture | Series Overview | Next: Domain-Driven Design →