Hexagonal Architecture in Go: Ports and Adapters Pattern

    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 %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TB subgraph "Primary Adapters (Driving)" HTTP[HTTP REST API] CLI[CLI Interface] GRPC[gRPC Server] end subgraph "Hexagon (Core Domain)" PORT1[Primary PortsDriving Interfaces] CORE[Business LogicDomain ModelsUse Cases] PORT2[Secondary PortsDriven 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:#78350f,color:#fff style PORT1 fill:#1e3a5f,color:#fff style PORT2 fill:#1e3a5f,color:#fff style HTTP fill:#134e4a,color:#fff style DB fill:#4c1d95,color:#fff Ports and Adapters Visualization %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph LR subgraph "Primary Side (Driving)" REST[REST Adapter] GraphQL[GraphQL Adapter] end subgraph "Core Application" IP[Input PortInterface] BL[Business Logic] OP[Output PortInterface] 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:#78350f,color:#fff style IP fill:#1e3a5f,color:#fff style OP fill:#1e3a5f,color:#fff style REST fill:#134e4a,color:#fff style DBAdapter fill:#4c1d95,color:#fff Complete Hexagonal Flow %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% 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 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: ...

    January 11, 2025 · 17 min · Rafiul Alam

    The Two-Tier API Strategy: Why You Need Both REST and RPC (and How to Manage It)

    The API Dilemma: REST vs RPC? For years, teams have debated REST vs RPC as if they were mutually exclusive choices. The truth? You need both. Modern applications benefit from a two-tier API strategy that leverages REST for external clients and RPC for internal services. This isn’t about choosing sides-it’s about using the right tool for each job. Understanding the Two Tiers Tier 1: REST for External APIs (The Public Face) Use REST when: ...

    January 10, 2025 · 12 min · Rafiul Alam

    Redis Streams in Go: Lightweight Event Streaming

    {{series_nav(current_post=9)}} Redis Streams is a powerful data structure that enables log-style data storage and messaging. Introduced in Redis 5.0, it combines the simplicity of Redis with sophisticated stream processing capabilities, making it ideal for building real-time data pipelines, activity feeds, and event sourcing systems without the operational overhead of dedicated streaming platforms. What are Redis Streams? Redis Streams is an append-only log data structure that allows producers to add entries and multiple consumers to read from them. It supports consumer groups for distributed processing, automatic ID generation, and a pending entry list (PEL) for tracking message acknowledgments. ...

    January 9, 2025 · 19 min · Rafiul Alam

    Ebiten Game Development: First Steps

    Ebiten Game Development Series: Part 1: First Steps | Part 2: Core Concepts → What is Ebiten? Ebiten is a dead-simple 2D game engine for Go. Unlike heavyweight engines with complex editors and asset pipelines, Ebiten gives you a minimalist foundation: a game loop, a way to draw pixels, and input handling. Everything else? You build it yourself. This simplicity is Ebiten’s superpower. You’re not fighting an editor or memorizing a sprawling API. You write Go code that runs 60 times per second and draws rectangles. From those humble beginnings, you can build anything from Pong to procedurally generated roguelikes. ...

    December 15, 2024 · 12 min · Rafiul Alam

    Mastering Go Concurrency: The Coffee Shop Guide to Goroutines

    Go Concurrency Patterns Series: Series Overview | Goroutine Basics | Channel Fundamentals Introduction: Welcome to Go Coffee Shop Imagine running a busy coffee shop. You have customers placing orders, baristas making drinks, shared equipment like espresso machines and milk steamers, and the constant challenge of managing it all efficiently. This is exactly what concurrent programming in Go is like - and goroutines are your baristas! In this comprehensive guide, we’ll explore Go’s concurrency patterns through the lens of running a coffee shop. By the end, you’ll understand not just how to write concurrent Go code, but why these patterns work and when to use them. ...

    December 15, 2024 · 30 min · Rafiul Alam

    Go Design Pattern: Iterator

    📚 Go Design Patterns 🎯Behavioral Pattern ← Observer Pattern 📋 All Patterns State Pattern → What is Iterator Pattern? The Iterator pattern provides a way to access elements of a collection sequentially without exposing the underlying representation. It’s like having a remote control for your TV - you don’t need to know how the channels are stored internally, you just press “next” to move through them. ...

    November 13, 2024 · 11 min · Rafiul Alam

    Managing UI Navigation with Pushdown Automata in Ebitengine

    The UI Navigation Problem Game UI often involves stacked screens: you open a pause menu, then settings, then graphics options, then a confirmation dialog. Each screen needs to: Pause the screen beneath it Handle input independently Resume the previous screen when closed Maintain state across transitions Simple state machines fall short here. You need something that can track a stack of states. Enter the pushdown automaton. What is a Pushdown Automaton? A pushdown automaton is a state machine with a stack. Instead of just transitioning between states, you can: ...

    November 3, 2024 · 8 min · Rafiul Alam

    Saving Game State: Implementing the Memento Pattern with encoding/gob

    The Save/Load Problem Every game needs to save player progress. But how do you capture the entire game state without exposing internal implementation details? How do you support undo/redo, time travel debugging, or replay systems? The Memento pattern solves this by capturing and externalizing an object’s internal state without violating encapsulation. Combined with Go’s encoding/gob package, you get powerful, type-safe serialization for game saves, undo systems, and more. The Naive Approach Here’s what not to do: ...

    October 12, 2024 · 9 min · Rafiul Alam

    Goal-Oriented Action Planning (GOAP): Writing Smarter NPCs in Go

    Beyond Scripted AI Most game NPCs follow scripted behaviors or state machines: “If enemy seen, attack. If health low, flee.” While predictable and easy to implement, these approaches lack the intelligence to adapt to changing circumstances. What if your NPC could plan their own actions based on goals? Goal-Oriented Action Planning (GOAP) empowers NPCs to dynamically create plans to achieve their goals. Used in games like F.E.A.R. and The Sims, GOAP creates emergent, intelligent behaviors that feel surprisingly alive. ...

    September 19, 2024 · 9 min · Rafiul Alam

    Pipeline Patterns: Streaming Data Processing with Goroutines

    The Power of Streaming Pipelines Imagine processing a million log entries. The naive approach loads everything into memory, processes it, then outputs results. But what if you don’t have enough RAM? What if you want results streaming in real-time? Pipeline patterns break complex processing into stages connected by channels. Data flows through the pipeline, with each stage transforming it concurrently. It’s Unix pipes meets goroutines-and it’s beautiful. The Sequential Approach Here’s what we’re moving away from: ...

    August 30, 2024 · 8 min · Rafiul Alam