Клієнт-серверна архітектура

Лекція 10. Шарувата архітектура та конвеєри обробки запитів

Патерн Middleware у Go, принципи Clean Architecture, впровадження залежностей та модульне тестування HTTP-шару

🎯 Мета лекції

  • Опанувати концепцію Middleware як конвеєрної обробки HTTP-запитів у мові Go.
  • Зрозуміти принципи шаруватої архітектури та Clean Architecture для серверних застосунків.
  • Навчитися застосовувати ручне впровадження залежностей (Dependency Injection) для слабкої зв'язності компонентів.
  • Набути практичних навичок модульного тестування HTTP-шару за допомогою пакета net/http/httptest.

🔑 Ключові терміни

  • Middleware: проміжний обробник запиту, що виконується перед основним handler та може модифікувати запит, відповідь або припинити подальшу обробку.
  • Clean Architecture: архітектурний підхід, що ізолює бізнес-логіку від зовнішніх деталей доставки даних (HTTP, БД, UI).
  • Dependency Injection (DI): патерн передачі залежностей ззовні замість створення їх усередині компонента.
  • Repository Pattern: абстракція доступу до даних через інтерфейси, що дозволяє змінювати джерело даних без зміни бізнес-логіки.

Налаштування проєкту з нуля

Перш ніж почати писати код, створимо правильну структуру Go-проєкту.

Ініціалізація Go-модуля

# Створюємо теку проєкту
mkdir myapp
cd myapp

# Ініціалізуємо Go-модуль
go mod init myapp

# Встановлюємо необхідні залежності
go get github.com/go-chi/chi/v5
go get golang.org/x/time/rate
go get golang.org/x/crypto/bcrypt
go get github.com/lib/pq  # PostgreSQL драйвер
go get github.com/google/uuid  # UUID generation

Після виконання цих команд з'явиться файл go.mod:

// go.mod
module myapp

go 1.21

require (
    github.com/go-chi/chi/v5 v5.0.11
    github.com/google/uuid v1.5.0
    github.com/lib/pq v1.10.9
    golang.org/x/crypto v0.17.0
    golang.org/x/time v0.5.0
)

Створення базової структури каталогів

# Створюємо структуру проєкту
mkdir -p cmd/server
mkdir -p internal/{domain,usecase,repository/{memory,postgres},delivery/http}
mkdir -p middleware
mkdir -p migrations

# Створюємо основні файли
touch cmd/server/main.go
touch middleware/{logger,recovery,cors,ratelimit,auth}.go
touch internal/domain/{user,user_repository,errors}.go
touch internal/usecase/user_usecase.go
touch internal/repository/memory/user_repository.go
touch internal/repository/postgres/user_repository.go
touch internal/delivery/http/user_handler.go
Важливо: Назва модуля в go.mod (myapp) має відповідати імпортам у коді. У реальних проєктах використовуйте повний шлях, наприклад: github.com/username/myapp.

Проблема дублювання коду в HTTP-обробниках

Початок: прості обробники без структури

Припустимо, ми розробляємо API для управління користувачами. Почнемо з найпростішого підходу — напишемо кілька HTTP-обробників без будь-якої архітектури:

// cmd/simple-server/main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "time"
)

// Примітка: для запуску цього прикладу достатньо стандартної бібліотеки Go

// Глобальна змінна для зберігання користувачів (in-memory)
var users = make(map[string]string)

func main() {
    http.HandleFunc("/api/users", handleUsers)
    http.HandleFunc("/api/health", handleHealth)

    fmt.Println("Server starting on :8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleUsers(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    log.Printf("[%s] %s - started", r.Method, r.URL.Path)

    switch r.Method {
    case http.MethodGet:
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(users)
        
    case http.MethodPost:
        var user struct {
            Name  string `json:"name"`
            Email string `json:"email"`
        }
        
        if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
            http.Error(w, "Invalid JSON", http.StatusBadRequest)
            log.Printf("[%s] %s - completed in %v (error: invalid JSON)", 
                r.Method, r.URL.Path, time.Since(start))
            return
        }
        
        users[user.Email] = user.Name
        w.WriteHeader(http.StatusCreated)
        fmt.Fprintf(w, "User %s created", user.Name)
        
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }

    log.Printf("[%s] %s - completed in %v", r.Method, r.URL.Path, time.Since(start))
}

func handleHealth(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    log.Printf("[%s] %s - started", r.Method, r.URL.Path)
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status": "ok",
        "time":   time.Now().Format(time.RFC3339),
    })
    
    log.Printf("[%s] %s - completed in %v", r.Method, r.URL.Path, time.Since(start))
}

Що ми бачимо в цьому коді?

Кожен обробник містить однаковий повторюваний код:

  1. Логування початку запиту з часом старту
  2. Встановлення заголовка Content-Type
  3. Логування завершення запиту з тривалістю

Це тільки два ендпоінти! Уявіть, що буде при 20-30 ендпоінтах. А що, якщо нам потрібно додати:

  • Перехоплення паніки (panic recovery) щоб сервер не падав при помилках?
  • CORS-заголовки для роботи з браузерними клієнтами?
  • Автентифікацію через JWT токени?
  • Обмеження частоти запитів (rate limiting)?

Код стане абсолютно нечитабельним. Саме цю проблему вирішує патерн Middleware.


Еволюція: виділення спільної логіки

Спроба 1: винесення логування у функцію

Спробуємо зменшити дублювання, створивши допоміжну функцію:

func logRequest(method, path string, start time.Time, stage string) {
    if stage == "start" {
        log.Printf("[%s] %s - started", method, path)
    } else {
        log.Printf("[%s] %s - completed in %v", method, path, time.Since(start))
    }
}

func handleUsers(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    logRequest(r.Method, r.URL.Path, start, "start")
    defer logRequest(r.Method, r.URL.Path, start, "end")
    
    // ... решта логіки
}

Це трохи краще, але:

  • Треба пам'ятати викликати logRequest у кожному обробнику
  • Неможливо легко вимкнути логування для окремих ендпоінтів
  • Код все одно дублюється

Спроба 2: обгортаюча функція

Створимо функцію, що приймає обробник і повертає новий обробник з логуванням:

func withLogging(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        log.Printf("[%s] %s - started", r.Method, r.URL.Path)
        
        // Викликаємо оригінальний обробник
        next(w, r)
        
        log.Printf("[%s] %s - completed in %v", r.Method, r.URL.Path, time.Since(start))
    }
}

Тепер можна обгорнути будь-який обробник:

func main() {
    http.HandleFunc("/api/users", withLogging(handleUsers))
    http.HandleFunc("/api/health", withLogging(handleHealth))
    
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Це вже набагато краще! Але є проблема: withLogging приймає http.HandlerFunc, а не http.Handler. Це обмежує можливості композиції.


Middleware: стандартний патерн у Go

Що таке Middleware?

Middleware (проміжне програмне забезпечення, проміжний обробник) — це функція, яка:

  1. Приймає http.Handler (наступний обробник у ланцюжку)
  2. Повертає новий http.Handler, що обгортає переданий
  3. Може виконувати код до та/або після виклику наступного обробника
  4. Може припинити подальшу обробку (не викликати next.ServeHTTP)
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam sequenceMessageAlign center

participant "HTTP Client" as Client #DBEAFE
participant "Middleware 1\n(Logger)" as MW1 #FEF3C7
participant "Middleware 2\n(Auth)" as MW2 #FEF3C7
participant "Final Handler\n(Business Logic)" as Handler #DCFCE7

Client -> MW1 : HTTP Request
activate MW1
note right of MW1 #F1F5F9
  Логує запит
  Записує час початку
end note

MW1 -> MW2 : next.ServeHTTP(w, r)
activate MW2
note right of MW2 #F1F5F9
  Перевіряє JWT токен
  Додає user_id у контекст
end note

MW2 -> Handler : next.ServeHTTP(w, r)
activate Handler
note right of Handler #F1F5F9
  Виконує бізнес-логіку
  Формує відповідь
end note

Handler --> MW2 : return
deactivate Handler

MW2 --> MW1 : return
deactivate MW2

note right of MW1 #F1F5F9
  Логує тривалість
  Записує статус-код
end note

MW1 --> Client : HTTP Response
deactivate MW1

@enduml

Стандартна сигнатура Middleware

У Go прийнято визначати Middleware як тип:

type Middleware func(http.Handler) http.Handler

Це функція, що:

  • Вхід: http.Handler (наступний обробник)
  • Вихід: http.Handler (новий обробник з додатковою логікою)
Інтерфейс http.Handler має лише один метод: ServeHTTP(ResponseWriter, *Request). Будь-яка структура чи функція, що реалізує цей метод, є валідним HTTP-обробником.

Створюємо перший Middleware з нуля

Крок 1: Розуміємо http.Handler

Щоб створити Middleware, спочатку потрібно зрозуміти інтерфейс http.Handler:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Будь-який тип, що має метод ServeHTTP, може бути обробником. Наприклад:

// Простий обробник як структура
type MyHandler struct{}

func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello from MyHandler")
}

// Використання
http.Handle("/test", &MyHandler{})

Але є зручніший спосіб — тип http.HandlerFunc:

// HandlerFunc — це адаптер, що перетворює функцію на Handler
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

Завдяки цьому можна писати так:

func myHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello")
}

http.Handle("/test", http.HandlerFunc(myHandler))
http.HandleFunc() робить це автоматично — перетворює функцію на http.Handler через http.HandlerFunc. Тому http.HandleFunc("/path", myFunc) еквівалентно http.Handle("/path", http.HandlerFunc(myFunc)).

Крок 2: Створюємо найпростіший Middleware

Створимо Middleware, що просто виводить повідомлення до та після виконання наступного обробника:

// middleware/simple.go
package middleware

import (
    "fmt"
    "net/http"
)

// Simple — найпростіший Middleware для демонстрації концепції
func Simple(next http.Handler) http.Handler {
    // Повертаємо нову функцію, що обгортає next
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Println("BEFORE: Middleware executed before handler")
        
        // Викликаємо наступний обробник у ланцюжку
        next.ServeHTTP(w, r)
        
        fmt.Println("AFTER: Middleware executed after handler")
    })
}

Покрокове пояснення:

  1. Simple — це функція, що приймає http.Handler та повертає http.Handler
  2. Всередині ми створюємо анонімну функцію func(w http.ResponseWriter, r *http.Request)
  3. Обгортаємо цю функцію в http.HandlerFunc(), щоб перетворити на http.Handler
  4. ДО виклику next.ServeHTTP() виконується препроцесинг
  5. ПІСЛЯ виклику next.ServeHTTP() виконується постпроцесинг

Крок 3: Використовуємо Middleware

Створимо повний робочий приклад:

// cmd/middleware-demo/main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    
    "myapp/middleware"  // Наш власний пакет
)

func mainHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello from main handler!")
    fmt.Println("HANDLER: Main handler executing")
}

func main() {
    // Обгортаємо handler у Middleware
    wrappedHandler := middleware.Simple(http.HandlerFunc(mainHandler))
    
    http.Handle("/", wrappedHandler)
    
    fmt.Println("Server starting on :8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Запускаємо сервер:

go run cmd/middleware-demo/main.go
$ go run cmd/middleware-demo/main.go
Server starting on :8080...

Виконуємо запит:

curl http://localhost:8080
$ curl http://localhost:8080
Hello from main handler!

У логах сервера побачимо:

Лог виконання
BEFORE: Middleware executed before handler
HANDLER: Main handler executing
AFTER: Middleware executed after handler
Ключовий момент: анонімна функція всередині Middleware має доступ до змінної next завдяки замиканню (closure). Це дозволяє їй викликати наступний обробник пізніше, коли прийде HTTP-запит.

Реальний приклад: Middleware логування

Тепер створимо корисний Middleware, що логує кожен HTTP-запит з деталями:

// middleware/logger.go
package middleware

import (
    "log"
    "net/http"
    "time"
)

// Logger — Middleware для логування HTTP-запитів
func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Записуємо час початку обробки
        start := time.Now()
        
        // Логуємо деталі запиту
        log.Printf(
            "[START] Method=%s Path=%s RemoteAddr=%s",
            r.Method,
            r.URL.Path,
            r.RemoteAddr,
        )
        
        // Викликаємо наступний обробник
        next.ServeHTTP(w, r)
        
        // Обчислюємо тривалість обробки
        duration := time.Since(start)
        
        // Логуємо завершення
        log.Printf(
            "[END] Method=%s Path=%s Duration=%v",
            r.Method,
            r.URL.Path,
            duration,
        )
    })
}

Проблема: як дізнатися статус-код відповіді?

Поточна реалізація не знає, який статус-код повернув обробник. Для цього потрібно обгорнути http.ResponseWriter у власний тип, що перехоплює виклик WriteHeader():

// middleware/logger.go (покращена версія)
package middleware

import (
    "log"
    "net/http"
    "time"
)

// responseWriter — обгортка для http.ResponseWriter, що зберігає статус-код
type responseWriter struct {
    http.ResponseWriter
    statusCode int
    written    bool
}

// WriteHeader перехоплює виклик та зберігає статус-код
func (rw *responseWriter) WriteHeader(code int) {
    if !rw.written {
        rw.statusCode = code
        rw.written = true
        rw.ResponseWriter.WriteHeader(code)
    }
}

// Write перехоплює запис даних (якщо WriteHeader не викликано, статус 200)
func (rw *responseWriter) Write(b []byte) (int, error) {
    if !rw.written {
        rw.statusCode = http.StatusOK
        rw.written = true
    }
    return rw.ResponseWriter.Write(b)
}

// Logger — Middleware з логуванням статус-коду
func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Обгортаємо ResponseWriter
        wrapped := &responseWriter{
            ResponseWriter: w,
            statusCode:     http.StatusOK, // за замовчуванням 200
            written:        false,
        }
        
        log.Printf(
            "[→] %s %s from %s",
            r.Method,
            r.URL.Path,
            r.RemoteAddr,
        )
        
        // Викликаємо наступний обробник з обгорнутим writer
        next.ServeHTTP(wrapped, r)
        
        duration := time.Since(start)
        
        log.Printf(
            "[←] %s %s → %d (%v)",
            r.Method,
            r.URL.Path,
            wrapped.statusCode,
            duration,
        )
    })
}
Ембединг (embedding) http.ResponseWriter у нашу структуру дозволяє автоматично делегувати всі інші методи інтерфейсу оригінальному writer. Ми перевизначаємо лише WriteHeader та Write.

Повний робочий приклад з логуванням

// cmd/logger-server/main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "myapp/middleware"
)

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status": "healthy",
    })
}

func errorHandler(w http.ResponseWriter, r *http.Request) {
    http.Error(w, "Something went wrong", http.StatusInternalServerError)
}

func main() {
    // Обгортаємо кожен handler у Logger Middleware
    http.Handle("/health", middleware.Logger(http.HandlerFunc(healthHandler)))
    http.Handle("/error", middleware.Logger(http.HandlerFunc(errorHandler)))
    
    fmt.Println("Server with logging starting on :8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Виконуємо запити:

curl http://localhost:8080/health
$ curl http://localhost:8080/health
{"status":"healthy"}
$ curl http://localhost:8080/error
Something went wrong

Логи сервера:

Логи сервера
[→] GET /health from 127.0.0.1:54321
[←] GET /health → 200 (342µs)
[→] GET /error from 127.0.0.1:54322
[←] GET /error → 500 (89µs)

Чудово! Тепер у нас є робочий Middleware, що логує всі запити з статус-кодами та тривалістю обробки.


Middleware для перехоплення паніки

Якщо в обробнику виникає паніка (panic), стандартна поведінка Go — завершити горутину і розірвати з'єднання. Це неприйнятно для продакшн-систем. Створимо Middleware для перехоплення паніки:

// middleware/recovery.go
package middleware

import (
    "log"
    "net/http"
    "runtime/debug"
)

// Recovery — Middleware для перехоплення паніки
func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                // Логуємо паніку та стек викликів
                log.Printf("PANIC: %v\n%s", err, debug.Stack())
                
                // Повертаємо клієнту статус 500
                http.Error(w, 
                    "Internal Server Error", 
                    http.StatusInternalServerError)
            }
        }()
        
        // Викликаємо наступний обробник
        next.ServeHTTP(w, r)
    })
}

Пояснення:

  • defer гарантує, що анонімна функція виконається після завершення next.ServeHTTP() (навіть якщо сталася паніка)
  • recover() перехоплює паніку і повертає значення, передане у panic()
  • debug.Stack() повертає повний стек викликів для діагностики

Тестуємо Recovery Middleware

// cmd/recovery-demo/main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "myapp/middleware"
)

func panicHandler(w http.ResponseWriter, r *http.Request) {
    // Навмисна паніка для демонстрації
    panic("Something terrible happened!")
}

func normalHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Everything is fine here")
}

func main() {
    // Обгортаємо handler у Recovery та Logger
    http.Handle("/panic", 
        middleware.Logger(
            middleware.Recovery(
                http.HandlerFunc(panicHandler))))
                
    http.Handle("/normal", 
        middleware.Logger(
            middleware.Recovery(
                http.HandlerFunc(normalHandler))))
    
    fmt.Println("Server with panic recovery on :8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Запит до /panic:

curl http://localhost:8080/panic
$ curl http://localhost:8080/panic
Internal Server Error

Лог сервера (сервер не падає!):

Лог сервера після паніки
[→] GET /panic from 127.0.0.1:54323
PANIC: Something terrible happened!
goroutine 19 [running]:
runtime/debug.Stack()
/usr/local/go/src/runtime/debug/stack.go:24 +0x65
myapp/middleware.Recovery.func1.1()
/app/middleware/recovery.go:14 +0x5e
[←] GET /panic → 500 (127µs)
Паніки у продакшн-коді слід уникати! Використовуйте явне повернення помилок через тип error. Recovery Middleware — це останній рубіж захисту від непередбачених ситуацій, а не основний механізм обробки помилок.

Композиція Middleware: побудова ланцюжків

Проблема вкладеності

Коли у нас є кілька Middleware, вкладення стає громіздким:

handler := middleware.Logger(
    middleware.Recovery(
        middleware.CORS(
            http.HandlerFunc(myHandler))))

Це важко читати і підтримувати. Створимо допоміжну функцію для композиції:

// middleware/chain.go
package middleware

import "net/http"

// Chain послідовно застосовує список Middleware до handler
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
    // Застосовуємо Middleware у зворотному порядку,
    // щоб перший у списку виконувався першим
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

Чому зворотний порядок? Middleware застосовуються як обгортки:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "Logger" as L #FEF3C7 {
  rectangle "Recovery" as R #DBEAFE {
    rectangle "CORS" as C #E2E8F0 {
      rectangle "Handler" as H #DCFCE7
    }
  }
}

note right of L
  1. Logger обгортає Recovery
  2. Recovery обгортає CORS
  3. CORS обгортає Handler
  
  Виконання:
  Logger → Recovery → CORS → Handler
end note

@enduml

Тепер можна писати так:

handler := middleware.Chain(
    http.HandlerFunc(myHandler),
    middleware.Logger,
    middleware.Recovery,
    middleware.CORS,
)
Маршрутизатор Chi має вбудований метод r.Use() для групування Middleware, що ще зручніше. Але розуміння того, як працює Chain, допомагає зрозуміти внутрішню механіку.

CORS Middleware: дозвіл крос-доменних запитів

Що таке CORS?

CORS (Cross-Origin Resource Sharing, спільне використання ресурсів між різними джерелами) — це механізм безпеки браузерів, що контролює, які домени можуть виконувати запити до вашого API.

Проблема: браузер блокує AJAX-запити до API на іншому домені з міркувань безпеки:

Клієнт: https://myapp.com
API:    https://api.myserver.com  ← Різні домени!

Рішення: API має додавати спеціальні CORS-заголовки, що дозволяють крос-доменні запити.

Preflight-запити

Перед основним запитом браузер надсилає preflight-запит з методом OPTIONS:

OPTIONS /api/users HTTP/1.1
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

Сервер має відповісти, які методи та заголовки дозволені:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Реалізація CORS Middleware

// middleware/cors.go
package middleware

import (
    "net/http"
    "strings"
)

// CORSConfig — налаштування CORS
type CORSConfig struct {
    AllowOrigins     []string // Дозволені домени (["*"] = всі)
    AllowMethods     []string // Дозволені HTTP-методи
    AllowHeaders     []string // Дозволені заголовки
    ExposeHeaders    []string // Заголовки, доступні клієнту
    AllowCredentials bool     // Дозволити cookies
    MaxAge           int      // Час кешування preflight (секунди)
}

// DefaultCORSConfig — базова конфігурація
func DefaultCORSConfig() CORSConfig {
    return CORSConfig{
        AllowOrigins:  []string{"*"},
        AllowMethods:  []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
        AllowHeaders:  []string{"Origin", "Content-Type", "Accept", "Authorization"},
        ExposeHeaders: []string{},
        AllowCredentials: false,
        MaxAge:        86400, // 24 години
    }
}

// CORS — Middleware для обробки крос-доменних запитів
func CORS(config CORSConfig) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            origin := r.Header.Get("Origin")
            
            // Перевіряємо, чи дозволений домен
            allowedOrigin := ""
            for _, allowed := range config.AllowOrigins {
                if allowed == "*" || allowed == origin {
                    allowedOrigin = allowed
                    break
                }
            }
            
            // Якщо домен дозволений, встановлюємо заголовки
            if allowedOrigin != "" {
                if allowedOrigin == "*" {
                    w.Header().Set("Access-Control-Allow-Origin", "*")
                } else {
                    w.Header().Set("Access-Control-Allow-Origin", origin)
                }
                
                w.Header().Set("Access-Control-Allow-Methods", 
                    strings.Join(config.AllowMethods, ", "))
                w.Header().Set("Access-Control-Allow-Headers", 
                    strings.Join(config.AllowHeaders, ", "))
                
                if len(config.ExposeHeaders) > 0 {
                    w.Header().Set("Access-Control-Expose-Headers", 
                        strings.Join(config.ExposeHeaders, ", "))
                }
                
                if config.AllowCredentials {
                    w.Header().Set("Access-Control-Allow-Credentials", "true")
                }
                
                if config.MaxAge > 0 {
                    w.Header().Set("Access-Control-Max-Age", 
                        fmt.Sprintf("%d", config.MaxAge))
                }
            }
            
            // Preflight-запит (OPTIONS) — відповідаємо одразу
            if r.Method == http.MethodOptions {
                w.WriteHeader(http.StatusNoContent)
                return
            }
            
            // Інші запити — передаємо далі
            next.ServeHTTP(w, r)
        })
    }
}

Використання CORS

// cmd/cors-server/main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "myapp/middleware"
)

func apiHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "message": "API доступне з будь-якого домену",
    })
}

func main() {
    corsConfig := middleware.DefaultCORSConfig()
    
    handler := middleware.Chain(
        http.HandlerFunc(apiHandler),
        middleware.Logger,
        middleware.Recovery,
        middleware.CORS(corsConfig),
    )
    
    http.Handle("/api/data", handler)
    
    fmt.Println("CORS-enabled server on :8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Тестування з браузера:

<!-- test.html -->
<!DOCTYPE html>
<html>
<head>
    <title>CORS Test</title>
</head>
<body>
    <button onclick="fetchData()">Fetch Data</button>
    <pre id="result"></pre>
    
    <script>
        async function fetchData() {
            try {
                const response = await fetch('http://localhost:8080/api/data');
                const data = await response.json();
                document.getElementById('result').textContent = 
                    JSON.stringify(data, null, 2);
            } catch (error) {
                document.getElementById('result').textContent = 
                    'Error: ' + error.message;
            }
        }
    </script>
</body>
</html>

Без CORS Middleware браузер заблокував би запит. З CORS — запит пройде успішно.


Rate Limiting Middleware

Навіщо потрібен Rate Limiting?

Rate Limiting (обмеження частоти запитів) захищає API від:

  • DDoS-атак
  • Ненавмисного перевантаження (помилки у клієнтському коді)
  • Зловживань безплатними API

Типові стратегії:

  • Fixed Window: X запитів за хвилину
  • Sliding Window: X запитів за останню хвилину
  • Token Bucket: поповнюваний запас токенів

Реалізація через Token Bucket

Використаємо пакет golang.org/x/time/rate:

go get golang.org/x/time/rate
// middleware/ratelimit.go
package middleware

import (
    "net/http"
    "sync"
    "time"

    "golang.org/x/time/rate"
)

// RateLimiter зберігає окремий лімітер для кожного клієнта
type RateLimiter struct {
    mu       sync.Mutex
    limiters map[string]*rate.Limiter
    rate     rate.Limit
    burst    int
}

// NewRateLimiter створює новий rate limiter
// rps - запитів на секунду, burst - максимальний сплеск
func NewRateLimiter(rps int, burst int) *RateLimiter {
    return &RateLimiter{
        limiters: make(map[string]*rate.Limiter),
        rate:     rate.Limit(rps),
        burst:    burst,
    }
}

// getLimiter отримує або створює лімітер для IP
func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter {
    rl.mu.Lock()
    defer rl.mu.Unlock()
    
    limiter, exists := rl.limiters[ip]
    if !exists {
        limiter = rate.NewLimiter(rl.rate, rl.burst)
        rl.limiters[ip] = limiter
    }
    
    return limiter
}

// Middleware повертає HTTP Middleware
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Отримуємо IP клієнта
        ip := r.RemoteAddr
        
        // Перевіряємо ліміт
        limiter := rl.getLimiter(ip)
        
        if !limiter.Allow() {
            // Ліміт перевищено
            w.Header().Set("X-RateLimit-Retry-After", 
                time.Second.String())
            http.Error(w, 
                "Too Many Requests - Rate limit exceeded", 
                http.StatusTooManyRequests)
            return
        }
        
        // Ліміт не перевищено — передаємо далі
        next.ServeHTTP(w, r)
    })
}

Використання Rate Limiter

// cmd/ratelimit-server/main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "myapp/middleware"
)

func dataHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "data": "This endpoint is rate-limited to 2 req/sec",
    })
}

func main() {
    // Дозволяємо 2 запити на секунду, сплеск до 5
    rateLimiter := middleware.NewRateLimiter(2, 5)
    
    handler := middleware.Chain(
        http.HandlerFunc(dataHandler),
        middleware.Logger,
        middleware.Recovery,
        rateLimiter.Middleware,
    )
    
    http.Handle("/api/data", handler)
    
    fmt.Println("Rate-limited server (2 req/s) on :8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Тестування:

Перевірка rate limit
$ for i in {1..10}; do curl -s http://localhost:8080/api/data; echo; sleep 0.3; done
{"data":"This endpoint is rate-limited to 2 req/sec"}
{"data":"This endpoint is rate-limited to 2 req/sec"}
Too Many Requests - Rate limit exceeded
Too Many Requests - Rate limit exceeded
{"data":"This endpoint is rate-limited to 2 req/sec"}
Token Bucket Algorithm: уявіть відро з токенами. Кожен запит "забирає" токен. Відро поповнюється зі швидкістю rate токенів на секунду, але не може вмістити більше burst токенів. Це дозволяє короткочасні сплески запитів при збереженні середньої швидкості.

Authentication Middleware: передача даних через контекст

Проблема передачі даних між Middleware

Припустимо, Middleware автентифікації перевіряє JWT токен та отримує user_id. Як передати цю інформацію до обробника?

Погані варіанти:

  • ❌ Глобальні змінні (не потокобезпечні, плутанина при конкурентних запитах)
  • ❌ Заголовки відповіді (це для відповіді клієнту, не для внутрішньої передачі даних)
  • ❌ Модифікація URL (неестетично і порушує REST-принципи)

Правильне рішення: context.Context.

Що таке context.Context?

context.Context — це стандартний механізм Go для передачі:

  • Метаданих між функціями (user ID, request ID, trace ID)
  • Сигналів скасування операцій
  • Таймаутів виконання

Кожен HTTP-запит має власний контекст: r.Context().

Контекст є іммутабельним (immutable). Методи context.WithValue(), context.WithTimeout() тощо повертають новий контекст, не змінюючи оригінал. Це забезпечує потокобезпечність.

Створення власних ключів контексту

Ключі контексту мають бути унікальними типами, щоб уникнути конфліктів:

// middleware/auth.go
package middleware

import (
    "context"
    "net/http"
    "strings"
)

// contextKey — приватний тип для ключів контексту
type contextKey string

const (
    // UserIDKey — ключ для збереження user_id у контексті
    UserIDKey contextKey = "user_id"
)
Використання власного типу contextKey замість звичайного string запобігає випадковим колізіям з ключами з інших пакетів. Це рекомендована практика у Go.

Authentication Middleware

Створимо Middleware, що перевіряє JWT токен (спрощена версія):

// middleware/auth.go (продовження)
package middleware

import (
    "context"
    "fmt"
    "net/http"
    "strings"
)

// Auth — Middleware для перевірки Bearer токена
func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Читаємо заголовок Authorization
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, 
                "Missing Authorization header", 
                http.StatusUnauthorized)
            return
        }
        
        // Очікуємо формат: "Bearer <token>"
        parts := strings.Split(authHeader, " ")
        if len(parts) != 2 || parts[0] != "Bearer" {
            http.Error(w, 
                "Invalid Authorization header format. Expected: Bearer <token>", 
                http.StatusUnauthorized)
            return
        }
        
        token := parts[1]
        
        // Валідація токена (тут спрощено)
        userID, err := validateJWT(token)
        if err != nil {
            http.Error(w, 
                "Invalid or expired token: " + err.Error(), 
                http.StatusUnauthorized)
            return
        }
        
        // Додаємо user_id у контекст запиту
        ctx := context.WithValue(r.Context(), UserIDKey, userID)
        
        // Передаємо оновлений запит з новим контекстом
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// validateJWT — спрощена валідація токена
// У реальному проєкті тут має бути перевірка підпису JWT
func validateJWT(token string) (string, error) {
    // Для демонстрації: просто повертаємо токен як user_id
    if token == "" {
        return "", fmt.Errorf("empty token")
    }
    
    // У реальності тут буде:
    // 1. Парсинг JWT
    // 2. Перевірка підпису
    // 3. Перевірка термінів дії (exp)
    // 4. Витягування claims (user_id, roles, etc.)
    
    return "user-123", nil // Заглушка
}

Отримання даних з контексту в обробнику

// cmd/auth-server/main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "myapp/middleware"
)

// Публічний ендпоінт (без автентифікації)
func publicHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "message": "This is a public endpoint",
    })
}

// Захищений ендпоінт (вимагає автентифікації)
func protectedHandler(w http.ResponseWriter, r *http.Request) {
    // Витягуємо user_id з контексту
    userID, ok := r.Context().Value(middleware.UserIDKey).(string)
    if !ok {
        http.Error(w, "User not authenticated", http.StatusUnauthorized)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "message": "This is a protected endpoint",
        "user_id": userID,
    })
}

func main() {
    // Публічний маршрут (без Auth Middleware)
    http.Handle("/api/public", 
        middleware.Chain(
            http.HandlerFunc(publicHandler),
            middleware.Logger,
            middleware.Recovery,
        ))
    
    // Захищений маршрут (з Auth Middleware)
    http.Handle("/api/protected", 
        middleware.Chain(
            http.HandlerFunc(protectedHandler),
            middleware.Logger,
            middleware.Recovery,
            middleware.Auth, // Додаємо автентифікацію
        ))
    
    fmt.Println("Server with authentication on :8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Тестування:

Запити до публічного та захищеного ендпоінтів
$ curl http://localhost:8080/api/public
{"message":"This is a public endpoint"}
$ curl http://localhost:8080/api/protected
Missing Authorization header
$ curl -H "Authorization: Bearer my-token" http://localhost:8080/api/protected
{"message":"This is a protected endpoint","user_id":"user-123"}

Інтеграція Middleware з Chi Router

Chi Router: маршрутизатор з вбудованою підтримкою Middleware

Chi — популярний легковаговий маршрутизатор для Go з вбудованою підтримкою Middleware:

go get -u github.com/go-chi/chi/v5

Групування маршрутів з різними Middleware

Chi дозволяє застосовувати Middleware до груп маршрутів:

// cmd/chi-server/main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "myapp/middleware"

    "github.com/go-chi/chi/v5"
)

func main() {
    r := chi.NewRouter()
    
    // Глобальні Middleware (застосовуються до всіх маршрутів)
    r.Use(middleware.Logger)
    r.Use(middleware.Recovery)
    
    // Публічні маршрути
    r.Get("/api/public", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{
            "message": "Public endpoint",
        })
    })
    
    // Група захищених маршрутів
    r.Group(func(r chi.Router) {
        r.Use(middleware.Auth) // Auth тільки для цієї групи
        
        r.Get("/api/profile", func(w http.ResponseWriter, r *http.Request) {
            userID := r.Context().Value(middleware.UserIDKey).(string)
            json.NewEncoder(w).Encode(map[string]string{
                "user_id": userID,
                "message": "Your profile",
            })
        })
        
        r.Get("/api/settings", func(w http.ResponseWriter, r *http.Request) {
            userID := r.Context().Value(middleware.UserIDKey).(string)
            json.NewEncoder(w).Encode(map[string]string{
                "user_id": userID,
                "message": "Your settings",
            })
        })
    })
    
    // Адміністративна група з додатковим rate limiting
    r.Group(func(r chi.Router) {
        r.Use(middleware.Auth)
        rateLimiter := middleware.NewRateLimiter(1, 3)
        r.Use(rateLimiter.Middleware)
        
        r.Get("/api/admin/users", func(w http.ResponseWriter, r *http.Request) {
            json.NewEncoder(w).Encode(map[string]string{
                "message": "Admin only - user list",
            })
        })
    })
    
    fmt.Println("Chi server with grouped middleware on :8080...")
    log.Fatal(http.ListenAndServe(":8080", r))
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "Chi Router" {
  rectangle "Global Middleware" as GM #FEF3C7 {
    note right
      Logger
      Recovery
    end note
  }
  
  rectangle "/api/public" as Public #E2E8F0
  
  rectangle "Protected Group" as PG #DBEAFE {
    note right: + Auth
    rectangle "/api/profile" as Profile #E2E8F0
    rectangle "/api/settings" as Settings #E2E8F0
  }
  
  rectangle "Admin Group" as AG #DCFCE7 {
    note right
      + Auth
      + RateLimit
    end note
    rectangle "/api/admin/users" as Admin #E2E8F0
  }
  
  GM -down-> Public
  GM -down-> PG
  GM -down-> AG
}

@enduml
Chi дозволяє створювати вкладені групи з наростаючою кількістю Middleware. Це зручно для побудови складних ієрархій доступу (публічні → автентифіковані → адміністратори → супер-адміністратори).

Повний приклад: реальний сервер з усіма Middleware

Об'єднаємо все, що ми вивчили, в один повноцінний сервер:

// cmd/full-server/main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "time"
    "myapp/middleware"

    "github.com/go-chi/chi/v5"
)

// Структури даних
type User struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
}

// In-memory зберігання (у реальності — БД)
var users = map[string]*User{
    "user-1": {
        ID:        "user-1",
        Name:      "John Doe",
        Email:     "john@example.com",
        CreatedAt: time.Now().Add(-24 * time.Hour),
    },
}

func main() {
    r := chi.NewRouter()
    
    // Глобальні Middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recovery)
    corsConfig := middleware.DefaultCORSConfig()
    r.Use(middleware.CORS(corsConfig))
    
    // Health check
    r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]interface{}{
            "status":    "ok",
            "timestamp": time.Now().Unix(),
        })
    })
    
    // Публічні маршрути
    r.Post("/api/register", registerHandler)
    r.Post("/api/login", loginHandler)
    
    // Захищені маршрути
    r.Group(func(r chi.Router) {
        r.Use(middleware.Auth)
        
        r.Get("/api/users/me", getCurrentUserHandler)
        r.Put("/api/users/me", updateCurrentUserHandler)
    })
    
    // Адмінські маршрути з rate limiting
    r.Group(func(r chi.Router) {
        r.Use(middleware.Auth)
        rateLimiter := middleware.NewRateLimiter(5, 10)
        r.Use(rateLimiter.Middleware)
        
        r.Get("/api/admin/users", listUsersHandler)
        r.Get("/api/admin/stats", getStatsHandler)
    })
    
    fmt.Println("╔════════════════════════════════════════╗")
    fmt.Println("║  Full-featured server on :8080 🚀      ║")
    fmt.Println("╠════════════════════════════════════════╣")
    fmt.Println("║  Endpoints:                            ║")
    fmt.Println("║   GET  /health                         ║")
    fmt.Println("║   POST /api/register                   ║")
    fmt.Println("║   POST /api/login                      ║")
    fmt.Println("║   GET  /api/users/me  (auth)           ║")
    fmt.Println("║   PUT  /api/users/me  (auth)           ║")
    fmt.Println("║   GET  /api/admin/users (auth+limit)   ║")
    fmt.Println("║   GET  /api/admin/stats (auth+limit)   ║")
    fmt.Println("╚════════════════════════════════════════╝")
    
    log.Fatal(http.ListenAndServe(":8080", r))
}

func registerHandler(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Name     string `json:"name"`
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Тут має бути хешування пароля і збереження в БД
    newUser := &User{
        ID:        fmt.Sprintf("user-%d", len(users)+1),
        Name:      req.Name,
        Email:     req.Email,
        CreatedAt: time.Now(),
    }
    users[newUser.ID] = newUser
    
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(newUser)
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
    // Спрощена реалізація (у реальності — перевірка паролю, генерація JWT)
    json.NewEncoder(w).Encode(map[string]string{
        "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", // Заглушка
    })
}

func getCurrentUserHandler(w http.ResponseWriter, r *http.Request) {
    userID := r.Context().Value(middleware.UserIDKey).(string)
    
    user, exists := users[userID]
    if !exists {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    
    json.NewEncoder(w).Encode(user)
}

func updateCurrentUserHandler(w http.ResponseWriter, r *http.Request) {
    userID := r.Context().Value(middleware.UserIDKey).(string)
    
    var updates struct {
        Name string `json:"name"`
    }
    if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    user, exists := users[userID]
    if !exists {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    
    user.Name = updates.Name
    json.NewEncoder(w).Encode(user)
}

func listUsersHandler(w http.ResponseWriter, r *http.Request) {
    allUsers := make([]*User, 0, len(users))
    for _, u := range users {
        allUsers = append(allUsers, u)
    }
    json.NewEncoder(w).Encode(allUsers)
}

func getStatsHandler(w http.ResponseWriter, r *http.Request) {
    json.NewEncoder(w).Encode(map[string]interface{}{
        "total_users":     len(users),
        "server_uptime":   "24h", // Заглушка
        "requests_today":  12543,  // Заглушка
    })
}

Цей сервер демонструє всі аспекти Middleware, які ми вивчили: логування, recovery, CORS, автентифікацію, rate limiting та групування маршрутів.


Від монолітного коду до Clean Architecture

Проблема: бізнес-логіка у HTTP-обробниках

Повернемось до нашого прикладу сервера. Зараз вся логіка знаходиться безпосередньо в обробниках:

func registerHandler(w http.ResponseWriter, r *http.Request) {
    // 1. Парсинг JSON
    var req struct {
        Name     string `json:"name"`
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    json.NewDecoder(r.Body).Decode(&req)
    
    // 2. Валідація
    if req.Email == "" || req.Password == "" {
        http.Error(w, "Invalid input", http.StatusBadRequest)
        return
    }
    
    // 3. Перевірка існування користувача
    for _, u := range users {
        if u.Email == req.Email {
            http.Error(w, "Email already exists", http.StatusConflict)
            return
        }
    }
    
    // 4. Хешування пароля
    hashedPassword := hashPassword(req.Password)
    
    // 5. Створення користувача
    newUser := &User{
        ID:       generateID(),
        Name:     req.Name,
        Email:    req.Email,
        Password: hashedPassword,
    }
    users[newUser.ID] = newUser
    
    // 6. Формування відповіді
    json.NewEncoder(w).Encode(newUser)
}

Що не так з цим кодом?

  1. Неможливо протестувати бізнес-логіку без запуску HTTP-сервера
  2. Порушення Single Responsibility Principle: обробник відповідає і за HTTP, і за валідацію, і за роботу з даними
  3. Жорстка прив'язка до способу зберігання даних (in-memory map)
  4. Неможливо повторно використати логіку реєстрації в інших місцях (наприклад, в CLI-утиліті або gRPC-сервісі)
  5. Складність розширення: додавання функціональності (відправка email, логування аудиту) робить код ще більш заплутаним

Крок 1: Виділяємо бізнес-логіку в окрему функцію

Спробуємо винести логіку реєстрації за межі HTTP-обробника:

// business/user.go
package business

import (
    "errors"
    "fmt"
)

type User struct {
    ID       string
    Name     string
    Email    string
    Password string
}

// RegisterUser — бізнес-логіка реєстрації
func RegisterUser(name, email, password string, existingUsers map[string]*User) (*User, error) {
    // Валідація
    if email == "" || password == "" {
        return nil, errors.New("email and password are required")
    }
    
    if len(password) < 8 {
        return nil, errors.New("password must be at least 8 characters")
    }
    
    // Перевірка дублікатів
    for _, u := range existingUsers {
        if u.Email == email {
            return nil, errors.New("email already exists")
        }
    }
    
    // Хешування пароля
    hashedPassword := hashPassword(password)
    
    // Створення користувача
    user := &User{
        ID:       fmt.Sprintf("user-%d", len(existingUsers)+1),
        Name:     name,
        Email:    email,
        Password: hashedPassword,
    }
    
    return user, nil
}

func hashPassword(password string) string {
    // Спрощена реалізація
    return "hashed_" + password
}

Тепер HTTP-обробник стає простішим:

func registerHandler(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Name     string `json:"name"`
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Викликаємо бізнес-логіку
    user, err := business.RegisterUser(req.Name, req.Email, req.Password, users)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Зберігаємо користувача
    users[user.ID] = user
    
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

Це вже краще, але:

  • Бізнес-логіка все ще залежить від map[string]*User (конкретного способу зберігання)
  • Складно замінити map на базу даних
  • Немає чіткого розділення відповідальності

Clean Architecture: філософія та принципи

Історична довідка

Clean Architecture (чиста архітектура) — це архітектурний підхід, запропонований Робертом Мартіном (Uncle Bob) у 2012 році. Він узагальнює ідеї з кількох інших архітектурних патернів:

  • Hexagonal Architecture (Alistair Cockburn, 2005)
  • Onion Architecture (Jeffrey Palermo, 2008)
  • DCI (Data, Context, Interactions) (Trygve Reenskaug, 2000-і)

Всі ці підходи об'єднує спільна ідея: бізнес-логіка не повинна залежати від технічних деталей.

Ключовий принцип: Dependency Rule

Правило залежностей (Dependency Rule): Залежності вихідного коду можуть вказувати тільки всередину (до внутрішніх шарів). Внутрішні шари не повинні знати про існування зовнішніх.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

circle "Entities\n(Domain)" as E #FEF3C7
circle "Use Cases\n(Business Logic)" as UC #DBEAFE
circle "Interface Adapters\n(Controllers, Gateways)" as IA #E2E8F0
circle "Frameworks & Drivers\n(HTTP, DB, UI)" as FD #F1F5F9

E -[hidden]-> UC
UC -[hidden]-> IA
IA -[hidden]-> FD

note top of E
  <b>Центр</b>
  Доменні сутності
  Бізнес-правила
  
  Залежить: ні від кого
end note

note right of UC
  Сценарії використання
  Application logic
  
  Залежить: Domain
end note

note bottom of IA
  HTTP handlers, Repositories
  Presenters, Gateways
  
  Залежить: Use Cases
end note

note left of FD
  Web framework, СУБД
  External APIs
  
  Залежить: Interface Adapters
end note

FD --> IA : залежність
IA --> UC : залежність
UC --> E : залежність

@enduml

Чотири основні шари

1. Entities (Domain Layer) — шар доменних сутностей

Найвнутрішній шар. Містить:

  • Доменні об'єкти — структури даних, що моделюють предметну область
  • Бізнес-правила рівня сутності — валідація, обчислення, інваріанти
// internal/domain/user.go
package domain

import (
    "errors"
    "regexp"
    "time"
)

// User — доменна сутність користувача
type User struct {
    ID        string
    Name      string
    Email     string
    Password  string // Хеш пароля
    CreatedAt time.Time
    UpdatedAt time.Time
}

var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

// Validate перевіряє коректність даних користувача
func (u *User) Validate() error {
    if u.Name == "" {
        return errors.New("name is required")
    }
    
    if u.Email == "" {
        return errors.New("email is required")
    }
    
    if !emailRegex.MatchString(u.Email) {
        return errors.New("invalid email format")
    }
    
    if len(u.Password) < 8 {
        return errors.New("password must be at least 8 characters")
    }
    
    return nil
}

// IsPasswordValid перевіряє відповідність пароля хешу
func (u *User) IsPasswordValid(plainPassword string) bool {
    // У реальності тут bcrypt.CompareHashAndPassword
    return "hashed_"+plainPassword == u.Password
}
Шар Domain не імпортує жодні зовнішні залежності (крім стандартної бібліотеки Go). Це робить його максимально переносимим — доменну логіку можна використовувати в будь-якому проєкті.

2. Use Cases (Application/Business Logic Layer) — шар сценаріїв

Містить конкретні бізнес-сценарії:

  • Реєстрація користувача
  • Вхід в систему
  • Оформлення замовлення
  • Відправка повідомлення

Use Case залежить від інтерфейсів, а не від реалізацій:

// internal/domain/user_repository.go
package domain

import "context"

// UserRepository — інтерфейс для доступу до даних користувачів
type UserRepository interface {
    Create(ctx context.Context, user *User) error
    FindByID(ctx context.Context, id string) (*User, error)
    FindByEmail(ctx context.Context, email string) (*User, error)
    Update(ctx context.Context, user *User) error
    Delete(ctx context.Context, id string) error
}

// Стандартні помилки домену
var (
    ErrUserNotFound      = errors.New("user not found")
    ErrUserAlreadyExists = errors.New("user already exists")
    ErrInvalidCredentials = errors.New("invalid credentials")
)

Тепер створюємо Use Case:

// internal/usecase/user_usecase.go
package usecase

import (
    "context"
    "myapp/internal/domain"
    "time"

    "golang.org/x/crypto/bcrypt"
)

// UserUseCase — бізнес-логіка роботи з користувачами
type UserUseCase struct {
    repo domain.UserRepository
}

// NewUserUseCase створює новий UseCase
func NewUserUseCase(repo domain.UserRepository) *UserUseCase {
    return &UserUseCase{repo: repo}
}

// RegisterUser реєструє нового користувача
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, plainPassword string) (*domain.User, error) {
    // 1. Перевіряємо, чи не існує користувач з таким email
    existing, _ := uc.repo.FindByEmail(ctx, email)
    if existing != nil {
        return nil, domain.ErrUserAlreadyExists
    }
    
    // 2. Хешуємо пароль
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
    if err != nil {
        return nil, err
    }
    
    // 3. Створюємо доменну сутність
    user := &domain.User{
        ID:        generateUserID(),
        Name:      name,
        Email:     email,
        Password:  string(hashedPassword),
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }
    
    // 4. Валідуємо доменні правила
    if err := user.Validate(); err != nil {
        return nil, err
    }
    
    // 5. Зберігаємо через репозиторій
    if err := uc.repo.Create(ctx, user); err != nil {
        return nil, err
    }
    
    return user, nil
}

// Login виконує вхід користувача
func (uc *UserUseCase) Login(ctx context.Context, email, plainPassword string) (*domain.User, error) {
    // 1. Знаходимо користувача
    user, err := uc.repo.FindByEmail(ctx, email)
    if err != nil {
        return nil, domain.ErrInvalidCredentials
    }
    
    // 2. Перевіряємо пароль
    if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(plainPassword)); err != nil {
        return nil, domain.ErrInvalidCredentials
    }
    
    return user, nil
}

func generateUserID() string {
    // У реальності: UUID або ULID
    return fmt.Sprintf("user-%d", time.Now().UnixNano())
}

Ключові моменти:

  • UserUseCase залежить від інтерфейсу domain.UserRepository, а не від конкретної реалізації
  • Бізнес-логіка не знає, де зберігаються дані (PostgreSQL? MongoDB? in-memory?)
  • Немає залежності від HTTP, JSON, або будь-яких інших технічних деталей

3. Repository Pattern — абстракція доступу до даних

Repository — це міст між бізнес-логікою та базою даних. Інтерфейс визначається в Domain, реалізація — у зовнішньому шарі.

In-memory реалізація (для тестування та прототипування):

// internal/repository/memory/user_repository.go
package memory

import (
    "context"
    "myapp/internal/domain"
    "sync"
)

// UserRepository — in-memory реалізація
type UserRepository struct {
    mu    sync.RWMutex
    users map[string]*domain.User
}

// NewUserRepository створює новий in-memory репозиторій
func NewUserRepository() *UserRepository {
    return &UserRepository{
        users: make(map[string]*domain.User),
    }
}

func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    r.users[user.ID] = user
    return nil
}

func (r *UserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    user, exists := r.users[id]
    if !exists {
        return nil, domain.ErrUserNotFound
    }
    return user, nil
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    
    for _, user := range r.users {
        if user.Email == email {
            return user, nil
        }
    }
    return nil, domain.ErrUserNotFound
}

func (r *UserRepository) Update(ctx context.Context, user *domain.User) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    if _, exists := r.users[user.ID]; !exists {
        return domain.ErrUserNotFound
    }
    
    r.users[user.ID] = user
    return nil
}

func (r *UserRepository) Delete(ctx context.Context, id string) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    delete(r.users, id)
    return nil
}

PostgreSQL реалізація (для продакшну):

// internal/repository/postgres/user_repository.go
package postgres

import (
    "context"
    "database/sql"
    "myapp/internal/domain"
)

// UserRepository — PostgreSQL реалізація
type UserRepository struct {
    db *sql.DB
}

// NewUserRepository створює PostgreSQL репозиторій
func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
    query := `
        INSERT INTO users (id, name, email, password, created_at, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6)
    `
    
    _, err := r.db.ExecContext(ctx, query,
        user.ID,
        user.Name,
        user.Email,
        user.Password,
        user.CreatedAt,
        user.UpdatedAt,
    )
    
    return err
}

func (r *UserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
    query := `
        SELECT id, name, email, password, created_at, updated_at
        FROM users
        WHERE id = $1
    `
    
    user := &domain.User{}
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &user.ID,
        &user.Name,
        &user.Email,
        &user.Password,
        &user.CreatedAt,
        &user.UpdatedAt,
    )
    
    if err == sql.ErrNoRows {
        return nil, domain.ErrUserNotFound
    }
    
    return user, err
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
    query := `
        SELECT id, name, email, password, created_at, updated_at
        FROM users
        WHERE email = $1
    `
    
    user := &domain.User{}
    err := r.db.QueryRowContext(ctx, query, email).Scan(
        &user.ID,
        &user.Name,
        &user.Email,
        &user.Password,
        &user.CreatedAt,
        &user.UpdatedAt,
    )
    
    if err == sql.ErrNoRows {
        return nil, domain.ErrUserNotFound
    }
    
    return user, err
}

func (r *UserRepository) Update(ctx context.Context, user *domain.User) error {
    query := `
        UPDATE users
        SET name = $2, email = $3, password = $4, updated_at = $5
        WHERE id = $1
    `
    
    result, err := r.db.ExecContext(ctx, query,
        user.ID,
        user.Name,
        user.Email,
        user.Password,
        user.UpdatedAt,
    )
    
    if err != nil {
        return err
    }
    
    rows, _ := result.RowsAffected()
    if rows == 0 {
        return domain.ErrUserNotFound
    }
    
    return nil
}

func (r *UserRepository) Delete(ctx context.Context, id string) error {
    query := `DELETE FROM users WHERE id = $1`
    _, err := r.db.ExecContext(ctx, query, id)
    return err
}
Ключова перевага Repository Pattern: UseCase не знає, з якою СУБД він працює. Можна легко замінити PostgreSQL на MySQL, MongoDB, або навіть cloud storage (AWS DynamoDB), не змінюючи жодного рядка у UseCase!

4. Delivery Layer (HTTP Handlers) — шар доставки

Найзовнішній шар. Відповідає за:

  • Парсинг HTTP-запитів (JSON, form data, query parameters)
  • Виклик відповідного UseCase
  • Формування HTTP-відповіді
// internal/delivery/http/user_handler.go
package http

import (
    "encoding/json"
    "myapp/internal/domain"
    "myapp/internal/usecase"
    "net/http"
)

// UserHandler — HTTP-обробники для користувачів
type UserHandler struct {
    useCase *usecase.UserUseCase
}

// NewUserHandler створює новий handler
func NewUserHandler(useCase *usecase.UserUseCase) *UserHandler {
    return &UserHandler{useCase: useCase}
}

// RegisterRequest — DTO для реєстрації
type RegisterRequest struct {
    Name     string `json:"name"`
    Email    string `json:"email"`
    Password string `json:"password"`
}

// UserResponse — DTO для відповіді (без пароля!)
type UserResponse struct {
    ID        string `json:"id"`
    Name      string `json:"name"`
    Email     string `json:"email"`
    CreatedAt string `json:"created_at"`
}

// Register — POST /api/users/register
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
    var req RegisterRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Викликаємо UseCase
    user, err := h.useCase.RegisterUser(r.Context(), req.Name, req.Email, req.Password)
    if err != nil {
        // Мапування доменних помилок на HTTP-статуси
        if err == domain.ErrUserAlreadyExists {
            http.Error(w, err.Error(), http.StatusConflict)
            return
        }
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Формуємо відповідь (без пароля!)
    response := UserResponse{
        ID:        user.ID,
        Name:      user.Name,
        Email:     user.Email,
        CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(response)
}

// LoginRequest — DTO для входу
type LoginRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
}

// LoginResponse — DTO з токеном
type LoginResponse struct {
    Token string       `json:"token"`
    User  UserResponse `json:"user"`
}

// Login — POST /api/users/login
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
    var req LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Викликаємо UseCase
    user, err := h.useCase.Login(r.Context(), req.Email, req.Password)
    if err != nil {
        if err == domain.ErrInvalidCredentials {
            http.Error(w, "Invalid email or password", http.StatusUnauthorized)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    // Генеруємо JWT токен (тут спрощено)
    token := generateJWT(user.ID)
    
    response := LoginResponse{
        Token: token,
        User: UserResponse{
            ID:        user.ID,
            Name:      user.Name,
            Email:     user.Email,
            CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
        },
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

func generateJWT(userID string) string {
    // Спрощена реалізація
    // У реальності: jwt-go або golang-jwt
    return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + userID
}
DTO (Data Transfer Object) — це структури для передачі даних через мережу. Вони відрізняються від доменних сутностей:
  • DTO містить тільки поля, необхідні для клієнта (без пароля!)
  • DTO може мати іншу структуру (згруповані поля, змінені імена)
  • DTO спрощує версіонування API (зміна DTO не впливає на Domain)

Dependency Injection: збираємо все разом

Тепер потрібно з'єднати всі шари. Це робиться у функції main() через ручне впровадження залежностей (manual dependency injection):

// cmd/server/main.go
package main

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"
    
    "myapp/internal/delivery/http"
    httphandler "myapp/internal/delivery/http"
    "myapp/internal/repository/memory"
    "myapp/internal/repository/postgres"
    "myapp/internal/usecase"
    "myapp/middleware"
    
    "github.com/go-chi/chi/v5"
    _ "github.com/lib/pq"
)

func main() {
    // Вибираємо тип репозиторію через змінну оточення
    usePostgres := os.Getenv("USE_POSTGRES") == "true"
    
    var userRepo domain.UserRepository
    
    if usePostgres {
        // PostgreSQL реалізація
        db, err := sql.Open("postgres", 
            "host=localhost port=5432 user=myuser password=mypass dbname=mydb sslmode=disable")
        if err != nil {
            log.Fatal("Failed to connect to database:", err)
        }
        defer db.Close()
        
        if err := db.Ping(); err != nil {
            log.Fatal("Database is unreachable:", err)
        }
        
        userRepo = postgres.NewUserRepository(db)
        log.Println("Using PostgreSQL repository")
    } else {
        // In-memory реалізація
        userRepo = memory.NewUserRepository()
        log.Println("Using in-memory repository")
    }
    
    // Створюємо UseCase (впроваджуємо репозиторій)
    userUseCase := usecase.NewUserUseCase(userRepo)
    
    // Створюємо HTTP handler (впроваджуємо UseCase)
    userHandler := httphandler.NewUserHandler(userUseCase)
    
    // Налаштовуємо роутер
    r := chi.NewRouter()
    
    // Глобальні Middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recovery)
    
    // Маршрути
    r.Post("/api/users/register", userHandler.Register)
    r.Post("/api/users/login", userHandler.Login)
    
    // Захищені маршрути
    r.Group(func(r chi.Router) {
        r.Use(middleware.Auth)
        // тут можуть бути інші маршрути
    })
    
    fmt.Println("🚀 Server with Clean Architecture on :8080")
    log.Fatal(http.ListenAndServe(":8080", r))
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

component "main()" as Main #FEF3C7
component "UserHandler\n(Delivery)" as Handler #F1F5F9
component "UserUseCase\n(Business Logic)" as UseCase #DBEAFE
component "UserRepository\nInterface" as RepoInterface #E2E8F0
component "PostgresUserRepository\n(Implementation)" as PostgresRepo #DCFCE7
component "MemoryUserRepository\n(Implementation)" as MemoryRepo #DCFCE7

Main -down-> PostgresRepo : створює
Main -down-> UseCase : створює\nта впроваджує repo
Main -down-> Handler : створює\nта впроваджує useCase

Handler -right-> UseCase : викликає методи
UseCase -right-> RepoInterface : викликає методи
PostgresRepo .up.|> RepoInterface : реалізує
MemoryRepo .up.|> RepoInterface : реалізує

note right of UseCase
  UseCase залежить від
  <b>інтерфейсу</b>, а не від
  конкретної реалізації
end note

note bottom of Main
  main() вирішує, яку
  реалізацію використовувати:
  PostgreSQL або In-Memory
end note

@enduml

Що ми отримали?

  1. Тестованість: Кожен шар можна тестувати незалежно з моками
  2. Гнучкість: Легко змінити СУБД, HTTP-фреймворк, формат відповіді
  3. Розширюваність: Додати gRPC-сервіс паралельно з HTTP без зміни бізнес-логіки
  4. Підтримуваність: Чітке розділення відповідальності
Ручна Dependency Injection у Go є стандартною практикою. На відміну від Java (Spring) чи C# (.NET DI), Go-спільнота віддає перевагу явності та простоті. Фреймворки типу Wire або Dig існують, але використовуються рідко.

Модульне тестування з Clean Architecture

Перевага архітектури: ізольоване тестування кожного шару

Завдяки чіткому розділенню шарів, ми можемо тестувати:

  • Domain: валідацію та бізнес-правила
  • UseCase: бізнес-логіку з мок-репозиторіями
  • Repository: запити до БД (інтеграційні тести)
  • Handlers: HTTP-шар з мок-UseCases

Пакет net/http/httptest

Go надає вбудований пакет для тестування HTTP без запуску сервера:

Основні компоненти:

  • httptest.ResponseRecorder — записує відповідь без надсилання по мережі
  • httptest.NewRequest — створює фіктивний HTTP-запит

Крок 1: Тестування Domain

// internal/domain/user_test.go
package domain

import (
    "testing"
)

func TestUser_Validate(t *testing.T) {
    tests := []struct {
        name    string
        user    User
        wantErr bool
        errMsg  string
    }{
        {
            name: "Valid user",
            user: User{
                Name:     "John Doe",
                Email:    "john@example.com",
                Password: "password123",
            },
            wantErr: false,
        },
        {
            name: "Missing name",
            user: User{
                Name:     "",
                Email:    "john@example.com",
                Password: "password123",
            },
            wantErr: true,
            errMsg:  "name is required",
        },
        {
            name: "Invalid email format",
            user: User{
                Name:     "John",
                Email:    "invalid-email",
                Password: "password123",
            },
            wantErr: true,
            errMsg:  "invalid email format",
        },
        {
            name: "Short password",
            user: User{
                Name:     "John",
                Email:    "john@example.com",
                Password: "short",
            },
            wantErr: true,
            errMsg:  "password must be at least 8 characters",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := tt.user.Validate()
            
            if tt.wantErr {
                if err == nil {
                    t.Errorf("Expected error, got nil")
                } else if err.Error() != tt.errMsg {
                    t.Errorf("Expected error %q, got %q", tt.errMsg, err.Error())
                }
            } else {
                if err != nil {
                    t.Errorf("Expected no error, got %v", err)
                }
            }
        })
    }
}

Запуск тестів:

go test ./internal/domain
$ go test ./internal/domain -v
=== RUN TestUser_Validate
=== RUN TestUser_Validate/Valid_user
=== RUN TestUser_Validate/Missing_name
=== RUN TestUser_Validate/Invalid_email_format
=== RUN TestUser_Validate/Short_password
--- PASS: TestUser_Validate (0.00s)
--- PASS: TestUser_Validate/Valid_user (0.00s)
--- PASS: TestUser_Validate/Missing_name (0.00s)
--- PASS: TestUser_Validate/Invalid_email_format (0.00s)
--- PASS: TestUser_Validate/Short_password (0.00s)
PASS
ok myapp/internal/domain 0.003s

Крок 2: Тестування UseCase з мок-репозиторієм

Створимо мок-реалізацію UserRepository:

// internal/usecase/user_usecase_test.go
package usecase

import (
    "context"
    "myapp/internal/domain"
    "testing"
)

// MockUserRepository — мок для тестування
type MockUserRepository struct {
    users         map[string]*domain.User
    createErr     error
    findByEmailFn func(ctx context.Context, email string) (*domain.User, error)
}

func NewMockUserRepository() *MockUserRepository {
    return &MockUserRepository{
        users: make(map[string]*domain.User),
    }
}

func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error {
    if m.createErr != nil {
        return m.createErr
    }
    m.users[user.ID] = user
    return nil
}

func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
    if m.findByEmailFn != nil {
        return m.findByEmailFn(ctx, email)
    }
    
    for _, u := range m.users {
        if u.Email == email {
            return u, nil
        }
    }
    return nil, domain.ErrUserNotFound
}

func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
    user, exists := m.users[id]
    if !exists {
        return nil, domain.ErrUserNotFound
    }
    return user, nil
}

func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error {
    m.users[user.ID] = user
    return nil
}

func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
    delete(m.users, id)
    return nil
}

Тепер тестуємо UseCase:

// internal/usecase/user_usecase_test.go (продовження)

func TestUserUseCase_RegisterUser_Success(t *testing.T) {
    // Arrange: створюємо мок-репозиторій
    mockRepo := NewMockUserRepository()
    uc := NewUserUseCase(mockRepo)
    
    // Act: викликаємо метод UseCase
    user, err := uc.RegisterUser(
        context.Background(),
        "John Doe",
        "john@example.com",
        "password123",
    )
    
    // Assert: перевіряємо результат
    if err != nil {
        t.Fatalf("Expected no error, got %v", err)
    }
    
    if user == nil {
        t.Fatal("Expected user, got nil")
    }
    
    if user.Name != "John Doe" {
        t.Errorf("Expected name 'John Doe', got '%s'", user.Name)
    }
    
    if user.Email != "john@example.com" {
        t.Errorf("Expected email 'john@example.com', got '%s'", user.Email)
    }
    
    // Перевіряємо, що користувач збережений у репозиторії
    savedUser, err := mockRepo.FindByEmail(context.Background(), "john@example.com")
    if err != nil {
        t.Fatalf("User not saved in repository: %v", err)
    }
    
    if savedUser.ID != user.ID {
        t.Errorf("Saved user ID mismatch")
    }
}

func TestUserUseCase_RegisterUser_DuplicateEmail(t *testing.T) {
    // Arrange: створюємо репозиторій з існуючим користувачем
    mockRepo := NewMockUserRepository()
    mockRepo.findByEmailFn = func(ctx context.Context, email string) (*domain.User, error) {
        // Імітуємо, що користувач вже існує
        return &domain.User{
            ID:    "existing-user",
            Email: email,
        }, nil
    }
    
    uc := NewUserUseCase(mockRepo)
    
    // Act
    user, err := uc.RegisterUser(
        context.Background(),
        "Jane Doe",
        "jane@example.com",
        "password123",
    )
    
    // Assert: очікуємо помилку
    if err != domain.ErrUserAlreadyExists {
        t.Errorf("Expected ErrUserAlreadyExists, got %v", err)
    }
    
    if user != nil {
        t.Errorf("Expected nil user, got %v", user)
    }
}

func TestUserUseCase_Login_Success(t *testing.T) {
    // Arrange: створюємо користувача з хешованим паролем
    mockRepo := NewMockUserRepository()
    
    // Хешуємо пароль (bcrypt)
    hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
    
    existingUser := &domain.User{
        ID:       "user-1",
        Name:     "John",
        Email:    "john@example.com",
        Password: string(hashedPassword),
    }
    mockRepo.users[existingUser.ID] = existingUser
    
    uc := NewUserUseCase(mockRepo)
    
    // Act
    user, err := uc.Login(context.Background(), "john@example.com", "password123")
    
    // Assert
    if err != nil {
        t.Fatalf("Expected successful login, got error: %v", err)
    }
    
    if user.ID != "user-1" {
        t.Errorf("Expected user ID 'user-1', got '%s'", user.ID)
    }
}

func TestUserUseCase_Login_InvalidPassword(t *testing.T) {
    // Arrange
    mockRepo := NewMockUserRepository()
    hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.DefaultCost)
    
    mockRepo.users["user-1"] = &domain.User{
        ID:       "user-1",
        Email:    "john@example.com",
        Password: string(hashedPassword),
    }
    
    uc := NewUserUseCase(mockRepo)
    
    // Act: неправильний пароль
    user, err := uc.Login(context.Background(), "john@example.com", "wrong-password")
    
    // Assert
    if err != domain.ErrInvalidCredentials {
        t.Errorf("Expected ErrInvalidCredentials, got %v", err)
    }
    
    if user != nil {
        t.Errorf("Expected nil user, got %v", user)
    }
}
go test ./internal/usecase -v
$ go test ./internal/usecase -v
=== RUN TestUserUseCase_RegisterUser_Success
--- PASS: TestUserUseCase_RegisterUser_Success (0.12s)
=== RUN TestUserUseCase_RegisterUser_DuplicateEmail
--- PASS: TestUserUseCase_RegisterUser_DuplicateEmail (0.00s)
=== RUN TestUserUseCase_Login_Success
--- PASS: TestUserUseCase_Login_Success (0.11s)
=== RUN TestUserUseCase_Login_InvalidPassword
--- PASS: TestUserUseCase_Login_InvalidPassword (0.10s)
PASS
ok myapp/internal/usecase 0.334s

Крок 3: Тестування HTTP-обробників

Тестуємо HTTP-шар з мок-UseCase:

// internal/delivery/http/user_handler_test.go
package http

import (
    "bytes"
    "context"
    "encoding/json"
    "myapp/internal/domain"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"
)

// MockUserUseCase — мок UseCase для тестування handlers
type MockUserUseCase struct {
    registerFn func(ctx context.Context, name, email, password string) (*domain.User, error)
    loginFn    func(ctx context.Context, email, password string) (*domain.User, error)
}

func (m *MockUserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error) {
    if m.registerFn != nil {
        return m.registerFn(ctx, name, email, password)
    }
    return nil, nil
}

func (m *MockUserUseCase) Login(ctx context.Context, email, password string) (*domain.User, error) {
    if m.loginFn != nil {
        return m.loginFn(ctx, email, password)
    }
    return nil, nil
}

func TestUserHandler_Register_Success(t *testing.T) {
    // Arrange: створюємо мок UseCase
    mockUC := &MockUserUseCase{
        registerFn: func(ctx context.Context, name, email, password string) (*domain.User, error) {
            return &domain.User{
                ID:        "user-123",
                Name:      name,
                Email:     email,
                CreatedAt: time.Now(),
            }, nil
        },
    }
    
    handler := NewUserHandler(mockUC)
    
    // Створюємо HTTP-запит
    reqBody := RegisterRequest{
        Name:     "John Doe",
        Email:    "john@example.com",
        Password: "password123",
    }
    body, _ := json.Marshal(reqBody)
    
    req := httptest.NewRequest(http.MethodPost, "/api/users/register", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    
    // Створюємо ResponseRecorder
    rr := httptest.NewRecorder()
    
    // Act: викликаємо handler
    handler.Register(rr, req)
    
    // Assert: перевіряємо статус-код
    if status := rr.Code; status != http.StatusCreated {
        t.Errorf("Expected status %d, got %d", http.StatusCreated, status)
    }
    
    // Перевіряємо Content-Type
    expectedCT := "application/json"
    if ct := rr.Header().Get("Content-Type"); ct != expectedCT {
        t.Errorf("Expected Content-Type %s, got %s", expectedCT, ct)
    }
    
    // Перевіряємо тіло відповіді
    var response UserResponse
    if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
        t.Fatalf("Failed to decode response: %v", err)
    }
    
    if response.Email != "john@example.com" {
        t.Errorf("Expected email 'john@example.com', got '%s'", response.Email)
    }
    
    if response.Name != "John Doe" {
        t.Errorf("Expected name 'John Doe', got '%s'", response.Name)
    }
}

func TestUserHandler_Register_InvalidJSON(t *testing.T) {
    mockUC := &MockUserUseCase{}
    handler := NewUserHandler(mockUC)
    
    // Невалідний JSON
    req := httptest.NewRequest(http.MethodPost, "/api/users/register", 
        bytes.NewReader([]byte("invalid json")))
    req.Header.Set("Content-Type", "application/json")
    
    rr := httptest.NewRecorder()
    handler.Register(rr, req)
    
    if status := rr.Code; status != http.StatusBadRequest {
        t.Errorf("Expected status %d, got %d", http.StatusBadRequest, status)
    }
}

func TestUserHandler_Register_DuplicateEmail(t *testing.T) {
    mockUC := &MockUserUseCase{
        registerFn: func(ctx context.Context, name, email, password string) (*domain.User, error) {
            return nil, domain.ErrUserAlreadyExists
        },
    }
    
    handler := NewUserHandler(mockUC)
    
    reqBody := RegisterRequest{
        Name:     "John",
        Email:    "existing@example.com",
        Password: "password123",
    }
    body, _ := json.Marshal(reqBody)
    
    req := httptest.NewRequest(http.MethodPost, "/api/users/register", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    
    rr := httptest.NewRecorder()
    handler.Register(rr, req)
    
    if status := rr.Code; status != http.StatusConflict {
        t.Errorf("Expected status %d (Conflict), got %d", http.StatusConflict, status)
    }
}
go test ./internal/delivery/http -v
$ go test ./internal/delivery/http -v
=== RUN TestUserHandler_Register_Success
--- PASS: TestUserHandler_Register_Success (0.00s)
=== RUN TestUserHandler_Register_InvalidJSON
--- PASS: TestUserHandler_Register_InvalidJSON (0.00s)
=== RUN TestUserHandler_Register_DuplicateEmail
--- PASS: TestUserHandler_Register_DuplicateEmail (0.00s)
PASS
ok myapp/internal/delivery/http 0.002s
Переваги тестування з моками: тести виконуються миттєво (немає реальних запитів до БД чи HTTP-сервера), детерміновано (результат завжди однаковий) та ізольовано (помилка в одному шарі не впливає на тести іншого).

Структура проєкту з Clean Architecture

Рекомендована організація каталогів

Пояснення структури:

  • cmd/server/main.go — точка входу. Тут відбувається створення та з'єднання всіх компонентів (DI).
  • internal/ — приватний код, що не може імпортуватися іншими проєктами (механізм Go).
  • internal/domain/ — найвнутрішній шар, без зовнішніх залежностей.
  • internal/usecase/ — бізнес-логіка, залежить тільки від Domain.
  • internal/repository/ — реалізації доступу до даних.
  • internal/delivery/http/ — HTTP-шар, залежить від UseCase.
  • migrations/ — SQL-файли для версіонування схеми БД.
  • pkg/ — код, що може бути використаний іншими проєктами (опціонально).
Конвенція internal/ є вбудованою в Go. Компілятор забороняє імпортування пакетів з internal/ іншими модулями. Це гарантує інкапсуляцію та запобігає випадковому використанню приватного коду.

Порівняння підходів: монолітний vs Clean Architecture

КритерійМонолітний обробникClean Architecture
Складність входу⭐ Низька (почати легко)⭐⭐⭐ Висока (багато файлів і шарів)
Тестованість❌ Складно: потрібен HTTP-сервер та БД✅ Легко: кожен шар тестується окремо
Повторне використання❌ Бізнес-логіка прив'язана до HTTP✅ UseCase можна викликати з HTTP, gRPC, CLI
Зміна технологій❌ Вимагає переписування всього коду✅ Заміна лише реалізації Repository або Delivery
Підтримуваність❌ Код швидко стає заплутаним✅ Чітке розділення відповідальності
Швидкість розробки⚡ Швидше на старті (прототипи, MVP)🐢 Повільніше на старті, але швидше при масштабуванні
Розмір проєктуПідходить для 1-5 ендпоінтівВиправданий для 10+ ендпоінтів

Коли використовувати Clean Architecture?

✅ Варто використовувати, якщо:

  • Проєкт планується розвивати довгостроково
  • Команда > 2-3 розробників
  • Очікується часта зміна вимог
  • Потрібна підтримка різних способів доставки (HTTP + gRPC + CLI)
  • Критична надійність та покриття тестами

❌ Можна обійтись без неї, якщо:

  • Простий прототип або MVP
  • Проєкт на 1-3 ендпоінти
  • Одноразова утиліта
  • Дедлайн через кілька днів
Прагматичний підхід: Почніть з простих обробників, а коли побачите дублювання логіки або складність тестування — поступово рефакторте код, виділяючи шари. Не варто вводити Clean Architecture "на всяк випадок" у невеликих проєктах.

Додаткові поради та best practices

1. Завжди передавайте context.Context першим аргументом

// ✅ Правильно
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error)

// ❌ Неправильно
func (uc *UserUseCase) RegisterUser(name, email, password string) (*domain.User, error)

Контекст дозволяє скасовувати операції, встановлювати таймаути та передавати метадані.

2. Використовуйте DTO замість доменних сутностей у відповідях

// ✅ Правильно: окремий DTO без пароля
type UserResponse struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

// ❌ Неправильно: віддаємо доменну сутність з паролем
json.NewEncoder(w).Encode(user) // user містить Password!

3. Доменні помилки vs HTTP-статуси

Мапування доменних помилок на HTTP-статуси має відбуватися тільки в Delivery Layer:

func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
    user, err := h.useCase.RegisterUser(...)
    
    if err != nil {
        // Мапування доменних помилок на HTTP-статуси
        switch err {
        case domain.ErrUserAlreadyExists:
            http.Error(w, err.Error(), http.StatusConflict)
        case domain.ErrInvalidEmail:
            http.Error(w, err.Error(), http.StatusBadRequest)
        default:
            http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        }
        return
    }
    
    // ...
}

4. Не витікайте абстракції

UseCase не повинен знати про HTTP, JSON, або статус-коди:

// ❌ Погано: UseCase знає про HTTP
func (uc *UserUseCase) RegisterUser(...) (int, error) {
    if exists {
        return http.StatusConflict, errors.New("user exists")
    }
    // ...
    return http.StatusCreated, nil
}

// ✅ Добре: UseCase повертає доменну помилку
func (uc *UserUseCase) RegisterUser(...) (*domain.User, error) {
    if exists {
        return nil, domain.ErrUserAlreadyExists
    }
    // ...
    return user, nil
}

5. Інтеграційні тести vs модульні тести

Модульні тести (unit tests):

  • Тестують окремий компонент з моками
  • Швидкі (мілісекунди)
  • Легко налагоджувати
  • Використовуйте для Domain та UseCase

Інтеграційні тести (integration tests):

  • Тестують взаємодію з реальною БД/API
  • Повільніші (секунди)
  • Виявляють проблеми інтеграції
  • Використовуйте для Repository та E2E-тестів

Запитання для самоконтролю


SQL-міграції для PostgreSQL

Створення таблиці користувачів

Міграції дозволяють версіонувати схему бази даних. Створимо міграції для таблиці users:

-- migrations/001_create_users_table.up.sql
CREATE TABLE IF NOT EXISTS users (
    id VARCHAR(255) PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);

-- Тригер для автоматичного оновлення updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_users_updated_at 
    BEFORE UPDATE ON users 
    FOR EACH ROW 
    EXECUTE FUNCTION update_updated_at_column();
-- migrations/001_create_users_table.down.sql
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
DROP FUNCTION IF EXISTS update_updated_at_column();
DROP INDEX IF EXISTS idx_users_email;
DROP TABLE IF EXISTS users;

Застосування міграцій

Встановимо інструмент для міграцій:

go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

Запуск міграцій:

migrate -path migrations -database "postgres://user:password@localhost:5432/mydb?sslmode=disable" up

End-to-End: повний приклад від створення до запуску

Тепер об'єднаємо все, що ми вивчили, в один повний робочий проєкт.

Крок 1: Структура проєкту

Фінальна структура має виглядати так:

Крок 2: Створення Makefile

Створимо Makefile для автоматизації:

# Makefile
.PHONY: help run test migrate-up migrate-down docker-up docker-down clean

help: ## Показати довідку
    @echo "Доступні команди:"
    @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2}'

run: ## Запустити сервер (in-memory)
    @echo "🚀 Запуск сервера..."
    go run cmd/server/main.go

run-postgres: ## Запустити сервер (PostgreSQL)
    @echo "🚀 Запуск сервера з PostgreSQL..."
    USE_POSTGRES=true go run cmd/server/main.go

test: ## Запустити всі тести
    @echo "🧪 Запуск тестів..."
    go test -v -cover ./...

test-coverage: ## Запустити тести з покриттям
    @echo "📊 Звіт покриття тестами..."
    go test -coverprofile=coverage.out ./...
    go tool cover -html=coverage.out

docker-up: ## Запустити PostgreSQL через Docker
    @echo "🐳 Запуск PostgreSQL..."
    docker compose up -d
    @sleep 2
    @echo "✅ PostgreSQL готовий на localhost:5432"

docker-down: ## Зупинити Docker контейнери
    @echo "🛑 Зупинка контейнерів..."
    docker compose down

migrate-up: ## Застосувати міграції
    @echo "⬆️  Застосування міграцій..."
    migrate -path migrations -database "postgres://myuser:mypass@localhost:5432/mydb?sslmode=disable" up

migrate-down: ## Відкотити міграції
    @echo "⬇️  Відкат міграцій..."
    migrate -path migrations -database "postgres://myuser:mypass@localhost:5432/mydb?sslmode=disable" down

clean: ## Очистити згенеровані файли
    @echo "🧹 Очищення..."
    rm -f coverage.out
    go clean -testcache

deps: ## Встановити залежності
    @echo "📦 Встановлення залежностей..."
    go mod download
    go mod tidy

build: ## Зібрати бінарний файл
    @echo "🔨 Збірка..."
    go build -o bin/server cmd/server/main.go
    @echo "✅ Бінарний файл: bin/server"

Крок 3: Запуск всієї системи

Варіант 1: In-memory (без бази даних)

# Встановити залежності
make deps

# Запустити сервер
make run

Варіант 2: З PostgreSQL

# 1. Запустити PostgreSQL
make docker-up

# 2. Застосувати міграції
make migrate-up

# 3. Запустити сервер
make run-postgres

Крок 4: Тестування API

# Реєстрація користувача
curl -X POST http://localhost:8080/api/users/register \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "password123"
  }'

# Вхід
curl -X POST http://localhost:8080/api/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "john@example.com",
    "password": "password123"
  }'

# Отримати профіль (потрібен токен з попереднього запиту)
curl http://localhost:8080/api/users/me \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"

Крок 5: Запуск тестів

# Всі тести
make test

# Тести з покриттям
make test-coverage
make test
$ make test
🧪 Запуск тестів...
? myapp/cmd/server [no test files]
ok myapp/internal/domain 0.003s coverage: 85.7% of statements
ok myapp/internal/usecase 0.334s coverage: 92.3% of statements
ok myapp/internal/delivery/http 0.002s coverage: 88.9% of statements
✅ Всі тести пройшли успішно!

Обробка помилок через шари архітектури

Філософія обробки помилок у Go

У Go немає винятків (exceptions). Замість цього помилки повертаються як значення типу error. Це явний підхід, що змушує розробника обробляти помилки на кожному кроці.

Доменні помилки

Створимо власні типи помилок для домену:

// internal/domain/errors.go
package domain

import "errors"

// Базові помилки домену
var (
    // Помилки користувача
    ErrUserNotFound       = errors.New("user not found")
    ErrUserAlreadyExists  = errors.New("user already exists")
    ErrInvalidCredentials = errors.New("invalid credentials")
    ErrInvalidEmail       = errors.New("invalid email format")
    ErrPasswordTooShort   = errors.New("password too short")
    
    // Помилки автентифікації
    ErrUnauthorized      = errors.New("unauthorized")
    ErrInvalidToken      = errors.New("invalid token")
    ErrTokenExpired      = errors.New("token expired")
    
    // Системні помилки
    ErrInternal          = errors.New("internal server error")
    ErrDatabaseConnection = errors.New("database connection failed")
)

Власні типи помилок з контекстом

Для складніших випадків створюємо структури помилок:

// internal/domain/errors.go (продовження)
package domain

import "fmt"

// ValidationError — помилка валідації з деталями
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error: field '%s' - %s", e.Field, e.Message)
}

// NewValidationError створює нову помилку валідації
func NewValidationError(field, message string) *ValidationError {
    return &ValidationError{
        Field:   field,
        Message: message,
    }
}

// NotFoundError — помилка "не знайдено" з деталями
type NotFoundError struct {
    Resource string
    ID       string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with id '%s' not found", e.Resource, e.ID)
}

Обгортання помилок для трейсингу

Go 1.13+ дозволяє обгортати помилки через %w:

// internal/repository/postgres/user_repository.go
func (r *UserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
    user := &domain.User{}
    err := r.db.QueryRowContext(ctx, query, id).Scan(...)
    
    if err == sql.ErrNoRows {
        // Повертаємо доменну помилку
        return nil, domain.ErrUserNotFound
    }
    
    if err != nil {
        // Обгортаємо помилку для збереження контексту
        return nil, fmt.Errorf("failed to find user by id %s: %w", id, err)
    }
    
    return user, nil
}

UseCase: обробка та трансформація помилок

// internal/usecase/user_usecase.go
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error) {
    // 1. Валідація вхідних даних
    if email == "" {
        return nil, domain.NewValidationError("email", "is required")
    }
    
    if len(password) < 8 {
        return nil, domain.NewValidationError("password", "must be at least 8 characters")
    }
    
    // 2. Перевірка існування
    existing, err := uc.repo.FindByEmail(ctx, email)
    if err != nil && err != domain.ErrUserNotFound {
        // Несподівана помилка БД
        return nil, fmt.Errorf("failed to check user existence: %w", err)
    }
    
    if existing != nil {
        return nil, domain.ErrUserAlreadyExists
    }
    
    // 3. Хешування пароля
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return nil, fmt.Errorf("failed to hash password: %w", err)
    }
    
    // 4. Створення користувача
    user := &domain.User{
        ID:        generateUserID(),
        Name:      name,
        Email:     email,
        Password:  string(hashedPassword),
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }
    
    // 5. Валідація доменних правил
    if err := user.Validate(); err != nil {
        return nil, err
    }
    
    // 6. Збереження
    if err := uc.repo.Create(ctx, user); err != nil {
        return nil, fmt.Errorf("failed to create user: %w", err)
    }
    
    return user, nil
}

HTTP Handler: мапування на статус-коди

// internal/delivery/http/user_handler.go
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
    var req RegisterRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondWithError(w, http.StatusBadRequest, "Invalid JSON format")
        return
    }
    
    user, err := h.useCase.RegisterUser(r.Context(), req.Name, req.Email, req.Password)
    if err != nil {
        handleUseCaseError(w, err)
        return
    }
    
    respondWithJSON(w, http.StatusCreated, toUserResponse(user))
}

// handleUseCaseError мапує доменні помилки на HTTP-статуси
func handleUseCaseError(w http.ResponseWriter, err error) {
    // Перевіряємо тип помилки
    var validationErr *domain.ValidationError
    if errors.As(err, &validationErr) {
        respondWithError(w, http.StatusBadRequest, validationErr.Error())
        return
    }
    
    var notFoundErr *domain.NotFoundError
    if errors.As(err, &notFoundErr) {
        respondWithError(w, http.StatusNotFound, notFoundErr.Error())
        return
    }
    
    // Перевіряємо стандартні доменні помилки
    switch {
    case errors.Is(err, domain.ErrUserAlreadyExists):
        respondWithError(w, http.StatusConflict, err.Error())
    case errors.Is(err, domain.ErrInvalidCredentials):
        respondWithError(w, http.StatusUnauthorized, err.Error())
    case errors.Is(err, domain.ErrUserNotFound):
        respondWithError(w, http.StatusNotFound, err.Error())
    case errors.Is(err, domain.ErrUnauthorized):
        respondWithError(w, http.StatusUnauthorized, err.Error())
    default:
        // Логуємо несподівану помилку
        log.Printf("Unexpected error: %v", err)
        respondWithError(w, http.StatusInternalServerError, "Internal server error")
    }
}

// Допоміжні функції для відповідей
func respondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(statusCode)
    json.NewEncoder(w).Encode(data)
}

func respondWithError(w http.ResponseWriter, statusCode int, message string) {
    respondWithJSON(w, statusCode, map[string]string{
        "error": message,
    })
}

Стандартизований формат помилок (RFC 7807 Problem Details)

Для складніших API можна використовувати стандарт RFC 7807:

// internal/delivery/http/problem.go
package http

// ProblemDetails — стандартний формат опису помилки
type ProblemDetails struct {
    Type     string `json:"type"`                // URI типу помилки
    Title    string `json:"title"`               // Короткий опис
    Status   int    `json:"status"`              // HTTP статус-код
    Detail   string `json:"detail"`              // Детальний опис
    Instance string `json:"instance,omitempty"`  // URI запиту, що викликав помилку
}

func respondWithProblem(w http.ResponseWriter, problem ProblemDetails) {
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(problem.Status)
    json.NewEncoder(w).Encode(problem)
}

// Приклад використання
func handleUseCaseErrorRFC7807(w http.ResponseWriter, r *http.Request, err error) {
    var problem ProblemDetails
    
    switch {
    case errors.Is(err, domain.ErrUserAlreadyExists):
        problem = ProblemDetails{
            Type:     "https://api.example.com/errors/user-already-exists",
            Title:    "User Already Exists",
            Status:   http.StatusConflict,
            Detail:   "A user with this email address already exists in the system",
            Instance: r.URL.Path,
        }
    case errors.Is(err, domain.ErrInvalidCredentials):
        problem = ProblemDetails{
            Type:     "https://api.example.com/errors/invalid-credentials",
            Title:    "Invalid Credentials",
            Status:   http.StatusUnauthorized,
            Detail:   "The provided email or password is incorrect",
            Instance: r.URL.Path,
        }
    default:
        problem = ProblemDetails{
            Type:     "https://api.example.com/errors/internal",
            Title:    "Internal Server Error",
            Status:   http.StatusInternalServerError,
            Detail:   "An unexpected error occurred",
            Instance: r.URL.Path,
        }
    }
    
    respondWithProblem(w, problem)
}
Переваги RFC 7807:
  • Стандартизований формат, зрозумілий багатьом клієнтам
  • Машиночитаємі type для автоматичної обробки
  • Детальна інформація для діагностики
  • Підтримка у багатьох бібліотеках та фреймворках

Context Cancellation: скасування операцій на практиці

Навіщо потрібно скасування?

Уявіть ситуацію: користувач відкрив сторінку у браузері, браузер почав завантажувати дані з API, але користувач передумав і закрив вкладку. Без механізму скасування сервер продовжить виконувати запит, витрачаючи ресурси на операцію, результат якої вже нікому не потрібен.

Базовий приклад скасування

// internal/usecase/user_usecase.go
func (uc *UserUseCase) SearchUsers(ctx context.Context, query string) ([]*domain.User, error) {
    // Симуляція довгої операції (наприклад, пошук у великій БД)
    results := make([]*domain.User, 0)
    
    // Припустимо, ми шукаємо серед 1 мільйона користувачів
    for i := 0; i < 1000000; i++ {
        // Перевіряємо, чи не скасовано контекст
        select {
        case <-ctx.Done():
            // Клієнт відключився або вийшов таймаут
            return nil, ctx.Err()
        default:
            // Продовжуємо роботу
        }
        
        // Перевірка кожної тисячної ітерації (оптимізація)
        if i%1000 == 0 {
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            default:
            }
        }
        
        // Логіка пошуку...
        if matchesQuery(i, query) {
            user := fetchUser(i)
            results = append(results, user)
        }
    }
    
    return results, nil
}

Встановлення таймаутів у Handler

// internal/delivery/http/user_handler.go
func (h *UserHandler) SearchUsers(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")
    
    // Встановлюємо таймаут 5 секунд для операції
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()  // Завжди викликаємо cancel для очищення ресурсів
    
    users, err := h.useCase.SearchUsers(ctx, query)
    if err != nil {
        // Перевіряємо, чи це таймаут
        if errors.Is(err, context.DeadlineExceeded) {
            respondWithError(w, http.StatusRequestTimeout, "Search timeout exceeded")
            return
        }
        
        // Перевіряємо, чи це скасування
        if errors.Is(err, context.Canceled) {
            // Клієнт відключився - просто логуємо
            log.Printf("Client disconnected during search")
            return
        }
        
        handleUseCaseError(w, err)
        return
    }
    
    respondWithJSON(w, http.StatusOK, users)
}

Передача контексту у запити до БД

// internal/repository/postgres/user_repository.go
func (r *UserRepository) SearchByName(ctx context.Context, name string) ([]*domain.User, error) {
    // PostgreSQL автоматично скасує запит, коли ctx буде скасовано
    query := `
        SELECT id, name, email, created_at, updated_at 
        FROM users 
        WHERE name ILIKE $1
        LIMIT 100
    `
    
    rows, err := r.db.QueryContext(ctx, query, "%"+name+"%")
    if err != nil {
        if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
            return nil, err
        }
        return nil, fmt.Errorf("search query failed: %w", err)
    }
    defer rows.Close()
    
    users := make([]*domain.User, 0)
    for rows.Next() {
        // Перевіряємо контекст навіть під час ітерації
        if ctx.Err() != nil {
            return nil, ctx.Err()
        }
        
        var user domain.User
        if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil {
            return nil, fmt.Errorf("failed to scan user: %w", err)
        }
        users = append(users, &user)
    }
    
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("rows iteration error: %w", err)
    }
    
    return users, nil
}

Контекст у горутинах

ВАЖЛИВО: Ніколи не передавайте r.Context() напряму в горутину! HTTP-контекст може бути скасовано після завершення handler, а горутина ще працюватиме.

❌ Неправильно:

func (h *Handler) ProcessAsync(w http.ResponseWriter, r *http.Request) {
    go func() {
        // ❌ r.Context() може бути скасовано!
        result := h.service.LongOperation(r.Context())
        // Зберегти result...
    }()
    
    w.WriteHeader(http.StatusAccepted)
    fmt.Fprintln(w, "Processing started")
}

✅ Правильно:

func (h *Handler) ProcessAsync(w http.ResponseWriter, r *http.Request) {
    // Створюємо новий контекст, незалежний від HTTP-запиту
    ctx := context.Background()
    
    // Або з таймаутом для фонової задачі
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
    
    go func() {
        defer cancel()
        result := h.service.LongOperation(ctx)
        // Зберегти result...
    }()
    
    w.WriteHeader(http.StatusAccepted)
    fmt.Fprintln(w, "Processing started")
}

Graceful Shutdown: коректне завершення сервера

// cmd/server/main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    // ... ініціалізація залежностей
    
    server := &http.Server{
        Addr:    ":8080",
        Handler: router,
    }
    
    // Канал для сигналів завершення
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    
    // Запускаємо сервер у горутині
    go func() {
        log.Println("Server starting on :8080...")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Server failed: %v", err)
        }
    }()
    
    // Чекаємо сигнал завершення
    <-quit
    log.Println("Shutting down server...")
    
    // Даємо серверу 30 секунд для завершення активних запитів
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }
    
    log.Println("Server gracefully stopped")
}

Тестування Graceful Shutdown:

Тест graceful shutdown
$ go run cmd/server/main.go
Server starting on :8080...
# В іншому терміналі: curl запит
# Натискаємо Ctrl+C
^C
Shutting down server...
Server gracefully stopped

Моніторинг скасувань

Додамо метрики для відстеження скасованих операцій:

// middleware/context_monitor.go
package middleware

import (
    "context"
    "errors"
    "log"
    "net/http"
    "sync/atomic"
    "time"
)

var (
    canceledRequests   int64
    timeoutRequests    int64
    completedRequests  int64
)

// ContextMonitor — Middleware для моніторингу контекстів
func ContextMonitor(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        done := make(chan struct{})
        
        go func() {
            next.ServeHTTP(w, r)
            close(done)
        }()
        
        select {
        case <-done:
            // Запит завершився нормально
            atomic.AddInt64(&completedRequests, 1)
            
        case <-r.Context().Done():
            // Контекст скасовано
            err := r.Context().Err()
            
            if errors.Is(err, context.Canceled) {
                atomic.AddInt64(&canceledRequests, 1)
                log.Printf("Request canceled: %s %s", r.Method, r.URL.Path)
            } else if errors.Is(err, context.DeadlineExceeded) {
                atomic.AddInt64(&timeoutRequests, 1)
                log.Printf("Request timeout: %s %s", r.Method, r.URL.Path)
            }
            
            // Чекаємо завершення handler (але не більше 5 секунд)
            select {
            case <-done:
            case <-time.After(5 * time.Second):
                log.Printf("Handler didn't stop after context cancellation")
            }
        }
    })
}

// GetStats повертає статистику
func GetContextStats() map[string]int64 {
    return map[string]int64{
        "completed": atomic.LoadInt64(&completedRequests),
        "canceled":  atomic.LoadInt64(&canceledRequests),
        "timeout":   atomic.LoadInt64(&timeoutRequests),
    }
}

Типові помилки початківців та як їх уникати

Помилка 1: Забування про помилки в Middleware

❌ Неправильно:

func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Println("Request received")
        next.ServeHTTP(w, r)
        // Що якщо next.ServeHTTP() викликав panic?
    })
}

✅ Правильно:

func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("PANIC: %v", err)
            }
        }()
        
        log.Println("Request received")
        next.ServeHTTP(w, r)
        log.Println("Request completed")
    })
}

Або краще — використовуйте окремий Recovery Middleware.

Помилка 2: Модифікація ResponseWriter після WriteHeader

❌ Неправильно:

func handler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Header().Set("X-Custom", "value")  // ❌ Заголовки вже надіслані!
    json.NewEncoder(w).Encode(data)
}

✅ Правильно:

func handler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Custom", "value")  // ✅ Спочатку заголовки
    w.WriteHeader(http.StatusOK)         // Потім статус
    json.NewEncoder(w).Encode(data)      // Потім тіло
}

Помилка 3: Витік горутин через неперевірку ctx.Done()

❌ Неправильно:

func (uc *UserUseCase) ProcessLongOperation(ctx context.Context) error {
    // Довга операція без перевірки контексту
    time.Sleep(10 * time.Second)
    // Що якщо клієнт відключився?
    return uc.repo.Save(ctx, data)
}

✅ Правильно:

func (uc *UserUseCase) ProcessLongOperation(ctx context.Context) error {
    select {
    case <-time.After(10 * time.Second):
        return uc.repo.Save(ctx, data)
    case <-ctx.Done():
        return ctx.Err()  // Операція скасована
    }
}

Помилка 4: UseCase залежить від HTTP-деталей

❌ Неправильно:

func (uc *UserUseCase) RegisterUser(w http.ResponseWriter, r *http.Request) {
    // UseCase не повинен знати про HTTP!
    var req RegisterRequest
    json.NewDecoder(r.Body).Decode(&req)
    // ...
    json.NewEncoder(w).Encode(user)
}

✅ Правильно:

func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error) {
    // UseCase працює з чистими даними
    // ...
    return user, nil
}

Помилка 5: Повернення доменних сутностей з паролями

❌ Неправильно:

func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
    user, _ := h.useCase.GetUser(...)
    json.NewEncoder(w).Encode(user)  // ❌ Віддаємо пароль клієнту!
}

✅ Правильно:

type UserResponse struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
    // Без password!
}

func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
    user, _ := h.useCase.GetUser(...)
    
    response := UserResponse{
        ID:    user.ID,
        Name:  user.Name,
        Email: user.Email,
    }
    
    json.NewEncoder(w).Encode(response)  // ✅ Безпечно
}

Помилка 6: Ігнорування помилок Close()

❌ Неправильно:

defer r.Body.Close()  // Ігноруємо можливу помилку

✅ Правильно:

defer func() {
    if err := r.Body.Close(); err != nil {
        log.Printf("Failed to close body: %v", err)
    }
}()

Помилка 7: Неправильне використання контекстних ключів

❌ Неправильно:

ctx := context.WithValue(r.Context(), "user_id", userID)  // ❌ String як ключ

✅ Правильно:

type contextKey string
const UserIDKey contextKey = "user_id"

ctx := context.WithValue(r.Context(), UserIDKey, userID)  // ✅ Власний тип

Помилка 8: Мутація вхідних параметрів

❌ Неправильно:

func (uc *UserUseCase) UpdateUser(user *domain.User) error {
    user.UpdatedAt = time.Now()  // ❌ Мутуємо вхідний параметр
    return uc.repo.Update(user)
}

✅ Правильно:

func (uc *UserUseCase) UpdateUser(user *domain.User) error {
    // Створюємо копію
    updated := *user
    updated.UpdatedAt = time.Now()
    return uc.repo.Update(&updated)
}

Debugging та Troubleshooting

Логування у Middleware: правильний підхід

Структуроване логування з log/slog (Go 1.21+):

// middleware/logger.go
package middleware

import (
    "log/slog"
    "net/http"
    "time"
)

func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Обгортаємо ResponseWriter
        wrapped := &responseWriter{
            ResponseWriter: w,
            statusCode:     http.StatusOK,
        }
        
        // Логуємо початок запиту
        slog.Info("request started",
            "method", r.Method,
            "path", r.URL.Path,
            "remote_addr", r.RemoteAddr,
            "user_agent", r.UserAgent(),
        )
        
        // Виконуємо запит
        next.ServeHTTP(wrapped, r)
        
        duration := time.Since(start)
        
        // Логуємо завершення з контекстом
        logger := slog.With(
            "method", r.Method,
            "path", r.URL.Path,
            "status", wrapped.statusCode,
            "duration_ms", duration.Milliseconds(),
            "remote_addr", r.RemoteAddr,
        )
        
        // Різний рівень залежно від статусу
        switch {
        case wrapped.statusCode >= 500:
            logger.Error("request completed with server error")
        case wrapped.statusCode >= 400:
            logger.Warn("request completed with client error")
        default:
            logger.Info("request completed successfully")
        }
    })
}

Request ID для трейсингу

Додамо унікальний ID до кожного запиту для відстеження через всі шари:

// middleware/request_id.go
package middleware

import (
    "context"
    "net/http"
    
    "github.com/google/uuid"
)

// Встановлення: go get github.com/google/uuid

type contextKey string

const RequestIDKey contextKey = "request_id"

// RequestID додає унікальний ID до кожного запиту
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Перевіряємо, чи є вже ID у заголовку
        requestID := r.Header.Get("X-Request-ID")
        if requestID == "" {
            // Генеруємо новий UUID
            requestID = uuid.New().String()
        }
        
        // Додаємо у контекст
        ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
        
        // Додаємо у відповідь для клієнта
        w.Header().Set("X-Request-ID", requestID)
        
        // Передаємо далі
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// GetRequestID витягує ID з контексту
func GetRequestID(ctx context.Context) string {
    if id, ok := ctx.Value(RequestIDKey).(string); ok {
        return id
    }
    return "unknown"
}

Використання у логуванні:

// internal/usecase/user_usecase.go
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error) {
    requestID := middleware.GetRequestID(ctx)
    
    slog.Info("registering user",
        "request_id", requestID,
        "email", email,
    )
    
    // ... логіка реєстрації
    
    if err != nil {
        slog.Error("failed to register user",
            "request_id", requestID,
            "email", email,
            "error", err,
        )
        return nil, err
    }
    
    slog.Info("user registered successfully",
        "request_id", requestID,
        "user_id", user.ID,
    )
    
    return user, nil
}

Debugging через pprof

Додамо ендпоінти для профілювання:

// cmd/server/main.go
import (
    "net/http"
    "net/http/pprof"
    
    "github.com/go-chi/chi/v5"
)

func setupDebugRoutes(r *chi.Mux) {
    // Увага: не додавайте ці маршрути у продакшн без автентифікації!
    r.Mount("/debug/pprof", http.HandlerFunc(pprof.Index))
    r.Handle("/debug/pprof/cmdline", http.HandlerFunc(pprof.Cmdline))
    r.Handle("/debug/pprof/profile", http.HandlerFunc(pprof.Profile))
    r.Handle("/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol))
    r.Handle("/debug/pprof/trace", http.HandlerFunc(pprof.Trace))
}

Використання pprof:

# Збираємо CPU профіль протягом 30 секунд
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30

# Аналізуємо у інтерактивному режимі
(pprof) top10
(pprof) list functionName
(pprof) web

Health Check ендпоінт

// internal/delivery/http/health_handler.go
package http

import (
    "context"
    "database/sql"
    "encoding/json"
    "net/http"
    "time"
)

type HealthHandler struct {
    db *sql.DB
}

type HealthResponse struct {
    Status    string            `json:"status"`
    Timestamp int64             `json:"timestamp"`
    Services  map[string]string `json:"services"`
}

func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
    response := HealthResponse{
        Status:    "ok",
        Timestamp: time.Now().Unix(),
        Services:  make(map[string]string),
    }
    
    // Перевірка підключення до БД
    if h.db != nil {
        ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
        defer cancel()
        
        if err := h.db.PingContext(ctx); err != nil {
            response.Status = "degraded"
            response.Services["database"] = "unhealthy: " + err.Error()
        } else {
            response.Services["database"] = "healthy"
        }
    }
    
    // Можна додати перевірки інших сервісів
    // Redis, RabbitMQ, external APIs...
    
    statusCode := http.StatusOK
    if response.Status != "ok" {
        statusCode = http.StatusServiceUnavailable
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(statusCode)
    json.NewEncoder(w).Encode(response)
}

// Liveness probe (для Kubernetes)
func (h *HealthHandler) Liveness(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("alive"))
}

// Readiness probe (для Kubernetes)
func (h *HealthHandler) Readiness(w http.ResponseWriter, r *http.Request) {
    // Перевіряємо критичні залежності
    if h.db != nil {
        ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
        defer cancel()
        
        if err := h.db.PingContext(ctx); err != nil {
            w.WriteHeader(http.StatusServiceUnavailable)
            w.Write([]byte("not ready"))
            return
        }
    }
    
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ready"))
}

Metrics ендпоінт (Prometheus-сумісний)

// middleware/metrics.go
package middleware

import (
    "fmt"
    "net/http"
    "strconv"
    "sync/atomic"
    "time"
)

var (
    requestsTotal     int64
    requestsInFlight  int64
    requestDurations  = make(map[string][]time.Duration)
)

// Metrics — Middleware для збору метрик
func Metrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        atomic.AddInt64(&requestsTotal, 1)
        atomic.AddInt64(&requestsInFlight, 1)
        defer atomic.AddInt64(&requestsInFlight, -1)
        
        wrapped := &responseWriter{
            ResponseWriter: w,
            statusCode:     http.StatusOK,
        }
        
        next.ServeHTTP(wrapped, r)
        
        duration := time.Since(start)
        
        // Зберігаємо тривалість для гістограми
        key := r.Method + " " + r.URL.Path
        // Тут має бути потокобезпечна структура даних
        // Для простоти пропускаємо
    })
}

// MetricsHandler віддає метрики у Prometheus форматі
func MetricsHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; version=0.0.4")
    
    fmt.Fprintf(w, "# HELP http_requests_total Total number of HTTP requests\n")
    fmt.Fprintf(w, "# TYPE http_requests_total counter\n")
    fmt.Fprintf(w, "http_requests_total %d\n", atomic.LoadInt64(&requestsTotal))
    
    fmt.Fprintf(w, "# HELP http_requests_in_flight Current number of HTTP requests being served\n")
    fmt.Fprintf(w, "# TYPE http_requests_in_flight gauge\n")
    fmt.Fprintf(w, "http_requests_in_flight %d\n", atomic.LoadInt64(&requestsInFlight))
}

Checklist для review коду

Використовуйте цей чеклист при перевірці коду:

Middleware

  • Middleware повертає http.Handler
  • Викликається next.ServeHTTP(w, r) (якщо не припиняємо обробку)
  • Panic Recovery на місці
  • Логування включає важливі поля (method, path, status, duration)

Clean Architecture

  • Domain не імпортує нічого, крім стандартної бібліотеки
  • UseCase залежить від інтерфейсів, а не реалізацій
  • Repository визначений як інтерфейс у Domain
  • Handler не містить бізнес-логіки

Context

  • context.Context передається першим параметром
  • Використовуються власні типи для ключів контексту
  • Перевіряється ctx.Done() у довгих операціях
  • Контекст не зберігається у структурах

HTTP

  • Заголовки встановлюються перед WriteHeader()
  • Статус-код відповідає RFC (200, 201, 400, 404, 500...)
  • Використовуються DTO замість доменних сутностей
  • Паролі не потрапляють у відповіді

Тестування

  • Кожен шар має тести
  • Використовуються моки для ізоляції
  • Table-driven tests для множинних сценаріїв
  • Покриття тестами > 80%

Безпека

  • Паролі хешуються (bcrypt, argon2)
  • JWT токени перевіряються
  • CORS налаштований правильно
  • Rate limiting на місці

Підсумки лекції

У цій лекції ми детально розглянули два фундаментальні архітектурні підходи для побудови професійних серверних застосунків на Go:

🔗 Middleware Pattern

Що вивчили:

  • Middleware — це функція, що обгортає http.Handler і повертає новий http.Handler
  • Створили власні Middleware з нуля: Logger, Recovery, CORS, RateLimit, Auth
  • Навчилися композиції Middleware через функцію Chain
  • Освоїли передачу даних через context.Context
  • Інтегрували Middleware з Chi Router для групування маршрутів

Ключова ідея: Middleware дозволяє модульно додавати наскрізну функціональність (логування, автентифікація, захист) без зміни основної бізнес-логіки.

🏛️ Clean Architecture

Що вивчили:

  • Еволюція від монолітного коду до шаруватої архітектури
  • Чотири шари: Domain → UseCase → Repository → Delivery
  • Dependency Rule: залежності завжди спрямовані всередину
  • Repository Pattern для абстракції доступу до даних
  • Ручне Dependency Injection для зв'язування компонентів
  • Модульне тестування кожного шару з моками

Ключова ідея: Бізнес-логіка не повинна залежати від технічних деталей (HTTP, БД, UI). Це забезпечує тестованість, гнучкість та можливість незалежної зміни технологій.

🧪 Тестування

  • net/http/httptest для тестування HTTP-обробників без сервера
  • Створення мок-реалізацій репозиторіїв та UseCases
  • Table-driven tests для покриття різних сценаріїв
  • Ізольоване тестування кожного шару

📖 Наступна лекція

Лекція 11. Архітектура та маршрутизація фреймворку Gin

Ми розглянемо високопродуктивний веб-фреймворк Gin, що надає зручні абстракції поверх стандартного net/http: маршрутизацію через HttpRouter, центральну сутність gin.Context, Model Binding, валідацію через теги та автоматичну генерацію Swagger-документації.

Перейти до лекції →

🔗 Додаткові матеріали

Документація та статті:

Бібліотеки:

Copyright © 2026