Go Architecture Patterns Series: Series Overview | Next: Clean Architecture →


What is Layered Architecture?

Layered Architecture is one of the most common and fundamental architectural patterns in software development. It organizes code into horizontal layers, where each layer has a specific responsibility and only communicates with adjacent layers.

Key Principles:

  • Separation of Concerns: Each layer handles a specific aspect of the application
  • Layer Independence: Layers are loosely coupled and can be changed independently
  • Unidirectional Dependencies: Dependencies flow in one direction (top-down)
  • Clear Boundaries: Well-defined interfaces between layers
  • Testability: Each layer can be tested in isolation

Architecture Overview

graph TD A[Presentation Layer] --> B[Business Logic Layer] B --> C[Data Access Layer] C --> D[(Database)] style A fill:#e1f5ff style B fill:#fff4e1 style C fill:#e8f5e9 style D fill:#f3e5f5

Traditional 3-Tier Layered Architecture

graph LR subgraph "Presentation Layer" A1[HTTP Handlers] A2[Templates/Views] A3[Request/Response DTOs] end subgraph "Business Logic Layer" B1[Services] B2[Business Rules] B3[Domain Models] end subgraph "Data Access Layer" C1[Repositories] C2[Database Access] C3[Data Models] end A1 --> B1 A2 --> B1 B1 --> C1 C1 --> C2 style A1 fill:#e1f5ff style B1 fill:#fff4e1 style C1 fill:#e8f5e9

Real-World Use Cases

  • Web Applications: RESTful APIs and web services
  • Enterprise Applications: Business management systems
  • CRUD Applications: Standard create-read-update-delete operations
  • Monolithic Applications: Traditional single-deployment applications
  • Internal Tools: Admin panels and dashboards
  • Legacy System Modernization: Refactoring existing codebases

Basic Layered Architecture Implementation

Project Structure

├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── handlers/          # Presentation Layer
│   │   ├── user_handler.go
│   │   └── product_handler.go
│   ├── services/          # Business Logic Layer
│   │   ├── user_service.go
│   │   └── product_service.go
│   ├── repositories/      # Data Access Layer
│   │   ├── user_repository.go
│   │   └── product_repository.go
│   └── models/            # Domain Models
│       ├── user.go
│       └── product.go
└── go.mod

Layer 1: Domain Models

package models

import "time"

// User represents a user in the system
type User struct {
    ID        int64     `json:"id"`
    Email     string    `json:"email"`
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

// Product represents a product in the system
type Product struct {
    ID          int64     `json:"id"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    Price       float64   `json:"price"`
    Stock       int       `json:"stock"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

// Order represents an order in the system
type Order struct {
    ID        int64     `json:"id"`
    UserID    int64     `json:"user_id"`
    ProductID int64     `json:"product_id"`
    Quantity  int       `json:"quantity"`
    Total     float64   `json:"total"`
    Status    string    `json:"status"`
    CreatedAt time.Time `json:"created_at"`
}

Layer 2: Data Access Layer (Repository)

package repositories

import (
    "context"
    "database/sql"
    "fmt"
    "time"

    "myapp/internal/models"
)

// UserRepository handles user data access
type UserRepository struct {
    db *sql.DB
}

// NewUserRepository creates a new user repository
func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

// Create creates a new user
func (r *UserRepository) Create(ctx context.Context, user *models.User) error {
    query := `
        INSERT INTO users (email, name, created_at, updated_at)
        VALUES ($1, $2, $3, $4)
        RETURNING id
    `

    now := time.Now()
    user.CreatedAt = now
    user.UpdatedAt = now

    err := r.db.QueryRowContext(
        ctx, query,
        user.Email, user.Name, user.CreatedAt, user.UpdatedAt,
    ).Scan(&user.ID)

    if err != nil {
        return fmt.Errorf("failed to create user: %w", err)
    }

    return nil
}

// GetByID retrieves a user by ID
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*models.User, error) {
    query := `
        SELECT id, email, name, created_at, updated_at
        FROM users
        WHERE id = $1
    `

    user := &models.User{}
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &user.ID,
        &user.Email,
        &user.Name,
        &user.CreatedAt,
        &user.UpdatedAt,
    )

    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("user not found")
    }
    if err != nil {
        return nil, fmt.Errorf("failed to get user: %w", err)
    }

    return user, nil
}

// GetByEmail retrieves a user by email
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
    query := `
        SELECT id, email, name, created_at, updated_at
        FROM users
        WHERE email = $1
    `

    user := &models.User{}
    err := r.db.QueryRowContext(ctx, query, email).Scan(
        &user.ID,
        &user.Email,
        &user.Name,
        &user.CreatedAt,
        &user.UpdatedAt,
    )

    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("user not found")
    }
    if err != nil {
        return nil, fmt.Errorf("failed to get user: %w", err)
    }

    return user, nil
}

// Update updates a user
func (r *UserRepository) Update(ctx context.Context, user *models.User) error {
    query := `
        UPDATE users
        SET email = $1, name = $2, updated_at = $3
        WHERE id = $4
    `

    user.UpdatedAt = time.Now()

    result, err := r.db.ExecContext(
        ctx, query,
        user.Email, user.Name, user.UpdatedAt, user.ID,
    )

    if err != nil {
        return fmt.Errorf("failed to update user: %w", err)
    }

    rows, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("failed to get affected rows: %w", err)
    }

    if rows == 0 {
        return fmt.Errorf("user not found")
    }

    return nil
}

// Delete deletes a user
func (r *UserRepository) Delete(ctx context.Context, id int64) error {
    query := `DELETE FROM users WHERE id = $1`

    result, err := r.db.ExecContext(ctx, query, id)
    if err != nil {
        return fmt.Errorf("failed to delete user: %w", err)
    }

    rows, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("failed to get affected rows: %w", err)
    }

    if rows == 0 {
        return fmt.Errorf("user not found")
    }

    return nil
}

// List retrieves all users
func (r *UserRepository) List(ctx context.Context, limit, offset int) ([]*models.User, error) {
    query := `
        SELECT id, email, name, created_at, updated_at
        FROM users
        ORDER BY id DESC
        LIMIT $1 OFFSET $2
    `

    rows, err := r.db.QueryContext(ctx, query, limit, offset)
    if err != nil {
        return nil, fmt.Errorf("failed to list users: %w", err)
    }
    defer rows.Close()

    var users []*models.User
    for rows.Next() {
        user := &models.User{}
        err := rows.Scan(
            &user.ID,
            &user.Email,
            &user.Name,
            &user.CreatedAt,
            &user.UpdatedAt,
        )
        if err != nil {
            return nil, fmt.Errorf("failed to scan user: %w", err)
        }
        users = append(users, user)
    }

    return users, nil
}

// ProductRepository handles product data access
type ProductRepository struct {
    db *sql.DB
}

// NewProductRepository creates a new product repository
func NewProductRepository(db *sql.DB) *ProductRepository {
    return &ProductRepository{db: db}
}

// UpdateStock updates product stock
func (r *ProductRepository) UpdateStock(ctx context.Context, productID int64, quantity int) error {
    query := `
        UPDATE products
        SET stock = stock + $1, updated_at = $2
        WHERE id = $3
    `

    result, err := r.db.ExecContext(ctx, query, quantity, time.Now(), productID)
    if err != nil {
        return fmt.Errorf("failed to update stock: %w", err)
    }

    rows, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("failed to get affected rows: %w", err)
    }

    if rows == 0 {
        return fmt.Errorf("product not found")
    }

    return nil
}

Layer 3: Business Logic Layer (Service)

package services

import (
    "context"
    "fmt"
    "regexp"

    "myapp/internal/models"
    "myapp/internal/repositories"
)

// UserService handles user business logic
type UserService struct {
    userRepo *repositories.UserRepository
}

// NewUserService creates a new user service
func NewUserService(userRepo *repositories.UserRepository) *UserService {
    return &UserService{
        userRepo: userRepo,
    }
}

// CreateUser creates a new user with validation
func (s *UserService) CreateUser(ctx context.Context, email, name string) (*models.User, error) {
    // Business rule: Validate email format
    if !isValidEmail(email) {
        return nil, fmt.Errorf("invalid email format")
    }

    // Business rule: Name must not be empty
    if name == "" {
        return nil, fmt.Errorf("name cannot be empty")
    }

    // Business rule: Email must be unique
    existingUser, err := s.userRepo.GetByEmail(ctx, email)
    if err == nil && existingUser != nil {
        return nil, fmt.Errorf("email already exists")
    }

    user := &models.User{
        Email: email,
        Name:  name,
    }

    if err := s.userRepo.Create(ctx, user); err != nil {
        return nil, fmt.Errorf("failed to create user: %w", err)
    }

    return user, nil
}

// GetUser retrieves a user by ID
func (s *UserService) GetUser(ctx context.Context, id int64) (*models.User, error) {
    return s.userRepo.GetByID(ctx, id)
}

// UpdateUser updates a user with validation
func (s *UserService) UpdateUser(ctx context.Context, id int64, email, name string) (*models.User, error) {
    // Business rule: Validate email format
    if !isValidEmail(email) {
        return nil, fmt.Errorf("invalid email format")
    }

    // Business rule: Name must not be empty
    if name == "" {
        return nil, fmt.Errorf("name cannot be empty")
    }

    user, err := s.userRepo.GetByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("user not found: %w", err)
    }

    // Business rule: If email is changing, check uniqueness
    if user.Email != email {
        existingUser, err := s.userRepo.GetByEmail(ctx, email)
        if err == nil && existingUser != nil {
            return nil, fmt.Errorf("email already exists")
        }
    }

    user.Email = email
    user.Name = name

    if err := s.userRepo.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 int64) error {
    return s.userRepo.Delete(ctx, id)
}

// ListUsers retrieves users with pagination
func (s *UserService) ListUsers(ctx context.Context, page, pageSize int) ([]*models.User, error) {
    // Business rule: Validate pagination parameters
    if page < 1 {
        page = 1
    }
    if pageSize < 1 || pageSize > 100 {
        pageSize = 20
    }

    offset := (page - 1) * pageSize
    return s.userRepo.List(ctx, pageSize, offset)
}

// isValidEmail validates email format
func isValidEmail(email string) bool {
    emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
    return emailRegex.MatchString(email)
}

// OrderService handles order business logic
type OrderService struct {
    userRepo    *repositories.UserRepository
    productRepo *repositories.ProductRepository
}

// NewOrderService creates a new order service
func NewOrderService(
    userRepo *repositories.UserRepository,
    productRepo *repositories.ProductRepository,
) *OrderService {
    return &OrderService{
        userRepo:    userRepo,
        productRepo: productRepo,
    }
}

// PlaceOrder places a new order with business validation
func (s *OrderService) PlaceOrder(ctx context.Context, userID, productID int64, quantity int) (*models.Order, error) {
    // Business rule: Validate user exists
    user, err := s.userRepo.GetByID(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("user not found: %w", err)
    }

    // Business rule: Validate quantity
    if quantity <= 0 {
        return nil, fmt.Errorf("quantity must be positive")
    }

    // Business rule: Check stock availability
    // (In a real implementation, this would be in ProductRepository)

    // Business rule: Calculate total
    // (In a real implementation, this would fetch product price)

    order := &models.Order{
        UserID:    user.ID,
        ProductID: productID,
        Quantity:  quantity,
        Status:    "pending",
    }

    return order, nil
}

Layer 4: Presentation Layer (HTTP Handlers)

package handlers

import (
    "encoding/json"
    "net/http"
    "strconv"

    "myapp/internal/services"
)

// UserHandler handles HTTP requests for users
type UserHandler struct {
    userService *services.UserService
}

// NewUserHandler creates a new user handler
func NewUserHandler(userService *services.UserService) *UserHandler {
    return &UserHandler{
        userService: userService,
    }
}

// CreateUserRequest represents the request to create a user
type CreateUserRequest struct {
    Email string `json:"email"`
    Name  string `json:"name"`
}

// 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 body")
        return
    }

    user, err := h.userService.CreateUser(r.Context(), req.Email, req.Name)
    if err != nil {
        respondError(w, http.StatusBadRequest, err.Error())
        return
    }

    respondJSON(w, http.StatusCreated, user)
}

// GetUser handles GET /users/:id
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    // Extract ID from URL (implementation depends on router)
    id, err := extractIDFromPath(r)
    if err != nil {
        respondError(w, http.StatusBadRequest, "invalid user ID")
        return
    }

    user, err := h.userService.GetUser(r.Context(), id)
    if err != nil {
        respondError(w, http.StatusNotFound, "user not found")
        return
    }

    respondJSON(w, http.StatusOK, user)
}

// UpdateUser handles PUT /users/:id
func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
    id, err := extractIDFromPath(r)
    if err != nil {
        respondError(w, http.StatusBadRequest, "invalid user ID")
        return
    }

    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "invalid request body")
        return
    }

    user, err := h.userService.UpdateUser(r.Context(), id, req.Email, req.Name)
    if err != nil {
        respondError(w, http.StatusBadRequest, err.Error())
        return
    }

    respondJSON(w, http.StatusOK, user)
}

// DeleteUser handles DELETE /users/:id
func (h *UserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
    id, err := extractIDFromPath(r)
    if err != nil {
        respondError(w, http.StatusBadRequest, "invalid user ID")
        return
    }

    if err := h.userService.DeleteUser(r.Context(), id); err != nil {
        respondError(w, http.StatusNotFound, err.Error())
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

// ListUsers handles GET /users
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))

    users, err := h.userService.ListUsers(r.Context(), page, pageSize)
    if err != nil {
        respondError(w, http.StatusInternalServerError, "failed to list users")
        return
    }

    respondJSON(w, http.StatusOK, users)
}

// Helper functions
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})
}

func extractIDFromPath(r *http.Request) (int64, error) {
    // This is a simplified version - use your router's method
    // For example with chi: chi.URLParam(r, "id")
    return 1, nil
}

Main Application

package main

import (
    "database/sql"
    "log"
    "net/http"

    _ "github.com/lib/pq"

    "myapp/internal/handlers"
    "myapp/internal/repositories"
    "myapp/internal/services"
)

func main() {
    // Initialize database connection
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/dbname?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Initialize layers from bottom to top
    // Data Access Layer
    userRepo := repositories.NewUserRepository(db)
    productRepo := repositories.NewProductRepository(db)

    // Business Logic Layer
    userService := services.NewUserService(userRepo)
    orderService := services.NewOrderService(userRepo, productRepo)

    // Presentation Layer
    userHandler := handlers.NewUserHandler(userService)

    // Setup routes
    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", userHandler.CreateUser)
    mux.HandleFunc("GET /users/{id}", userHandler.GetUser)
    mux.HandleFunc("PUT /users/{id}", userHandler.UpdateUser)
    mux.HandleFunc("DELETE /users/{id}", userHandler.DeleteUser)
    mux.HandleFunc("GET /users", userHandler.ListUsers)

    // Start server
    log.Println("Server starting on :8080")
    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatal(err)
    }
}

Testing Layered Architecture

package services_test

import (
    "context"
    "testing"

    "myapp/internal/models"
    "myapp/internal/services"
)

// MockUserRepository for testing
type MockUserRepository struct {
    users map[int64]*models.User
    nextID int64
}

func NewMockUserRepository() *MockUserRepository {
    return &MockUserRepository{
        users: make(map[int64]*models.User),
        nextID: 1,
    }
}

func (m *MockUserRepository) Create(ctx context.Context, user *models.User) error {
    user.ID = m.nextID
    m.nextID++
    m.users[user.ID] = user
    return nil
}

func (m *MockUserRepository) GetByID(ctx context.Context, id int64) (*models.User, error) {
    user, exists := m.users[id]
    if !exists {
        return nil, fmt.Errorf("user not found")
    }
    return user, nil
}

func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
    for _, user := range m.users {
        if user.Email == email {
            return user, nil
        }
    }
    return nil, fmt.Errorf("user not found")
}

func TestUserService_CreateUser(t *testing.T) {
    mockRepo := NewMockUserRepository()
    service := services.NewUserService(mockRepo)

    tests := []struct {
        name    string
        email   string
        userName string
        wantErr bool
    }{
        {
            name:     "valid user",
            email:    "[email protected]",
            userName: "Test User",
            wantErr:  false,
        },
        {
            name:     "invalid email",
            email:    "invalid-email",
            userName: "Test User",
            wantErr:  true,
        },
        {
            name:     "empty name",
            email:    "[email protected]",
            userName: "",
            wantErr:  true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            user, err := service.CreateUser(context.Background(), tt.email, tt.userName)

            if tt.wantErr {
                if err == nil {
                    t.Errorf("expected error but got none")
                }
                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)
            }
        })
    }
}

Best Practices

  1. Clear Layer Boundaries: Define clear interfaces between layers
  2. Dependency Direction: Always depend downward, never upward
  3. Keep Layers Thin: Avoid bloated service layers with too much logic
  4. Use Dependency Injection: Inject dependencies rather than creating them
  5. Interface Segregation: Define minimal interfaces for layer communication
  6. Error Handling: Handle errors appropriately at each layer
  7. Testing: Test each layer independently with mocks

Common Pitfalls

  1. Layer Violation: Skipping layers (e.g., handler directly accessing repository)
  2. Anemic Domain Models: Models with no behavior, only data
  3. Fat Service Layer: Putting all logic in the service layer
  4. Tight Coupling: Layers knowing too much about each other
  5. God Objects: Services or repositories handling too many responsibilities
  6. Inconsistent Abstractions: Different patterns in different layers

When to Use Layered Architecture

Use When:

  • Building standard CRUD applications
  • Team is familiar with traditional architectures
  • Requirements are straightforward and stable
  • Need quick development with proven patterns
  • Building monolithic applications

Avoid When:

  • Building complex domain-driven systems (use DDD)
  • Need high flexibility and testability (use Clean/Hexagonal)
  • Building microservices (consider other patterns)
  • Domain logic is complex and constantly changing

Advantages

  • Simple to Understand: Intuitive structure for most developers
  • Clear Separation: Each layer has distinct responsibilities
  • Easy to Test: Layers can be tested independently
  • Maintainable: Changes are localized to specific layers
  • Industry Standard: Well-documented with many examples

Disadvantages

  • Tight Coupling: Layers are coupled to adjacent layers
  • Rigid Structure: Can be inflexible for complex domains
  • Database-Centric: Often leads to database-driven design
  • Scalability Issues: Not optimized for distributed systems
  • Testing Challenges: May require database for integration tests

Layered Architecture provides a solid foundation for building maintainable applications. While it may not be suitable for all scenarios, it remains one of the most practical and widely-used architectural patterns in software development.


Go Architecture Patterns Series: Series Overview | Next: Clean Architecture →