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() предпочтительнее: явный, отлаживаемый, без магии.