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
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
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
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
- Port Definition: Define ports (interfaces) in the core domain
- Adapter Independence: Adapters should not know about each other
- Domain First: Design domain model before thinking about adapters
- Single Responsibility: Each adapter handles one external concern
- Configuration Injection: Inject configuration into adapters, not core
- Error Handling: Let domain define error types
- Testing: Use mock adapters for testing core logic
Common Pitfalls
- Adapter Coupling: Adapters depending on each other directly
- Leaky Abstractions: Infrastructure details leaking into core
- Anemic Ports: Ports that are too thin or just data transfer
- Adapter in Core: Importing adapter packages in core
- Forgetting Symmetry: Treating primary and secondary adapters differently
- 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 →