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
- Layered Architecture: Horizontal layers (presentation, business, data)
- Microservices: Independent services with separate databases
- Event-Driven: Components communicate through events
- CQRS: Separate read and write models
- 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
- Domain-Driven Design: https://www.domainlanguage.com/ddd/
- Microservices Patterns: https://microservices.io/
- CQRS Pattern: https://martinfowler.com/bliki/CQRS.html
- Event Sourcing: https://martinfowler.com/eaaDev/EventSourcing.html
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