Skip to main content
โšก Calmops

Architectural Patterns in Go

Architectural Patterns in Go

Introduction

Architectural patterns provide proven solutions for organizing large-scale applications. They define how components interact, how data flows, and how the system scales. Choosing the right architecture is crucial for building maintainable, scalable Go applications.

This guide covers the most important architectural patterns used in Go: layered architecture, microservices, event-driven architecture, and CQRS. Each pattern has trade-offs, and understanding them helps you make informed decisions.

Core Concepts

What is an Architectural Pattern?

An architectural pattern is a high-level solution to a recurring problem in software architecture. It defines:

  • Component organization: How to structure the application
  • Communication patterns: How components interact
  • Data flow: How information moves through the system
  • Scalability approach: How the system grows

Common Architectural Patterns

  1. Layered Architecture: Horizontal layers (presentation, business, data)
  2. Microservices: Independent services with separate databases
  3. Event-Driven: Components communicate through events
  4. CQRS: Separate read and write models
  5. Hexagonal: Core domain isolated from external concerns

Good: Layered Architecture

Basic Layered Structure

package main

import (
	"fmt"
	"sync"
)

// โœ… GOOD: Layered architecture with clear separation

// Domain Layer - Business logic
type User struct {
	ID    int
	Name  string
	Email string
}

type UserService interface {
	CreateUser(name, email string) (*User, error)
	GetUser(id int) (*User, error)
	UpdateUser(id int, name, email string) error
}

// Repository Layer - Data access
type UserRepository interface {
	Save(user *User) error
	FindByID(id int) (*User, error)
	Update(user *User) error
}

type InMemoryUserRepository struct {
	mu    sync.RWMutex
	users map[int]*User
	nextID int
}

func (r *InMemoryUserRepository) Save(user *User) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	
	user.ID = r.nextID
	r.nextID++
	r.users[user.ID] = user
	return nil
}

func (r *InMemoryUserRepository) FindByID(id int) (*User, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	
	user, ok := r.users[id]
	if !ok {
		return nil, fmt.Errorf("user not found")
	}
	return user, nil
}

func (r *InMemoryUserRepository) Update(user *User) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	
	r.users[user.ID] = user
	return nil
}

// Service Layer - Business logic
type DefaultUserService struct {
	repo UserRepository
}

func (s *DefaultUserService) CreateUser(name, email string) (*User, error) {
	user := &User{Name: name, Email: email}
	if err := s.repo.Save(user); err != nil {
		return nil, err
	}
	return user, nil
}

func (s *DefaultUserService) GetUser(id int) (*User, error) {
	return s.repo.FindByID(id)
}

func (s *DefaultUserService) UpdateUser(id int, name, email string) error {
	user, err := s.repo.FindByID(id)
	if err != nil {
		return err
	}
	
	user.Name = name
	user.Email = email
	return s.repo.Update(user)
}

// Handler Layer - HTTP handlers
type UserHandler struct {
	service UserService
}

func (h *UserHandler) HandleCreateUser(name, email string) {
	user, err := h.service.CreateUser(name, email)
	if err != nil {
		fmt.Printf("Error: %v\n", err)
		return
	}
	fmt.Printf("Created user: %v\n", user)
}

func main() {
	// Setup layers
	repo := &InMemoryUserRepository{users: make(map[int]*User)}
	service := &DefaultUserService{repo: repo}
	handler := &UserHandler{service: service}
	
	// Use the application
	handler.HandleCreateUser("John", "[email protected]")
	handler.HandleCreateUser("Jane", "[email protected]")
}

Good: Microservices Architecture

Service Structure

package main

import (
	"fmt"
	"net/http"
)

// โœ… GOOD: Microservices with independent services

// User Service
type UserService struct {
	port int
}

func (s *UserService) Start() {
	http.HandleFunc("/users", s.handleUsers)
	fmt.Printf("User service listening on :%d\n", s.port)
	http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil)
}

func (s *UserService) handleUsers(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"users": []}`))
}

// Order Service
type OrderService struct {
	port int
	userServiceURL string
}

func (s *OrderService) Start() {
	http.HandleFunc("/orders", s.handleOrders)
	fmt.Printf("Order service listening on :%d\n", s.port)
	http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil)
}

func (s *OrderService) handleOrders(w http.ResponseWriter, r *http.Request) {
	// Call user service
	resp, _ := http.Get(s.userServiceURL + "/users")
	defer resp.Body.Close()
	
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"orders": []}`))
}

func main() {
	// Each service runs independently
	userService := &UserService{port: 8001}
	orderService := &OrderService{
		port: 8002,
		userServiceURL: "http://localhost:8001",
	}
	
	go userService.Start()
	go orderService.Start()
	
	select {}
}

Good: Event-Driven Architecture

Event Bus Pattern

package main

import (
	"fmt"
	"sync"
)

// โœ… GOOD: Event-driven architecture

// Event represents a domain event
type Event interface {
	EventType() string
}

type UserCreatedEvent struct {
	UserID int
	Name   string
	Email  string
}

func (e *UserCreatedEvent) EventType() string {
	return "user.created"
}

type OrderCreatedEvent struct {
	OrderID int
	UserID  int
	Amount  float64
}

func (e *OrderCreatedEvent) EventType() string {
	return "order.created"
}

// EventHandler handles events
type EventHandler func(event Event) error

// EventBus manages event publishing and subscription
type EventBus struct {
	mu        sync.RWMutex
	handlers  map[string][]EventHandler
}

func NewEventBus() *EventBus {
	return &EventBus{
		handlers: make(map[string][]EventHandler),
	}
}

func (b *EventBus) Subscribe(eventType string, handler EventHandler) {
	b.mu.Lock()
	defer b.mu.Unlock()
	
	b.handlers[eventType] = append(b.handlers[eventType], handler)
}

func (b *EventBus) Publish(event Event) error {
	b.mu.RLock()
	handlers := b.handlers[event.EventType()]
	b.mu.RUnlock()
	
	for _, handler := range handlers {
		if err := handler(event); err != nil {
			return err
		}
	}
	return nil
}

// Domain services
type UserDomainService struct {
	eventBus *EventBus
}

func (s *UserDomainService) CreateUser(id int, name, email string) error {
	// Create user
	event := &UserCreatedEvent{
		UserID: id,
		Name:   name,
		Email:  email,
	}
	
	// Publish event
	return s.eventBus.Publish(event)
}

// Event handlers
func HandleUserCreated(event Event) error {
	e := event.(*UserCreatedEvent)
	fmt.Printf("User created: %s (%s)\n", e.Name, e.Email)
	return nil
}

func HandleUserCreatedForNotification(event Event) error {
	e := event.(*UserCreatedEvent)
	fmt.Printf("Sending welcome email to %s\n", e.Email)
	return nil
}

func main() {
	eventBus := NewEventBus()
	
	// Subscribe handlers
	eventBus.Subscribe("user.created", HandleUserCreated)
	eventBus.Subscribe("user.created", HandleUserCreatedForNotification)
	
	// Create service
	userService := &UserDomainService{eventBus: eventBus}
	
	// Create user (triggers events)
	userService.CreateUser(1, "John", "[email protected]")
}

Good: CQRS Pattern

Command Query Responsibility Segregation

package main

import (
	"fmt"
	"sync"
)

// โœ… GOOD: CQRS pattern

// Commands
type Command interface {
	CommandType() string
}

type CreateUserCommand struct {
	Name  string
	Email string
}

func (c *CreateUserCommand) CommandType() string {
	return "create_user"
}

// Queries
type Query interface {
	QueryType() string
}

type GetUserQuery struct {
	UserID int
}

func (q *GetUserQuery) QueryType() string {
	return "get_user"
}

// Write Model
type WriteModel struct {
	mu    sync.Mutex
	users map[int]map[string]interface{}
	nextID int
}

func (m *WriteModel) CreateUser(cmd *CreateUserCommand) (int, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	
	id := m.nextID
	m.nextID++
	
	m.users[id] = map[string]interface{}{
		"name":  cmd.Name,
		"email": cmd.Email,
	}
	
	return id, nil
}

// Read Model (optimized for queries)
type ReadModel struct {
	mu    sync.RWMutex
	users map[int]map[string]interface{}
}

func (m *ReadModel) GetUser(id int) (map[string]interface{}, error) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	
	user, ok := m.users[id]
	if !ok {
		return nil, fmt.Errorf("user not found")
	}
	return user, nil
}

// Command Handler
type CommandHandler struct {
	writeModel *WriteModel
	readModel  *ReadModel
}

func (h *CommandHandler) HandleCreateUser(cmd *CreateUserCommand) error {
	id, err := h.writeModel.CreateUser(cmd)
	if err != nil {
		return err
	}
	
	// Update read model
	h.readModel.mu.Lock()
	h.readModel.users[id] = map[string]interface{}{
		"name":  cmd.Name,
		"email": cmd.Email,
	}
	h.readModel.mu.Unlock()
	
	return nil
}

// Query Handler
type QueryHandler struct {
	readModel *ReadModel
}

func (h *QueryHandler) HandleGetUser(query *GetUserQuery) (map[string]interface{}, error) {
	return h.readModel.GetUser(query.UserID)
}

func main() {
	writeModel := &WriteModel{users: make(map[int]map[string]interface{})}
	readModel := &ReadModel{users: make(map[int]map[string]interface{})}
	
	cmdHandler := &CommandHandler{writeModel, readModel}
	queryHandler := &QueryHandler{readModel}
	
	// Execute command
	cmd := &CreateUserCommand{Name: "John", Email: "[email protected]"}
	cmdHandler.HandleCreateUser(cmd)
	
	// Execute query
	query := &GetUserQuery{UserID: 0}
	user, _ := queryHandler.HandleGetUser(query)
	fmt.Printf("User: %v\n", user)
}

Bad: Monolithic Anti-Pattern

package main

// โŒ BAD: Everything mixed together
type BadApplication struct {
	// HTTP handlers
	// Database code
	// Business logic
	// Configuration
	// All in one struct!
}

func (app *BadApplication) HandleRequest(data string) {
	// Mix of concerns
	// Hard to test
	// Hard to scale
	// Hard to maintain
}

Best Practices

1. Choose Architecture Based on Requirements

// โœ… GOOD: Match architecture to needs
// Small app: Layered architecture
// Growing app: Microservices
// Real-time: Event-driven
// Complex reads/writes: CQRS

2. Define Clear Boundaries

// โœ… GOOD: Clear layer boundaries
// Presentation -> Service -> Repository -> Database
// Each layer has specific responsibilities
// Dependencies flow downward only

3. Use Dependency Injection

// โœ… GOOD: Inject dependencies
type Service struct {
	repo Repository
}

func NewService(repo Repository) *Service {
	return &Service{repo: repo}
}

// โŒ BAD: Create dependencies internally
type BadService struct{}

func (s *BadService) GetData() {
	repo := NewRepository() // Hard to test
}

Resources

Summary

Choosing the right architectural pattern is crucial for building scalable, maintainable Go applications. Start with layered architecture for simple applications, then evolve to microservices or event-driven patterns as complexity grows. Always maintain clear boundaries between layers and use dependency injection for testability.

Comments