Hard📖Теория11 min

Clean Architecture

Гексагональная архитектура, Ports & Adapters и слоистая архитектура в Go

Clean Architecture в Go

Clean Architecture (Robert C. Martin) и Hexagonal Architecture (Alistair Cockburn) -- близкие подходы к организации кода, где бизнес-логика изолирована от инфраструктуры. В Go эти идеи реализуются через интерфейсы, пакеты и Dependency Inversion.

Ключевая идея

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

┌──────────────────────────────────────────────────┐
│  Infrastructure (HTTP, DB, gRPC, CLI, Queue)     │
│  ┌──────────────────────────────────────────┐    │
│  │  Adapters (Handlers, Repositories)       │    │
│  │  ┌──────────────────────────────────┐    │    │
│  │  │  Use Cases (Application Services) │    │    │
│  │  │  ┌──────────────────────────┐    │    │    │
│  │  │  │  Domain (Entities, VOs)  │    │    │    │
│  │  │  └──────────────────────────┘    │    │    │
│  │  └──────────────────────────────────┘    │    │
│  └──────────────────────────────────────────┘    │
└──────────────────────────────────────────────────┘

Правила:

  • Domain не импортирует ничего из внешних слоёв
  • Use Cases импортируют только Domain
  • Adapters импортируют Domain и Use Cases
  • Infrastructure -- точка входа, связывает всё вместе

Слой 1: Domain (Entities)

Чистые Go-структуры без зависимостей от фреймворков, БД, HTTP. Содержит бизнес-правила.

// domain/order.go
package domain

import (
    "errors"
    "time"
)

// OrderStatus represents the lifecycle state of an order.
type OrderStatus string

const (
    OrderStatusDraft     OrderStatus = "draft"
    OrderStatusPending   OrderStatus = "pending"
    OrderStatusConfirmed OrderStatus = "confirmed"
    OrderStatusShipped   OrderStatus = "shipped"
    OrderStatusDelivered OrderStatus = "delivered"
    OrderStatusCancelled OrderStatus = "cancelled"
)

var (
    ErrEmptyOrder     = errors.New("order has no items")
    ErrInvalidTotal   = errors.New("order total must be positive")
    ErrAlreadyShipped = errors.New("cannot cancel shipped order")
)

// Money is a value object representing monetary amount.
type Money struct {
    Amount   int64  // in cents to avoid floating point
    Currency string // ISO 4217
}

// Add returns the sum of two Money values.
func (m Money) Add(other Money) (Money, error) {
    if m.Currency != other.Currency {
        return Money{}, errors.New("currency mismatch")
    }
    return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}, nil
}

// OrderItem represents a line item in an order.
type OrderItem struct {
    ProductID string
    Name      string
    Quantity  int
    Price     Money
}

// Total returns the line total for this item.
func (i OrderItem) Total() Money {
    return Money{
        Amount:   i.Price.Amount * int64(i.Quantity),
        Currency: i.Price.Currency,
    }
}

// Order is the core domain entity.
type Order struct {
    ID         string
    CustomerID string
    Items      []OrderItem
    Status     OrderStatus
    Total      Money
    CreatedAt  time.Time
    UpdatedAt  time.Time
}

// NewOrder creates a draft order with validation.
func NewOrder(id, customerID string, items []OrderItem) (*Order, error) {
    if len(items) == 0 {
        return nil, ErrEmptyOrder
    }

    total := Money{Currency: items[0].Price.Currency}
    for _, item := range items {
        lineTotal := item.Total()
        var err error
        total, err = total.Add(lineTotal)
        if err != nil {
            return nil, err
        }
    }

    if total.Amount <= 0 {
        return nil, ErrInvalidTotal
    }

    now := time.Now()
    return &Order{
        ID:         id,
        CustomerID: customerID,
        Items:      items,
        Status:     OrderStatusDraft,
        Total:      total,
        CreatedAt:  now,
        UpdatedAt:  now,
    }, nil
}

// Confirm transitions the order to confirmed status.
func (o *Order) Confirm() error {
    if o.Status != OrderStatusPending {
        return fmt.Errorf("cannot confirm order in status %s", o.Status)
    }
    o.Status = OrderStatusConfirmed
    o.UpdatedAt = time.Now()
    return nil
}

// Cancel cancels the order if it hasn't been shipped.
func (o *Order) Cancel() error {
    if o.Status == OrderStatusShipped || o.Status == OrderStatusDelivered {
        return ErrAlreadyShipped
    }
    o.Status = OrderStatusCancelled
    o.UpdatedAt = time.Now()
    return nil
}

Обратите внимание:

  • Никаких зависимостей от БД, HTTP, фреймворков
  • Value Objects (Money) с поведением
  • Бизнес-правила внутри entity (Confirm, Cancel)
  • Валидация в конструкторе

Слой 2: Use Cases (Application Services)

Оркестрация бизнес-логики. Определяет порты (интерфейсы) для доступа к внешнему миру.

// usecase/order.go
package usecase

import (
    "context"
    "fmt"

    "myapp/domain"
)

// OrderRepository is a port for order persistence.
type OrderRepository interface {
    Save(ctx context.Context, order *domain.Order) error
    FindByID(ctx context.Context, id string) (*domain.Order, error)
    FindByCustomer(ctx context.Context, customerID string) ([]*domain.Order, error)
}

// PaymentGateway is a port for payment processing.
type PaymentGateway interface {
    Charge(ctx context.Context, customerID string, amount domain.Money) (transactionID string, err error)
    Refund(ctx context.Context, transactionID string) error
}

// OrderNotifier is a port for sending notifications.
type OrderNotifier interface {
    NotifyOrderConfirmed(ctx context.Context, order *domain.Order) error
}

// IDGenerator is a port for generating unique IDs.
type IDGenerator interface {
    NewID() string
}

// OrderService implements order-related use cases.
type OrderService struct {
    repo      OrderRepository
    payments  PaymentGateway
    notifier  OrderNotifier
    idGen     IDGenerator
}

// NewOrderService creates an OrderService with its dependencies.
func NewOrderService(
    repo OrderRepository,
    payments PaymentGateway,
    notifier OrderNotifier,
    idGen IDGenerator,
) *OrderService {
    return &OrderService{
        repo:     repo,
        payments: payments,
        notifier: notifier,
        idGen:    idGen,
    }
}

// PlaceOrderInput is the input DTO for PlaceOrder use case.
type PlaceOrderInput struct {
    CustomerID string
    Items      []domain.OrderItem
}

// PlaceOrderOutput is the output DTO for PlaceOrder use case.
type PlaceOrderOutput struct {
    OrderID       string
    Total         domain.Money
    TransactionID string
}

// PlaceOrder executes the place order use case.
func (s *OrderService) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*PlaceOrderOutput, error) {
    // 1. Create domain entity (validates business rules)
    order, err := domain.NewOrder(s.idGen.NewID(), input.CustomerID, input.Items)
    if err != nil {
        return nil, fmt.Errorf("creating order: %w", err)
    }

    // 2. Process payment
    txID, err := s.payments.Charge(ctx, order.CustomerID, order.Total)
    if err != nil {
        return nil, fmt.Errorf("charging payment: %w", err)
    }

    // 3. Confirm and save
    order.Status = domain.OrderStatusConfirmed
    if err := s.repo.Save(ctx, order); err != nil {
        // Compensate: refund payment
        if refundErr := s.payments.Refund(ctx, txID); refundErr != nil {
            return nil, fmt.Errorf("saving order failed (%w), refund also failed: %w", err, refundErr)
        }
        return nil, fmt.Errorf("saving order: %w", err)
    }

    // 4. Notify (non-critical)
    if err := s.notifier.NotifyOrderConfirmed(ctx, order); err != nil {
        // Log but don't fail the use case
        slog.Error("notifying order confirmed", "err", err, "orderID", order.ID)
    }

    return &PlaceOrderOutput{
        OrderID:       order.ID,
        Total:         order.Total,
        TransactionID: txID,
    }, nil
}

// CancelOrder cancels an existing order.
func (s *OrderService) CancelOrder(ctx context.Context, orderID string) error {
    order, err := s.repo.FindByID(ctx, orderID)
    if err != nil {
        return fmt.Errorf("finding order %s: %w", orderID, err)
    }

    if err := order.Cancel(); err != nil {
        return fmt.Errorf("cancelling order: %w", err)
    }

    if err := s.repo.Save(ctx, order); err != nil {
        return fmt.Errorf("saving cancelled order: %w", err)
    }

    return nil
}

Обратите внимание:

  • Порты (интерфейсы) определены в use case слое, не в адаптерах
  • Input/Output DTO изолируют use case от деталей транспорта (HTTP, gRPC)
  • Use case оркестрирует, но бизнес-логика -- в domain

Слой 3: Adapters

Конкретные реализации портов: PostgreSQL, HTTP, gRPC, email.

Repository (PostgreSQL)

// adapter/postgres/order_repo.go
package postgres

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5/pgxpool"

    "myapp/domain"
)

// OrderRepo implements usecase.OrderRepository with PostgreSQL.
type OrderRepo struct {
    pool *pgxpool.Pool
}

// NewOrderRepo creates a new PostgreSQL order repository.
func NewOrderRepo(pool *pgxpool.Pool) *OrderRepo {
    return &OrderRepo{pool: pool}
}

func (r *OrderRepo) Save(ctx context.Context, order *domain.Order) error {
    _, err := r.pool.Exec(ctx, `
        INSERT INTO orders (id, customer_id, status, total_amount, total_currency, created_at, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
        ON CONFLICT (id) DO UPDATE SET
            status = EXCLUDED.status,
            updated_at = EXCLUDED.updated_at
    `, order.ID, order.CustomerID, order.Status,
       order.Total.Amount, order.Total.Currency,
       order.CreatedAt, order.UpdatedAt)

    if err != nil {
        return fmt.Errorf("upserting order %s: %w", order.ID, err)
    }
    return nil
}

func (r *OrderRepo) FindByID(ctx context.Context, id string) (*domain.Order, error) {
    row := r.pool.QueryRow(ctx, `
        SELECT id, customer_id, status, total_amount, total_currency, created_at, updated_at
        FROM orders WHERE id = $1
    `, id)

    var o domain.Order
    err := row.Scan(&o.ID, &o.CustomerID, &o.Status,
        &o.Total.Amount, &o.Total.Currency,
        &o.CreatedAt, &o.UpdatedAt)
    if err != nil {
        return nil, fmt.Errorf("scanning order %s: %w", id, err)
    }

    return &o, nil
}

func (r *OrderRepo) FindByCustomer(ctx context.Context, customerID string) ([]*domain.Order, error) {
    rows, err := r.pool.Query(ctx, `
        SELECT id, customer_id, status, total_amount, total_currency, created_at, updated_at
        FROM orders WHERE customer_id = $1 ORDER BY created_at DESC
    `, customerID)
    if err != nil {
        return nil, fmt.Errorf("querying orders for customer %s: %w", customerID, err)
    }
    defer rows.Close()

    var orders []*domain.Order
    for rows.Next() {
        var o domain.Order
        if err := rows.Scan(&o.ID, &o.CustomerID, &o.Status,
            &o.Total.Amount, &o.Total.Currency,
            &o.CreatedAt, &o.UpdatedAt); err != nil {
            return nil, fmt.Errorf("scanning order row: %w", err)
        }
        orders = append(orders, &o)
    }

    return orders, rows.Err()
}

HTTP Handler

// adapter/http/order_handler.go
package http

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

    "myapp/usecase"
)

// OrderHandler handles HTTP requests for orders.
type OrderHandler struct {
    orderSvc *usecase.OrderService
}

// NewOrderHandler creates an OrderHandler.
func NewOrderHandler(orderSvc *usecase.OrderService) *OrderHandler {
    return &OrderHandler{orderSvc: orderSvc}
}

// RegisterRoutes registers order routes on the given mux.
func (h *OrderHandler) RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("POST /api/orders", h.PlaceOrder)
    mux.HandleFunc("DELETE /api/orders/{id}", h.CancelOrder)
}

type placeOrderRequest struct {
    CustomerID string              `json:"customer_id"`
    Items      []orderItemRequest  `json:"items"`
}

type orderItemRequest struct {
    ProductID string `json:"product_id"`
    Name      string `json:"name"`
    Quantity  int    `json:"quantity"`
    Price     int64  `json:"price_cents"`
    Currency  string `json:"currency"`
}

type placeOrderResponse struct {
    OrderID       string `json:"order_id"`
    TotalCents    int64  `json:"total_cents"`
    Currency      string `json:"currency"`
    TransactionID string `json:"transaction_id"`
}

func (h *OrderHandler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
    var req placeOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid request body")
        return
    }

    // Map HTTP DTO to use case input
    input := usecase.PlaceOrderInput{
        CustomerID: req.CustomerID,
    }
    for _, item := range req.Items {
        input.Items = append(input.Items, domain.OrderItem{
            ProductID: item.ProductID,
            Name:      item.Name,
            Quantity:  item.Quantity,
            Price:     domain.Money{Amount: item.Price, Currency: item.Currency},
        })
    }

    // Call use case
    output, err := h.orderSvc.PlaceOrder(r.Context(), input)
    if err != nil {
        writeError(w, http.StatusInternalServerError, err.Error())
        return
    }

    // Map use case output to HTTP response
    writeJSON(w, http.StatusCreated, placeOrderResponse{
        OrderID:       output.OrderID,
        TotalCents:    output.Total.Amount,
        Currency:      output.Total.Currency,
        TransactionID: output.TransactionID,
    })
}

func (h *OrderHandler) CancelOrder(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    if err := h.orderSvc.CancelOrder(r.Context(), id); err != nil {
        writeError(w, http.StatusInternalServerError, err.Error())
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, map[string]string{"error": msg})
}

Слой 4: Infrastructure (main)

main() -- единственное место, которое знает обо всех слоях и связывает их вместе.

// cmd/server/main.go
package main

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

    "github.com/jackc/pgx/v5/pgxpool"

    httpAdapter "myapp/adapter/http"
    "myapp/adapter/postgres"
    "myapp/adapter/stripe"
    "myapp/adapter/email"
    "myapp/usecase"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)

    // Infrastructure: database
    pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
    if err != nil {
        slog.Error("connecting to database", "err", err)
        os.Exit(1)
    }
    defer pool.Close()

    // Adapters: repositories and external services
    orderRepo := postgres.NewOrderRepo(pool)
    paymentGW := stripe.NewGateway(os.Getenv("STRIPE_KEY"))
    notifier := email.NewNotifier(os.Getenv("SMTP_HOST"))
    idGen := &UUIDGenerator{}

    // Use cases
    orderSvc := usecase.NewOrderService(orderRepo, paymentGW, notifier, idGen)

    // HTTP adapter
    mux := http.NewServeMux()
    orderHandler := httpAdapter.NewOrderHandler(orderSvc)
    orderHandler.RegisterRoutes(mux)

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Graceful shutdown
    go func() {
        slog.Info("server starting", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("server error", "err", err)
            os.Exit(1)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    slog.Info("shutting down server")
    if err := srv.Shutdown(ctx); err != nil {
        slog.Error("server shutdown error", "err", err)
    }
}

Структура проекта

myapp/
├── cmd/
│   └── server/
│       └── main.go              # Wiring, infrastructure
├── domain/
│   ├── order.go                 # Order entity, value objects
│   ├── user.go                  # User entity
│   └── errors.go                # Domain errors
├── usecase/
│   ├── order.go                 # OrderService, port interfaces
│   ├── order_test.go            # Tests with mocks
│   └── user.go                  # UserService
├── adapter/
│   ├── http/
│   │   ├── order_handler.go     # HTTP handler
│   │   ├── middleware.go        # HTTP middleware
│   │   └── order_handler_test.go
│   ├── postgres/
│   │   ├── order_repo.go        # PostgreSQL repository
│   │   └── order_repo_test.go   # Integration tests
│   ├── stripe/
│   │   └── gateway.go           # Stripe payment adapter
│   └── email/
│       └── notifier.go          # Email notification adapter
├── migrations/
│   ├── 001_create_orders.up.sql
│   └── 001_create_orders.down.sql
├── go.mod
└── go.sum

Тестирование каждого слоя

Domain -- unit тесты (без моков)

func TestNewOrder_EmptyItems(t *testing.T) {
    _, err := domain.NewOrder("id-1", "cust-1", nil)
    if !errors.Is(err, domain.ErrEmptyOrder) {
        t.Errorf("got %v, want ErrEmptyOrder", err)
    }
}

func TestOrder_Cancel_ShippedOrder(t *testing.T) {
    order := &domain.Order{Status: domain.OrderStatusShipped}
    err := order.Cancel()
    if !errors.Is(err, domain.ErrAlreadyShipped) {
        t.Errorf("got %v, want ErrAlreadyShipped", err)
    }
}

Use Cases -- unit тесты с моками

type mockOrderRepo struct {
    orders map[string]*domain.Order
}

func (m *mockOrderRepo) Save(_ context.Context, o *domain.Order) error {
    m.orders[o.ID] = o
    return nil
}

func (m *mockOrderRepo) FindByID(_ context.Context, id string) (*domain.Order, error) {
    o, ok := m.orders[id]
    if !ok {
        return nil, fmt.Errorf("not found")
    }
    return o, nil
}

func TestPlaceOrder_Success(t *testing.T) {
    repo := &mockOrderRepo{orders: make(map[string]*domain.Order)}
    payments := &mockPayments{txID: "tx-123"}
    notifier := &mockNotifier{}
    idGen := &mockIDGen{id: "order-1"}

    svc := usecase.NewOrderService(repo, payments, notifier, idGen)

    output, err := svc.PlaceOrder(context.Background(), usecase.PlaceOrderInput{
        CustomerID: "cust-1",
        Items: []domain.OrderItem{
            {ProductID: "prod-1", Name: "Widget", Quantity: 2,
             Price: domain.Money{Amount: 1000, Currency: "USD"}},
        },
    })

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if output.OrderID != "order-1" {
        t.Errorf("got order ID %s, want order-1", output.OrderID)
    }
    if output.TransactionID != "tx-123" {
        t.Errorf("got tx ID %s, want tx-123", output.TransactionID)
    }
}

Adapters -- integration тесты

func TestPostgresOrderRepo_Save(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }

    pool := setupTestDB(t)
    repo := postgres.NewOrderRepo(pool)

    order := &domain.Order{
        ID:         "test-order-1",
        CustomerID: "cust-1",
        Status:     domain.OrderStatusDraft,
        Total:      domain.Money{Amount: 5000, Currency: "USD"},
        CreatedAt:  time.Now(),
        UpdatedAt:  time.Now(),
    }

    err := repo.Save(context.Background(), order)
    if err != nil {
        t.Fatalf("saving order: %v", err)
    }

    found, err := repo.FindByID(context.Background(), "test-order-1")
    if err != nil {
        t.Fatalf("finding order: %v", err)
    }

    if found.CustomerID != "cust-1" {
        t.Errorf("got customer %s, want cust-1", found.CustomerID)
    }
}

DI-фреймворки

Wire (Google) -- compile-time DI

// wire.go (build input)
//go:build wireinject

package main

import "github.com/google/wire"

func InitializeOrderService() (*usecase.OrderService, error) {
    wire.Build(
        postgres.NewOrderRepo,
        stripe.NewGateway,
        email.NewNotifier,
        usecase.NewOrderService,
        // Wire автоматически определяет граф зависимостей
    )
    return nil, nil
}

Fx (Uber) -- runtime DI

func main() {
    fx.New(
        fx.Provide(
            postgres.NewOrderRepo,
            stripe.NewGateway,
            email.NewNotifier,
            usecase.NewOrderService,
            httpAdapter.NewOrderHandler,
        ),
        fx.Invoke(startServer),
    ).Run()
}

Для большинства проектов ручной DI в main() предпочтительнее: явный, отлаживаемый, без магии.

Проверь себя

🧪

Какое главное правило зависимостей в Clean Architecture?

🧪

Как тестировать слой Use Cases в Clean Architecture?

🧪

Где определяются интерфейсы (порты) в Clean Architecture?