Skip to main content
Version: v2.2.82 (latest)
Advanced · 04 / 04

Architecture Patterns

Design scalable functional applications with hexagonal architecture, clean architecture, domain-driven design, CQRS, event sourcing, and effect systems.

01

Hexagonal Architecture

Isolate business logic from infrastructure concerns using ports and adapters.

hexagonal_domain.go
package domain

import (
  "context"
  "github.com/your-org/fp-go/either"
)

// Domain model (pure)
type User struct {
  ID    string
  Email string
  Name  string
}

type UserError struct {
  Code    string
  Message string
}

// Port (interface)
type UserRepository interface {
  FindByID(ctx context.Context, id string) either.Either[UserError, User]
  Save(ctx context.Context, user User) either.Either[UserError, User]
}

// Domain service (pure business logic)
type UserService struct {
  repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
  return &UserService{repo: repo}
}

func (s *UserService) GetUser(ctx context.Context, id string) either.Either[UserError, User] {
  return s.repo.FindByID(ctx, id)
}

func (s *UserService) UpdateEmail(ctx context.Context, id, newEmail string) either.Either[UserError, User] {
  return either.Chain(
      s.repo.FindByID(ctx, id),
      func(user User) either.Either[UserError, User] {
          user.Email = newEmail
          return s.repo.Save(ctx, user)
      },
  )
}
hexagonal_adapter.go
package postgres

import (
  "context"
  "database/sql"
  "github.com/your-org/fp-go/either"
  "myapp/domain"
)

// Adapter (infrastructure)
type PostgresUserRepository struct {
  db *sql.DB
}

func NewPostgresUserRepository(db *sql.DB) *PostgresUserRepository {
  return &PostgresUserRepository{db: db}
}

func (r *PostgresUserRepository) FindByID(ctx context.Context, id string) either.Either[domain.UserError, domain.User] {
  var user domain.User
  err := r.db.QueryRowContext(ctx, "SELECT id, email, name FROM users WHERE id = $1", id).
      Scan(&user.ID, &user.Email, &user.Name)
  
  if err == sql.ErrNoRows {
      return either.Left[domain.UserError, domain.User](domain.UserError{
          Code:    "NOT_FOUND",
          Message: "User not found",
      })
  }
  
  if err != nil {
      return either.Left[domain.UserError, domain.User](domain.UserError{
          Code:    "DATABASE_ERROR",
          Message: err.Error(),
      })
  }
  
  return either.Right[domain.UserError](user)
}

func (r *PostgresUserRepository) Save(ctx context.Context, user domain.User) either.Either[domain.UserError, domain.User] {
  _, err := r.db.ExecContext(ctx,
      "UPDATE users SET email = $1, name = $2 WHERE id = $3",
      user.Email, user.Name, user.ID,
  )
  
  if err != nil {
      return either.Left[domain.UserError, domain.User](domain.UserError{
          Code:    "DATABASE_ERROR",
          Message: err.Error(),
      })
  }
  
  return either.Right[domain.UserError](user)
}
02

Clean Architecture

Organize code in concentric layers with dependency inversion.

clean_layers.go
// Layer 1: Entities (innermost - pure domain)
package entities

type Order struct {
  ID         string
  CustomerID string
  Items      []OrderItem
  Total      float64
}

type OrderItem struct {
  ProductID string
  Quantity  int
  Price     float64
}

// Layer 2: Use Cases (application logic)
package usecases

import (
  "context"
  "myapp/entities"
  "github.com/your-org/fp-go/either"
)

type OrderRepository interface {
  Save(ctx context.Context, order entities.Order) either.Either[error, entities.Order]
}

type PaymentGateway interface {
  Charge(ctx context.Context, amount float64, customerID string) either.Either[error, string]
}

type PlaceOrderUseCase struct {
  orderRepo OrderRepository
  payment   PaymentGateway
}

func NewPlaceOrderUseCase(repo OrderRepository, payment PaymentGateway) *PlaceOrderUseCase {
  return &PlaceOrderUseCase{
      orderRepo: repo,
      payment:   payment,
  }
}

func (uc *PlaceOrderUseCase) Execute(ctx context.Context, order entities.Order) either.Either[error, entities.Order] {
  // Charge payment
  chargeResult := uc.payment.Charge(ctx, order.Total, order.CustomerID)
  
  return either.Chain(
      chargeResult,
      func(transactionID string) either.Either[error, entities.Order] {
          // Save order
          return uc.orderRepo.Save(ctx, order)
      },
  )
}

// Layer 3: Interface Adapters (controllers, presenters)
package controllers

import (
  "encoding/json"
  "net/http"
  "myapp/usecases"
  "github.com/your-org/fp-go/either"
)

type OrderController struct {
  placeOrder *usecases.PlaceOrderUseCase
}

func NewOrderController(placeOrder *usecases.PlaceOrderUseCase) *OrderController {
  return &OrderController{placeOrder: placeOrder}
}

func (c *OrderController) PlaceOrder(w http.ResponseWriter, r *http.Request) {
  var req struct {
      CustomerID string  `json:"customer_id"`
      Items      []struct {
          ProductID string  `json:"product_id"`
          Quantity  int     `json:"quantity"`
          Price     float64 `json:"price"`
      } `json:"items"`
  }
  
  if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
      http.Error(w, err.Error(), http.StatusBadRequest)
      return
  }
  
  // Convert to domain entity
  order := entities.Order{
      CustomerID: req.CustomerID,
      Items:      make([]entities.OrderItem, len(req.Items)),
  }
  
  for i, item := range req.Items {
      order.Items[i] = entities.OrderItem{
          ProductID: item.ProductID,
          Quantity:  item.Quantity,
          Price:     item.Price,
      }
      order.Total += item.Price * float64(item.Quantity)
  }
  
  // Execute use case
  result := c.placeOrder.Execute(r.Context(), order)
  
  either.Match(
      result,
      func(err error) {
          http.Error(w, err.Error(), http.StatusInternalServerError)
      },
      func(order entities.Order) {
          w.Header().Set("Content-Type", "application/json")
          json.NewEncoder(w).Encode(order)
      },
  )
}

// Layer 4: Frameworks & Drivers (outermost - infrastructure)
package main

import (
  "database/sql"
  "net/http"
  "myapp/controllers"
  "myapp/infrastructure"
  "myapp/usecases"
)

func main() {
  db, _ := sql.Open("postgres", "connection-string")
  
  // Wire dependencies (outer to inner)
  orderRepo := infrastructure.NewPostgresOrderRepository(db)
  paymentGateway := infrastructure.NewStripePaymentGateway("api-key")
  
  placeOrderUC := usecases.NewPlaceOrderUseCase(orderRepo, paymentGateway)
  orderController := controllers.NewOrderController(placeOrderUC)
  
  http.HandleFunc("/orders", orderController.PlaceOrder)
  http.ListenAndServe(":8080", nil)
}
03

Domain-Driven Design

Model complex domains with aggregates, value objects, and domain events.

ddd_aggregate.go
package domain

import (
  "time"
  "github.com/your-org/fp-go/either"
)

// Value Object (immutable)
type Money struct {
  Amount   float64
  Currency string
}

func NewMoney(amount float64, currency string) either.Either[error, Money] {
  if amount < 0 {
      return either.Left[error, Money](errors.New("amount cannot be negative"))
  }
  return either.Right[error](Money{Amount: amount, Currency: currency})
}

// Entity
type OrderLine struct {
  ProductID string
  Quantity  int
  Price     Money
}

// Aggregate Root
type Order struct {
  id         string
  customerID string
  lines      []OrderLine
  status     OrderStatus
  events     []DomainEvent
}

type OrderStatus string

const (
  OrderPending   OrderStatus = "PENDING"
  OrderConfirmed OrderStatus = "CONFIRMED"
  OrderShipped   OrderStatus = "SHIPPED"
)

// Domain Event
type DomainEvent interface {
  OccurredAt() time.Time
}

type OrderConfirmed struct {
  OrderID    string
  occurredAt time.Time
}

func (e OrderConfirmed) OccurredAt() time.Time {
  return e.occurredAt
}

// Factory
func NewOrder(id, customerID string) *Order {
  return &Order{
      id:         id,
      customerID: customerID,
      lines:      []OrderLine{},
      status:     OrderPending,
      events:     []DomainEvent{},
  }
}

// Business logic (domain methods)
func (o *Order) AddLine(productID string, quantity int, price Money) either.Either[error, *Order] {
  if o.status != OrderPending {
      return either.Left[error, *Order](errors.New("cannot modify confirmed order"))
  }
  
  o.lines = append(o.lines, OrderLine{
      ProductID: productID,
      Quantity:  quantity,
      Price:     price,
  })
  
  return either.Right[error](o)
}

func (o *Order) Confirm() either.Either[error, *Order] {
  if len(o.lines) == 0 {
      return either.Left[error, *Order](errors.New("cannot confirm empty order"))
  }
  
  if o.status != OrderPending {
      return either.Left[error, *Order](errors.New("order already confirmed"))
  }
  
  o.status = OrderConfirmed
  o.events = append(o.events, OrderConfirmed{
      OrderID:    o.id,
      occurredAt: time.Now(),
  })
  
  return either.Right[error](o)
}

func (o *Order) Total() Money {
  total := 0.0
  currency := "USD"
  
  for _, line := range o.lines {
      total += line.Price.Amount * float64(line.Quantity)
      currency = line.Price.Currency
  }
  
  return Money{Amount: total, Currency: currency}
}

func (o *Order) DomainEvents() []DomainEvent {
  return o.events
}

func (o *Order) ClearEvents() {
  o.events = []DomainEvent{}
}
04

CQRS Pattern

Separate read and write models for scalability and optimization.

cqrs_commands.go
package commands

import (
  "context"
  "github.com/your-org/fp-go/either"
)

// Command (write model)
type CreateUserCommand struct {
  Email string
  Name  string
}

type CommandHandler[C any, R any] interface {
  Handle(ctx context.Context, cmd C) either.Either[error, R]
}

type CreateUserHandler struct {
  repo UserRepository
}

func (h *CreateUserHandler) Handle(ctx context.Context, cmd CreateUserCommand) either.Either[error, string] {
  // Validate
  if cmd.Email == "" {
      return either.Left[error, string](errors.New("email required"))
  }
  
  // Create user
  user := User{
      ID:    generateID(),
      Email: cmd.Email,
      Name:  cmd.Name,
  }
  
  // Save
  return either.Map(
      h.repo.Save(ctx, user),
      func(u User) string { return u.ID },
  )
}
cqrs_queries.go
package queries

import (
  "context"
  "github.com/your-org/fp-go/either"
)

// Query (read model - optimized for reads)
type UserListQuery struct {
  Page     int
  PageSize int
}

type UserListItem struct {
  ID    string
  Email string
  Name  string
}

type QueryHandler[Q any, R any] interface {
  Handle(ctx context.Context, query Q) either.Either[error, R]
}

type UserListHandler struct {
  readDB ReadDatabase
}

func (h *UserListHandler) Handle(ctx context.Context, query UserListQuery) either.Either[error, []UserListItem] {
  offset := (query.Page - 1) * query.PageSize
  
  // Query optimized read model (could be denormalized, cached, etc.)
  rows, err := h.readDB.Query(ctx,
      "SELECT id, email, name FROM user_list_view LIMIT $1 OFFSET $2",
      query.PageSize, offset,
  )
  
  if err != nil {
      return either.Left[error, []UserListItem](err)
  }
  defer rows.Close()
  
  var users []UserListItem
  for rows.Next() {
      var user UserListItem
      if err := rows.Scan(&user.ID, &user.Email, &user.Name); err != nil {
          return either.Left[error, []UserListItem](err)
      }
      users = append(users, user)
  }
  
  return either.Right[error](users)
}
05

Event Sourcing

Store state changes as a sequence of events for auditability and time travel.

event_sourcing.go
package eventsourcing

import (
  "time"
)

// Event
type Event interface {
  AggregateID() string
  OccurredAt() time.Time
  EventType() string
}

type AccountCreated struct {
  AccountID  string
  Owner      string
  occurredAt time.Time
}

func (e AccountCreated) AggregateID() string { return e.AccountID }
func (e AccountCreated) OccurredAt() time.Time { return e.occurredAt }
func (e AccountCreated) EventType() string { return "AccountCreated" }

type MoneyDeposited struct {
  AccountID  string
  Amount     float64
  occurredAt time.Time
}

func (e MoneyDeposited) AggregateID() string { return e.AccountID }
func (e MoneyDeposited) OccurredAt() time.Time { return e.occurredAt }
func (e MoneyDeposited) EventType() string { return "MoneyDeposited" }

type MoneyWithdrawn struct {
  AccountID  string
  Amount     float64
  occurredAt time.Time
}

func (e MoneyWithdrawn) AggregateID() string { return e.AccountID }
func (e MoneyWithdrawn) OccurredAt() time.Time { return e.occurredAt }
func (e MoneyWithdrawn) EventType() string { return "MoneyWithdrawn" }

// Aggregate (rebuilt from events)
type Account struct {
  ID      string
  Owner   string
  Balance float64
  version int
}

func NewAccount() *Account {
  return &Account{}
}

// Apply events to rebuild state
func (a *Account) Apply(event Event) {
  switch e := event.(type) {
  case AccountCreated:
      a.ID = e.AccountID
      a.Owner = e.Owner
      a.Balance = 0
  case MoneyDeposited:
      a.Balance += e.Amount
  case MoneyWithdrawn:
      a.Balance -= e.Amount
  }
  a.version++
}

// Event Store
type EventStore interface {
  Save(events []Event) error
  Load(aggregateID string) ([]Event, error)
}

// Repository
type AccountRepository struct {
  store EventStore
}

func (r *AccountRepository) Load(id string) (*Account, error) {
  events, err := r.store.Load(id)
  if err != nil {
      return nil, err
  }
  
  account := NewAccount()
  for _, event := range events {
      account.Apply(event)
  }
  
  return account, nil
}

func (r *AccountRepository) Save(account *Account, events []Event) error {
  return r.store.Save(events)
}

// Usage
func main() {
  store := NewInMemoryEventStore()
  repo := &AccountRepository{store: store}
  
  // Create account
  events := []Event{
      AccountCreated{
          AccountID:  "acc-1",
          Owner:      "Alice",
          occurredAt: time.Now(),
      },
      MoneyDeposited{
          AccountID:  "acc-1",
          Amount:     100.0,
          occurredAt: time.Now(),
      },
  }
  
  repo.Save(nil, events)
  
  // Load account (rebuild from events)
  account, _ := repo.Load("acc-1")
  fmt.Println("Balance:", account.Balance) // 100.0
}
06

Effect Systems

Track side effects in types for composability and testability.

effect_system.go
package effects

import (
  "context"
  "github.com/your-org/fp-go/io"
  "github.com/your-org/fp-go/reader"
  "github.com/your-org/fp-go/readerioeither"
)

// Environment (dependencies)
type Env struct {
  DB     Database
  Logger Logger
  Config Config
}

// Effect type: ReaderIOEither[Env, Error, A]
// - Reader: needs Env
// - IO: performs side effects
// - Either: can fail with Error
type Effect[A any] = readerioeither.ReaderIOEither[Env, error, A]

// Pure computation (no effects)
func pure[A any](value A) Effect[A] {
  return readerioeither.Right[Env, error, A](value)
}

// Database effect
func findUser(id string) Effect[User] {
  return readerioeither.Ask[Env, error, User](func(env Env) io.IO[either.Either[error, User]] {
      return io.Of(func() either.Either[error, User] {
          return env.DB.FindUser(id)
      })
  })
}

// Logging effect
func logInfo(message string) Effect[unit.Unit] {
  return readerioeither.Ask[Env, error, unit.Unit](func(env Env) io.IO[either.Either[error, unit.Unit]] {
      return io.Of(func() either.Either[error, unit.Unit] {
          env.Logger.Info(message)
          return either.Right[error](unit.Unit{})
      })
  })
}

// Compose effects
func getUserWithLogging(id string) Effect[User] {
  return readerioeither.Chain(
      logInfo("Finding user: " + id),
      func(_ unit.Unit) Effect[User] {
          return readerioeither.Chain(
              findUser(id),
              func(user User) Effect[User] {
                  return readerioeither.Chain(
                      logInfo("Found user: " + user.Name),
                      func(_ unit.Unit) Effect[User] {
                          return pure(user)
                      },
                  )
              },
          )
      },
  )
}

// Run effect with environment
func main() {
  env := Env{
      DB:     NewPostgresDB(),
      Logger: NewLogger(),
      Config: LoadConfig(),
  }
  
  effect := getUserWithLogging("user-123")
  
  // Execute effect
  result := effect(env)() // Reader -> IO -> Either
  
  either.Match(
      result,
      func(err error) {
          fmt.Println("Error:", err)
      },
      func(user User) {
          fmt.Println("User:", user.Name)
      },
  )
}