agent-rules-kit
Version:
Bootstrap of **Cursor** rules (`.mdc`) and mirror documentation (`.md`) for AI agent-guided projects.
813 lines (664 loc) • 21.6 kB
Markdown
---
description: Hexagonal (Ports & Adapters) architecture for Go applications
globs: <root>/**/*.go
alwaysApply: false
---
# Hexagonal Architecture in Go
This document explains how to implement the Hexagonal Architecture pattern (also known as Ports and Adapters) in Go applications.
## Core Concepts
Hexagonal Architecture, introduced by Alistair Cockburn, organizes the application into layers:
1. **Domain** (Application Core): Contains the business logic
2. **Ports**: Interfaces that define how the application core interacts with the outside world
3. **Adapters**: Implementations of those interfaces that connect to external systems
The key benefit is that the domain doesn't depend on any external concerns, making it easier to test and maintain.
## Project Structure
A typical Go project using Hexagonal Architecture:
```
myapp/
├── cmd/ # Application entry points
│ └── api/ # Web API entry point
│ └── main.go
├── internal/ # Private application code
│ ├── core/ # Application core
│ │ ├── domain/ # Domain entities and logic
│ │ │ ├── user.go
│ │ │ └── errors.go
│ │ └── services/ # Business logic
│ │ └── user_service.go
│ ├── ports/ # Interface definitions
│ │ ├── repositories/ # Data access interfaces (secondary ports)
│ │ │ └── user_repository.go
│ │ └── handlers/ # API interfaces (primary ports)
│ │ └── user_handler.go
│ └── adapters/ # Interface implementations
│ ├── repositories/ # Secondary adapters (outbound)
│ │ ├── postgres/
│ │ │ └── user_repository.go
│ │ └── mongodb/
│ │ └── user_repository.go
│ └── handlers/ # Primary adapters (inbound)
│ ├── rest/
│ │ └── user_handler.go
│ └── grpc/
│ └── user_handler.go
└── pkg/ # Public libraries
└── common/ # Shared utilities
```
## Implementation Example
### Domain Entities
```go
// internal/core/domain/user.go
package domain
import (
"errors"
"time"
)
// User represents a user in the system
type User struct {
ID string
Email string
Name string
CreatedAt time.Time
}
// NewUser creates a new user entity
func NewUser(email, name string) (*User, error) {
if email == "" {
return nil, errors.New("email cannot be empty")
}
if name == "" {
return nil, errors.New("name cannot be empty")
}
return &User{
Email: email,
Name: name,
CreatedAt: time.Now(),
}, nil
}
// Validate validates the user entity
func (u *User) Validate() error {
if u.Email == "" {
return errors.New("email cannot be empty")
}
if u.Name == "" {
return errors.New("name cannot be empty")
}
return nil
}
```
### Domain Errors
```go
// internal/core/domain/errors.go
package domain
import "errors"
// Domain errors
var (
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidInput = errors.New("invalid input")
)
```
### Ports (Interfaces)
Primary port (inbound):
```go
// internal/ports/handlers/user_handler.go
package handlers
import "context"
// UserHandler defines the interface for handling user-related requests
type UserHandler interface {
GetUser(ctx context.Context, id string) (interface{}, error)
CreateUser(ctx context.Context, input interface{}) (interface{}, error)
UpdateUser(ctx context.Context, id string, input interface{}) (interface{}, error)
DeleteUser(ctx context.Context, id string) error
ListUsers(ctx context.Context) (interface{}, error)
}
```
Secondary port (outbound):
```go
// internal/ports/repositories/user_repository.go
package repositories
import (
"context"
"myapp/internal/core/domain"
)
// UserRepository defines the interface for user data access
type UserRepository interface {
GetByID(ctx context.Context, id string) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error)
Save(ctx context.Context, user *domain.User) error
Delete(ctx context.Context, id string) error
List(ctx context.Context) ([]*domain.User, error)
}
```
### Domain Services
```go
// internal/core/services/user_service.go
package services
import (
"context"
"myapp/internal/core/domain"
"myapp/internal/ports/repositories"
)
// UserService contains the business logic for user operations
type UserService struct {
userRepo repositories.UserRepository
}
// NewUserService creates a new user service
func NewUserService(userRepo repositories.UserRepository) *UserService {
return &UserService{
userRepo: userRepo,
}
}
// GetUser retrieves a user by ID
func (s *UserService) GetUser(ctx context.Context, id string) (*domain.User, error) {
return s.userRepo.GetByID(ctx, id)
}
// 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.userRepo.GetByEmail(ctx, email)
if err == nil && existingUser != nil {
return nil, domain.ErrUserAlreadyExists
}
// Create new user entity
user, err := domain.NewUser(email, name)
if err != nil {
return nil, err
}
// Save user
if err := s.userRepo.Save(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// UpdateUser updates a user's information
func (s *UserService) UpdateUser(ctx context.Context, id, email, name string) (*domain.User, error) {
// Get existing user
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Update fields
if email != "" {
user.Email = email
}
if name != "" {
user.Name = name
}
// Validate
if err := user.Validate(); err != nil {
return nil, err
}
// Save
if err := s.userRepo.Save(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// DeleteUser deletes a user
func (s *UserService) DeleteUser(ctx context.Context, id string) error {
return s.userRepo.Delete(ctx, id)
}
// ListUsers lists all users
func (s *UserService) ListUsers(ctx context.Context) ([]*domain.User, error) {
return s.userRepo.List(ctx)
}
```
### Adapters
Secondary adapter (repository implementation):
```go
// internal/adapters/repositories/postgres/user_repository.go
package postgres
import (
"context"
"database/sql"
"errors"
"myapp/internal/core/domain"
"github.com/google/uuid"
)
// UserRepository implements the user repository interface with PostgreSQL
type UserRepository struct {
db *sql.DB
}
// NewUserRepository creates a new PostgreSQL user repository
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{
db: db,
}
}
// GetByID retrieves a user by ID
func (r *UserRepository) GetByID(ctx context.Context, id string) (*domain.User, error) {
query := `SELECT id, email, name, created_at FROM users WHERE id = $1`
var user domain.User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
&user.CreatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrUserNotFound
}
return nil, err
}
return &user, nil
}
// GetByEmail retrieves a user by email
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
query := `SELECT id, email, name, created_at FROM users WHERE email = $1`
var user domain.User
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.Email,
&user.Name,
&user.CreatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrUserNotFound
}
return nil, err
}
return &user, nil
}
// Save persists a user to the database
func (r *UserRepository) Save(ctx context.Context, user *domain.User) error {
// Generate ID for new users
if user.ID == "" {
user.ID = uuid.New().String()
}
query := `
INSERT INTO users (id, email, name, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET email = $2, name = $3
`
_, err := r.db.ExecContext(
ctx,
query,
user.ID,
user.Email,
user.Name,
user.CreatedAt,
)
return err
}
// Delete removes a user
func (r *UserRepository) 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
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return domain.ErrUserNotFound
}
return nil
}
// List retrieves all users
func (r *UserRepository) List(ctx context.Context) ([]*domain.User, error) {
query := `SELECT id, email, name, created_at FROM users ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var users []*domain.User
for rows.Next() {
var user domain.User
if err := rows.Scan(
&user.ID,
&user.Email,
&user.Name,
&user.CreatedAt,
); err != nil {
return nil, err
}
users = append(users, &user)
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}
```
Primary adapter (REST API handler):
```go
// internal/adapters/handlers/rest/user_handler.go
package rest
import (
"encoding/json"
"net/http"
"myapp/internal/core/domain"
"myapp/internal/core/services"
"github.com/go-chi/chi/v5"
)
// UserRequest represents the incoming user data
type UserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
// UserResponse represents the outgoing user data
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
// UserHandler implements the REST API handler for users
type UserHandler struct {
userService *services.UserService
}
// NewUserHandler creates a new REST API handler for users
func NewUserHandler(userService *services.UserService) *UserHandler {
return &UserHandler{
userService: userService,
}
}
// mapUserToResponse maps a domain user to a response user
func mapUserToResponse(user *domain.User) UserResponse {
return UserResponse{
ID: user.ID,
Email: user.Email,
Name: user.Name,
CreatedAt: user.CreatedAt.Format(time.RFC3339),
}
}
// GetUser handles the get user request
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
user, err := h.userService.GetUser(r.Context(), id)
if err != nil {
if err == domain.ErrUserNotFound {
http.Error(w, "User not found", http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
response := mapUserToResponse(user)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// CreateUser handles the create user request
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req UserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
user, err := h.userService.CreateUser(r.Context(), req.Email, req.Name)
if err != nil {
switch err {
case domain.ErrUserAlreadyExists:
http.Error(w, "User already exists", http.StatusConflict)
case domain.ErrInvalidInput:
http.Error(w, "Invalid input", http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
response := mapUserToResponse(user)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// UpdateUser handles the update user request
func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req UserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
user, err := h.userService.UpdateUser(r.Context(), id, req.Email, req.Name)
if err != nil {
switch err {
case domain.ErrUserNotFound:
http.Error(w, "User not found", http.StatusNotFound)
case domain.ErrInvalidInput:
http.Error(w, "Invalid input", http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
response := mapUserToResponse(user)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// DeleteUser handles the delete user request
func (h *UserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.userService.DeleteUser(r.Context(), id); err != nil {
if err == domain.ErrUserNotFound {
http.Error(w, "User not found", http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListUsers handles the list users request
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.userService.ListUsers(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var response []UserResponse
for _, user := range users {
response = append(response, mapUserToResponse(user))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// RegisterRoutes registers the user handler routes
func (h *UserHandler) RegisterRoutes(r chi.Router) {
r.Get("/users", h.ListUsers)
r.Post("/users", h.CreateUser)
r.Get("/users/{id}", h.GetUser)
r.Put("/users/{id}", h.UpdateUser)
r.Delete("/users/{id}", h.DeleteUser)
}
```
### Wiring It All Together
```go
// cmd/api/main.go
package main
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"myapp/internal/adapters/handlers/rest"
"myapp/internal/adapters/repositories/postgres"
"myapp/internal/core/services"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
_ "github.com/lib/pq"
)
func main() {
// Setup database
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
// Setup repositories
userRepo := postgres.NewUserRepository(db)
// Setup services
userService := services.NewUserService(userRepo)
// Setup handlers
userHandler := rest.NewUserHandler(userService)
// Setup router
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Register routes
r.Route("/api", func(r chi.Router) {
userHandler.RegisterRoutes(r)
})
// Create server
server := &http.Server{
Addr: ":8080",
Handler: r,
}
// Start server
go func() {
log.Println("Starting server on :8080")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Handle graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server shutdown failed: %v", err)
}
log.Println("Server stopped")
}
```
## Testing in Hexagonal Architecture
One of the key benefits of hexagonal architecture is testability. Here's how to test each layer:
### Testing Domain Logic
```go
// internal/core/domain/user_test.go
package domain_test
import (
"testing"
"myapp/internal/core/domain"
)
func TestNewUser(t *testing.T) {
tests := []struct {
name string
email string
username string
wantErr bool
}{
{"valid user", "test@example.com", "testuser", false},
{"empty email", "", "testuser", true},
{"empty name", "test@example.com", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user, err := domain.NewUser(tt.email, tt.username)
if tt.wantErr {
if err == nil {
t.Errorf("Expected error but got nil")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if user.Email != tt.email {
t.Errorf("Expected email %s, got %s", tt.email, user.Email)
}
if user.Name != tt.username {
t.Errorf("Expected name %s, got %s", tt.username, user.Name)
}
})
}
}
```
### Testing Services with Mocks
```go
// internal/core/services/user_service_test.go
package services_test
import (
"context"
"testing"
"errors"
"myapp/internal/core/domain"
"myapp/internal/core/services"
"myapp/internal/ports/repositories"
)
// MockUserRepository is a mock implementation of the UserRepository interface
type MockUserRepository struct {
users map[string]*domain.User
}
func NewMockUserRepository() *MockUserRepository {
return &MockUserRepository{
users: make(map[string]*domain.User),
}
}
func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*domain.User, error) {
user, exists := m.users[id]
if !exists {
return nil, domain.ErrUserNotFound
}
return user, nil
}
func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
for _, user := range m.users {
if user.Email == email {
return user, nil
}
}
return nil, domain.ErrUserNotFound
}
func (m *MockUserRepository) Save(ctx context.Context, user *domain.User) error {
m.users[user.ID] = user
return nil
}
func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
if _, exists := m.users[id]; !exists {
return domain.ErrUserNotFound
}
delete(m.users, id)
return nil
}
func (m *MockUserRepository) List(ctx context.Context) ([]*domain.User, error) {
users := make([]*domain.User, 0, len(m.users))
for _, user := range m.users {
users = append(users, user)
}
return users, nil
}
func TestUserService_CreateUser(t *testing.T) {
mockRepo := NewMockUserRepository()
service := services.NewUserService(mockRepo)
// Test successful creation
user, err := service.CreateUser(context.Background(), "test@example.com", "Test User")
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
if user.Email != "test@example.com" {
t.Errorf("Expected email test@example.com, got %s", user.Email)
}
// Test duplicate user
_, err = service.CreateUser(context.Background(), "test@example.com", "Another User")
if err != domain.ErrUserAlreadyExists {
t.Errorf("Expected error %v, got %v", domain.ErrUserAlreadyExists, err)
}
}
```
## Key Benefits of Hexagonal Architecture
1. **Separation of Concerns**: Clear boundaries between business logic and external systems
2. **Testability**: Easy to test the core domain in isolation
3. **Flexibility**: Swap out adapters without changing the core logic
4. **Independence from Frameworks**: Core business logic isn't tied to any specific framework
5. **Maintainability**: Clear structure makes the codebase easier to maintain
## Best Practices
1. **Keep the Domain Pure**: The domain should have no dependencies on external systems
2. **Design Interfaces First**: Start by defining the ports before implementing the adapters
3. **Use Dependency Injection**: Inject dependencies through constructors
4. **Handle Cross-Cutting Concerns**: Use middleware or decorators for logging, metrics, etc.
5. **Respect Package Boundaries**: Don't import from adapters into the core domain