Clean Architecture, Hexagonal и Onion в Go
Зачем знать: Архитектурные паттерны — основа сопровождаемых сервисов. На middle-уровне от вас ждут умения выделять слои, прятать инфраструктуру за интерфейсами и писать код, который легко тестировать и менять. Это разница между «работающим прототипом» и системой, которая живёт 5+ лет.
Содержание
Заголовок раздела «Содержание»- Базовая концепция
- В Go идиоматично (как применять)
- Gotchas
- Best practices
- Вопросы на собесе
- Practice
- Источники
1. Базовая концепция
Заголовок раздела «1. Базовая концепция»1.1 Clean Architecture (Uncle Bob)
Заголовок раздела «1.1 Clean Architecture (Uncle Bob)»Роберт Мартин в книге Clean Architecture (2017) предложил 4 концентрических слоя:
┌───────────────────────────────────────────────┐│ Frameworks & Drivers │ ← HTTP, gRPC, БД, фреймворки│ ┌─────────────────────────────────────────┐ ││ │ Interface Adapters │ │ ← Контроллеры, presenters, gateway│ │ ┌───────────────────────────────────┐ │ ││ │ │ Application Business Rules │ │ │ ← Use Cases (Interactors)│ │ │ ┌─────────────────────────────┐ │ │ ││ │ │ │ Enterprise Business Rules │ │ │ │ ← Entities (Domain)│ │ │ └─────────────────────────────┘ │ │ ││ │ └───────────────────────────────────┘ │ ││ └─────────────────────────────────────────┘ │└───────────────────────────────────────────────┘Главное правило — Dependency Rule: зависимости направлены ТОЛЬКО внутрь. Внутренние слои НИЧЕГО не знают о внешних. Внешние слои зависят от интерфейсов внутренних.
| Слой | Что внутри | Зависит от |
|---|---|---|
| Entities | Бизнес-объекты с поведением | Ничего (stdlib только) |
| Use Cases | Сценарии (CreateOrder, PayOrder) | Только Entities |
| Interface Adapters | Реализации портов (DB repo, HTTP handler) | Use Cases, Entities |
| Frameworks | net/http, pgx, kafka | Adapters |
1.2 Hexagonal (Ports & Adapters) — Alistair Cockburn, 2005
Заголовок раздела «1.2 Hexagonal (Ports & Adapters) — Alistair Cockburn, 2005»Та же идея, но термины другие:
- Ports — интерфейсы, определённые в домене:
- Primary/Driving ports — то, что вызывает домен (use cases interface)
- Secondary/Driven ports — то, что домен вызывает (repository interface)
- Adapters — реализации портов:
- Primary adapters — HTTP/gRPC/CLI handlers
- Secondary adapters — PostgreSQL repo, Kafka producer, Redis cache
┌─────────┐ port port ┌──────────┐HTTP│ Adapter │─────────► │ DOMAIN │ ─────────►│PostgreSQL│ └─────────┘ (Ports) └──────────┘ AdapterHexagonal — это, по сути, тот же Clean Architecture, только без 4 слоёв, а через метафору «шестиугольника» (домен в центре, любое число адаптеров вокруг).
1.3 Onion Architecture — Jeffrey Palermo, 2008
Заголовок раздела «1.3 Onion Architecture — Jeffrey Palermo, 2008»Третий вариант, тоже похож:
┌──────────────────────────────────────────┐│ Infrastructure (DB, HTTP, External) ││ ┌────────────────────────────────────┐ ││ │ Application Services │ ││ │ ┌──────────────────────────────┐ │ ││ │ │ Domain Services │ │ ││ │ │ ┌────────────────────────┐ │ │ ││ │ │ │ Domain Model │ │ │ ││ │ │ └────────────────────────┘ │ │ ││ │ └──────────────────────────────┘ │ ││ └────────────────────────────────────┘ │└──────────────────────────────────────────┘1.4 Почему всё это похоже?
Заголовок раздела «1.4 Почему всё это похоже?»В Go реализация всех трёх практически идентична. Различия терминологические:
| Понятие в Clean Arch | Onion | Hexagonal |
|---|---|---|
| Entities | Domain Model | Domain |
| Use Cases | Application Services | Application |
| Interface Adapters | Infrastructure | Adapters |
| Frameworks | Infrastructure | (внешний мир) |
Главное: домен в центре, инфраструктура снаружи, зависимости — внутрь через интерфейсы.
2. В Go идиоматично
Заголовок раздела «2. В Go идиоматично»2.1 Структура папок
Заголовок раздела «2.1 Структура папок»Типовая структура для middle-проекта:
my-service/├── cmd/│ └── api/│ └── main.go # composition root├── internal/│ ├── domain/ # entities, value objects│ │ ├── order.go│ │ └── money.go│ ├── usecase/ # business logic (interactors)│ │ ├── create_order.go│ │ └── create_order_test.go│ ├── adapter/ # реализации портов│ │ ├── postgres/│ │ │ └── order_repo.go│ │ ├── kafka/│ │ │ └── publisher.go│ │ └── redis/│ │ └── cache.go│ └── transport/ # HTTP/gRPC handlers│ ├── http/│ │ └── order_handler.go│ └── grpc/│ └── order_service.go├── pkg/ # публичные библиотеки (если нужны)├── api/ # OpenAPI/proto файлы├── configs/└── go.mod2.2 Domain (entities)
Заголовок раздела «2.2 Domain (entities)»Entity — это объект с идентичностью и поведением.
package domain
import ( "errors" "time"
"github.com/google/uuid")
type OrderStatus string
const ( StatusPending OrderStatus = "pending" StatusPaid OrderStatus = "paid" StatusShipped OrderStatus = "shipped" StatusCanceled OrderStatus = "canceled")
type Order struct { ID uuid.UUID UserID uuid.UUID Items []OrderItem Status OrderStatus Total Money CreatedAt time.Time}
type OrderItem struct { ProductID uuid.UUID Quantity int Price Money}
var ( ErrOrderEmpty = errors.New("order has no items") ErrCannotCancelShipped = errors.New("cannot cancel shipped order") ErrInvalidStatusTransition = errors.New("invalid status transition"))
// NewOrder — конструктор с валидацией инвариантов.func NewOrder(userID uuid.UUID, items []OrderItem) (*Order, error) { if len(items) == 0 { return nil, ErrOrderEmpty } total := Money{} for _, it := range items { total = total.Add(it.Price.Multiply(it.Quantity)) } return &Order{ ID: uuid.New(), UserID: userID, Items: items, Status: StatusPending, Total: total, CreatedAt: time.Now().UTC(), }, nil}
// Cancel — это поведение домена, а не сервиса.func (o *Order) Cancel() error { if o.Status == StatusShipped { return ErrCannotCancelShipped } o.Status = StatusCanceled return nil}
// MarkAsPaid — переход состояния с проверкой.func (o *Order) MarkAsPaid() error { if o.Status != StatusPending { return ErrInvalidStatusTransition } o.Status = StatusPaid return nil}Важно: Order не знает про БД, HTTP или Protobuf. Это чистая бизнес-логика.
2.3 Value Object
Заголовок раздела «2.3 Value Object»package domain
import "errors"
type Currency string
const ( USD Currency = "USD" EUR Currency = "EUR" RUB Currency = "RUB")
// Money — value object: immutable, equality by value.type Money struct { Amount int64 // в копейках/центах Currency Currency}
var ErrCurrencyMismatch = errors.New("currency mismatch")
func (m Money) Add(other Money) Money { if m.Currency != other.Currency { // в реальности — возвращаем error или панику в инвариантах return m } return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}}
func (m Money) Multiply(qty int) Money { return Money{Amount: m.Amount * int64(qty), Currency: m.Currency}}
func (m Money) Equal(other Money) bool { return m.Amount == other.Amount && m.Currency == other.Currency}2.4 Use Case (Interactor)
Заголовок раздела «2.4 Use Case (Interactor)»Use case — это конкретный сценарий приложения. Он принимает зависимости через интерфейсы.
package usecase
import ( "context" "fmt"
"github.com/google/uuid"
"myservice/internal/domain")
// OrderRepository — secondary port. Определён в use case (или в domain).type OrderRepository interface { Save(ctx context.Context, o *domain.Order) error GetByID(ctx context.Context, id uuid.UUID) (*domain.Order, error)}
// ProductCatalog — secondary port для получения цен.type ProductCatalog interface { GetPrice(ctx context.Context, productID uuid.UUID) (domain.Money, error)}
// EventPublisher — для domain events.type EventPublisher interface { Publish(ctx context.Context, event any) error}
// CreateOrderInput — данные на входе use case.type CreateOrderInput struct { UserID uuid.UUID Items []CreateOrderItemInput}
type CreateOrderItemInput struct { ProductID uuid.UUID Quantity int}
// CreateOrderUseCase — use case с зависимостями через интерфейсы.type CreateOrderUseCase struct { orders OrderRepository catalog ProductCatalog events EventPublisher}
func NewCreateOrderUseCase( orders OrderRepository, catalog ProductCatalog, events EventPublisher,) *CreateOrderUseCase { return &CreateOrderUseCase{orders: orders, catalog: catalog, events: events}}
// Execute — один метод на use case.func (uc *CreateOrderUseCase) Execute(ctx context.Context, in CreateOrderInput) (*domain.Order, error) { items := make([]domain.OrderItem, 0, len(in.Items)) for _, it := range in.Items { price, err := uc.catalog.GetPrice(ctx, it.ProductID) if err != nil { return nil, fmt.Errorf("get price for %s: %w", it.ProductID, err) } items = append(items, domain.OrderItem{ ProductID: it.ProductID, Quantity: it.Quantity, Price: price, }) } order, err := domain.NewOrder(in.UserID, items) if err != nil { return nil, fmt.Errorf("create order: %w", err) } if err := uc.orders.Save(ctx, order); err != nil { return nil, fmt.Errorf("save order: %w", err) } if err := uc.events.Publish(ctx, OrderCreated{OrderID: order.ID, UserID: order.UserID}); err != nil { // event publishing не должен ломать use case (или через outbox) return order, nil } return order, nil}
type OrderCreated struct { OrderID uuid.UUID UserID uuid.UUID}2.5 Adapter (PostgreSQL repository)
Заголовок раздела «2.5 Adapter (PostgreSQL repository)»package postgres
import ( "context" "encoding/json" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool"
"myservice/internal/domain")
// OrderRepo реализует usecase.OrderRepository.type OrderRepo struct { pool *pgxpool.Pool}
func NewOrderRepo(pool *pgxpool.Pool) *OrderRepo { return &OrderRepo{pool: pool}}
func (r *OrderRepo) Save(ctx context.Context, o *domain.Order) error { itemsJSON, err := json.Marshal(o.Items) if err != nil { return fmt.Errorf("marshal items: %w", err) } const q = ` INSERT INTO orders (id, user_id, items, status, total_amount, total_currency, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, items = EXCLUDED.items ` _, err = r.pool.Exec(ctx, q, o.ID, o.UserID, itemsJSON, o.Status, o.Total.Amount, o.Total.Currency, o.CreatedAt, ) return err}
func (r *OrderRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Order, error) { const q = `SELECT id, user_id, items, status, total_amount, total_currency, created_at FROM orders WHERE id = $1` var o domain.Order var itemsJSON []byte err := r.pool.QueryRow(ctx, q, id).Scan( &o.ID, &o.UserID, &itemsJSON, &o.Status, &o.Total.Amount, &o.Total.Currency, &o.CreatedAt, ) if err != nil { return nil, err } if err := json.Unmarshal(itemsJSON, &o.Items); err != nil { return nil, fmt.Errorf("unmarshal items: %w", err) } return &o, nil}2.6 Transport (HTTP handler)
Заголовок раздела «2.6 Transport (HTTP handler)»package httptransport
import ( "encoding/json" "errors" "net/http"
"github.com/google/uuid"
"myservice/internal/domain" "myservice/internal/usecase")
type OrderHandler struct { createOrder *usecase.CreateOrderUseCase}
func NewOrderHandler(createOrder *usecase.CreateOrderUseCase) *OrderHandler { return &OrderHandler{createOrder: createOrder}}
type createOrderRequest struct { UserID uuid.UUID `json:"user_id"` Items []struct { ProductID uuid.UUID `json:"product_id"` Quantity int `json:"quantity"` } `json:"items"`}
type createOrderResponse struct { OrderID uuid.UUID `json:"order_id"` Total int64 `json:"total"`}
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) { var req createOrderRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } in := usecase.CreateOrderInput{UserID: req.UserID} for _, it := range req.Items { in.Items = append(in.Items, usecase.CreateOrderItemInput{ ProductID: it.ProductID, Quantity: it.Quantity, }) } order, err := h.createOrder.Execute(r.Context(), in) if err != nil { switch { case errors.Is(err, domain.ErrOrderEmpty): http.Error(w, err.Error(), http.StatusBadRequest) default: http.Error(w, "internal error", http.StatusInternalServerError) } return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(createOrderResponse{ OrderID: order.ID, Total: order.Total.Amount, })}2.7 Composition Root (main.go)
Заголовок раздела «2.7 Composition Root (main.go)»Все зависимости собираются ТОЛЬКО в main.go:
package main
import ( "context" "log" "net/http" "os" "os/signal" "syscall" "time"
"github.com/jackc/pgx/v5/pgxpool"
httptransport "myservice/internal/transport/http" "myservice/internal/adapter/postgres" "myservice/internal/adapter/kafka" "myservice/internal/adapter/catalog" "myservice/internal/usecase")
func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel()
pool, err := pgxpool.New(ctx, os.Getenv("DB_DSN")) if err != nil { log.Fatal(err) } defer pool.Close()
// адаптеры orderRepo := postgres.NewOrderRepo(pool) publisher := kafka.NewPublisher(os.Getenv("KAFKA_BROKERS")) productCat := catalog.NewHTTPClient(os.Getenv("CATALOG_URL"))
// use cases createOrder := usecase.NewCreateOrderUseCase(orderRepo, productCat, publisher)
// handlers h := httptransport.NewOrderHandler(createOrder)
mux := http.NewServeMux() mux.HandleFunc("POST /orders", h.CreateOrder)
srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, } go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } }() <-ctx.Done() shutdownCtx, sc := context.WithTimeout(context.Background(), 15*time.Second) defer sc() srv.Shutdown(shutdownCtx)}2.8 Тестирование use case с mock-репозиториями
Заголовок раздела «2.8 Тестирование use case с mock-репозиториями»package usecase_test
import ( "context" "errors" "testing"
"github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require"
"myservice/internal/domain" "myservice/internal/usecase")
// Fake (in-memory) репозиторий.type fakeOrderRepo struct { orders map[uuid.UUID]*domain.Order failOn string}
func newFakeOrderRepo() *fakeOrderRepo { return &fakeOrderRepo{orders: make(map[uuid.UUID]*domain.Order)}}
func (r *fakeOrderRepo) Save(_ context.Context, o *domain.Order) error { if r.failOn == "save" { return errors.New("db failure") } r.orders[o.ID] = o return nil}
func (r *fakeOrderRepo) GetByID(_ context.Context, id uuid.UUID) (*domain.Order, error) { o, ok := r.orders[id] if !ok { return nil, errors.New("not found") } return o, nil}
type fakeCatalog struct{}
func (fakeCatalog) GetPrice(_ context.Context, _ uuid.UUID) (domain.Money, error) { return domain.Money{Amount: 1000, Currency: domain.USD}, nil}
type fakePublisher struct{ events []any }
func (p *fakePublisher) Publish(_ context.Context, e any) error { p.events = append(p.events, e) return nil}
func TestCreateOrder_Success(t *testing.T) { repo := newFakeOrderRepo() cat := fakeCatalog{} pub := &fakePublisher{} uc := usecase.NewCreateOrderUseCase(repo, cat, pub)
order, err := uc.Execute(context.Background(), usecase.CreateOrderInput{ UserID: uuid.New(), Items: []usecase.CreateOrderItemInput{ {ProductID: uuid.New(), Quantity: 2}, }, }) require.NoError(t, err) assert.Equal(t, int64(2000), order.Total.Amount) assert.Len(t, repo.orders, 1) assert.Len(t, pub.events, 1)}
func TestCreateOrder_EmptyItems(t *testing.T) { uc := usecase.NewCreateOrderUseCase(newFakeOrderRepo(), fakeCatalog{}, &fakePublisher{}) _, err := uc.Execute(context.Background(), usecase.CreateOrderInput{ UserID: uuid.New(), }) assert.ErrorIs(t, err, domain.ErrOrderEmpty)}
func TestCreateOrder_DBFailure(t *testing.T) { repo := newFakeOrderRepo() repo.failOn = "save" uc := usecase.NewCreateOrderUseCase(repo, fakeCatalog{}, &fakePublisher{}) _, err := uc.Execute(context.Background(), usecase.CreateOrderInput{ UserID: uuid.New(), Items: []usecase.CreateOrderItemInput{{ProductID: uuid.New(), Quantity: 1}}, }) assert.Error(t, err)}Тесты use case не трогают БД, сеть и фреймворки. Они выполняются за миллисекунды.
3. Gotchas
Заголовок раздела «3. Gotchas»3.1 Интерфейс — у потребителя (consumer-defined)
Заголовок раздела «3.1 Интерфейс — у потребителя (consumer-defined)»В Java/C# обычно интерфейс рядом с реализацией. В Go идиома обратная: интерфейс там, где его используют.
// ПЛОХО: интерфейс в пакете postgrespackage postgres
type OrderRepository interface { ... }type OrderRepo struct {...}
// ХОРОШО: интерфейс в usecase (или domain), реализация в postgrespackage usecasetype OrderRepository interface { ... }
package postgrestype OrderRepo struct{...}// удовлетворяет usecase.OrderRepository неявно (duck typing)Это позволяет иметь несколько реализаций без зависимости domain → infrastructure.
3.2 Не пытайтесь сделать “domain без зависимостей вообще”
Заголовок раздела «3.2 Не пытайтесь сделать “domain без зависимостей вообще”»В Go domain может (и должен) использовать time, errors, uuid и другие низкоуровневые либы. Запрещены: ORM-аннотации, database/sql, HTTP/Protobuf-теги.
// ПЛОХО: domain знает про БД и HTTPtype Order struct { ID uuid.UUID `db:"id" json:"id"` // ...}Решение — отдельные DTO в адаптерах:
type orderRow struct { ID uuid.UUID UserID uuid.UUID // ...}func (r orderRow) toDomain() *domain.Order { ... }На практике многие проекты идут на компромисс и держат JSON/DB-теги прямо на domain — это допустимо, если вы понимаете trade-off.
3.3 Анемичные модели
Заголовок раздела «3.3 Анемичные модели»// ПЛОХО: Order без поведения (anemic)type Order struct { ID uuid.UUID Status string}
// логика «размазана» по сервисамfunc (s *OrderService) Cancel(o *Order) error { if o.Status == "shipped" { return errors.New("...") } o.Status = "canceled" return nil}// ХОРОШО: поведение на entityfunc (o *Order) Cancel() error { if o.Status == StatusShipped { return ErrCannotCancelShipped } o.Status = StatusCanceled return nil}Use case оркеструет, но инварианты живут в entity.
3.4 Циклические импорты
Заголовок раздела «3.4 Циклические импорты»Самая частая ошибка — domain начинает импортировать usecase или infrastructure. Это запрещено правилом зависимостей. Используйте линтер go vet / golangci-lint depguard.
3.5 internal/ vs pkg/
Заголовок раздела «3.5 internal/ vs pkg/»internal/ — компилятор не даст импортировать снаружи модуля. Используйте его для всей бизнес-логики. pkg/ — публичные либы; многие гайдлайны прямо советуют не использовать pkg/ в простых сервисах (Go style, Rakyll, Bill Kennedy).
3.6 Переусложнение
Заголовок раздела «3.6 Переусложнение»// ПЛОХО: 5 слоёв для CRUD-эндпоинтаGET /users/{id}: Handler → UseCase → Service → Repository → Mapper → ORMДля тривиального CRUD-проекта Clean Architecture — overkill. Это уместно при:
- средней/высокой сложности домена (биллинг, заказы, ML pipelines);
- проекте, который живёт 2+ года;
- команде 3+ человек.
Альтернатива — Transaction Script (handler сразу пишет в БД). Это anti-pattern для домена, но валидный выбор для админок и lookup-сервисов.
3.7 Слишком мелкие use cases
Заголовок раздела «3.7 Слишком мелкие use cases»CreateOrderUseCase, UpdateOrderUseCase, DeleteOrderUseCase — нормально.
SetOrderStatusToCanceledIfNotShippedUseCase — нет. Это инвариант домена, который должен быть в Order.Cancel().
3.8 Перепутали слои
Заголовок раздела «3.8 Перепутали слои»// ПЛОХО: domain зависит от sql.DBpackage domainimport "database/sql"type Order struct { ... }func (o *Order) SaveTo(db *sql.DB) error { ... }Domain не знает, КАК он сохраняется. Это работа адаптера.
3.9 «Repository пишет в кэш»
Заголовок раздела «3.9 «Repository пишет в кэш»»// ПЛОХО: repository содержит логику кэшированияfunc (r *OrderRepo) GetByID(...) { if v, ok := r.cache.Get(id); ok { return v } // ...}Решение — декоратор:
type CachedOrderRepo struct { inner usecase.OrderRepository cache Cache}func (r *CachedOrderRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Order, error) { if v, ok := r.cache.Get(id); ok { return v.(*domain.Order), nil } o, err := r.inner.GetByID(ctx, id) if err == nil { r.cache.Set(id, o) } return o, err}В composition root оборачиваем: cachedRepo := NewCachedOrderRepo(pgRepo, cache).
3.10 Use case вызывает другой use case
Заголовок раздела «3.10 Use case вызывает другой use case»Это запах. Use case — конкретный сценарий, и они не должны зависеть друг от друга. Если есть пересечение — выделите доменный сервис (domain.Pricer) или privatе функцию.
4. Best practices
Заголовок раздела «4. Best practices»- Domain в центре. Bizlogic — в entity и domain service, а не в use case или handler.
- Интерфейсы — у потребителя. В
usecase/илиdomain/, не вinfrastructure/. - DTO на каждой границе. Request → input → entity → output → response. Не пробрасывайте entity на HTTP.
- Композиция в main.go. Никаких глобальных переменных, синглтонов или service locator.
- Тестируйте use case с fake-репозиториями. Это даст 80% покрытия за 10% времени.
- Линтер на правила зависимостей.
go-arch-lint,depguardвgolangci-lint. - Не плодите тривиальные обёртки. Если репозиторий вызывает 1 SQL и возвращает entity — это нормально.
- Errors из domain — типизированные.
var ErrOrderEmpty = errors.New(...)— handler сможет различить. - Pure use case. Use case не должен иметь логирования внутри (это side effect). Логирование — в декораторах/middleware.
- Конструкторы валидируют инварианты.
NewOrder(...)либо возвращает валидный объект, либо ошибку. - Одна публичная функция на use case.
Execute(ctx, input) (output, error)— стандарт. - Контекст всегда первым параметром.
- Не используйте orm-теги на entity. ORM-аннотации связывают домен с конкретной либой.
- Composition root знает всё, остальные — ничего. Только main.go знает, какой Kafka-producer и какой Postgres-pool используются.
5. Вопросы на собесе
Заголовок раздела «5. Вопросы на собесе»-
Что такое Dependency Rule в Clean Architecture? Зависимости направлены только внутрь, наружу — никогда.
-
Чем Hexagonal Architecture отличается от Clean Architecture? Концептуально — почти ничем. Hexagonal делит мир на «домен» и «адаптеры», Clean — 4 концентрических слоя. Реализация в Go идентична.
-
Где должен быть определён интерфейс репозитория — в
domain,usecaseилиinfrastructure? В usecase (или domain), потому что интерфейс принадлежит потребителю. Реализация — в infrastructure. -
Что такое анемичная модель и почему это antipattern? Entity без поведения, только данные. Логика «размазана» по сервисам, инварианты нарушаются, тестировать сложнее. В DDD/Clean — антипаттерн.
-
Может ли use case вызывать другой use case? Нет, это запах. Use case — конкретный сценарий приложения. Пересечение логики выносится в domain service.
-
Что такое Composition Root? Единственное место в приложении (main.go), где создаются все зависимости и собираются граф. Обычно use case → adapter → infrastructure инстанцируются здесь.
-
Зачем нужен слой adapter, если можно вызвать pgxpool напрямую из use case? Чтобы use case не зависел от конкретной СУБД. Завтра поменяем postgres на mongo — поменяется только адаптер.
-
Что делает
internal/в Go? Это keyword: компилятор запрещает импорт пакетов изinternal/за пределами родительского модуля. Используется для приватной бизнес-логики. -
Когда Clean Architecture — overkill? Для CRUD-сервисов, тривиальных админок, прототипов. Если проект живёт меньше года и домена нет.
-
Что такое Value Object и чем отличается от Entity? Value Object — immutable, equality by value (две Money равны, если равны Amount и Currency). Entity имеет identity (ID), equality by reference.
-
Как тестировать use case без поднятия БД? Fake/mock-реализацию интерфейса репозитория. Тест проходит за миллисекунды, не зависит от инфраструктуры.
-
Что такое Repository Pattern? Абстракция доступа к persistence, инкапсулирующая запросы. Use case работает с
OrderRepositoryинтерфейсом, не зная про SQL/Mongo. -
Куда положить логирование в Clean Architecture? В middleware (HTTP/gRPC) или в декораторы вокруг репозиториев/use case. В сам use case — не рекомендуется (impure).
-
Чем
pkg/отличается отinternal/?pkg/— публичные пакеты, доступные снаружи модуля.internal/— приватные, компилятор enforce ограничение. -
Можно ли в domain использовать
time.Now()? Прямой вызов — anti-pattern, потому что не тестируется. Лучше пробрасыватьClockинтерфейс или передаватьnowпараметром в use case. -
Что такое Onion Architecture и чем она отличается от Clean? Концептуально похожа: домен в центре, инфраструктура снаружи. Различия — терминологические.
-
Куда положить транзакции БД (BEGIN/COMMIT)? В use case через Unit-of-Work/TxManager интерфейс. Сам SQL — в репозитории, но управление транзакцией — на уровне use case.
-
Как организовать domain events? Entity накапливает события (
order.events), use case достаёт их и публикует через EventPublisher. В критичных случаях — outbox pattern. -
Что такое Bounded Context (DDD)? Граница, в которой термины domain однозначны. Например, «Order» в контексте billing и shipping — разные сущности. Часто = отдельный микросервис.
-
Где живёт валидация — в handler или в use case? Технические проверки (длина строки) — в handler. Доменные инварианты (статус заказа, ненулевая сумма) — в entity. Use case — оркестратор.
-
Что такое Ports & Adapters? Hexagonal Architecture. Ports — интерфейсы домена (primary — что вызывает домен; secondary — что вызывает домен). Adapters — реализации.
-
Чем CleanArch от DDD? Clean Architecture — про слои и зависимости. DDD — про моделирование домена (entity, aggregate, bounded context). Часто используются вместе.
-
Что такое Transaction Script и когда уместен? Pattern, где вся логика операции — в одной функции (обычно handler), без слоёв. Уместен для тривиального CRUD, где домена нет.
-
Как добавить новый source (CLI вместо HTTP) к существующему сервису по Clean Arch? Добавить новый адаптер в
internal/transport/cli/, который вызывает существующие use case. Никаких изменений в domain/usecase. -
Объясните Dependency Inversion в Clean Arch. High-level (use case) не зависит от low-level (postgres). Оба зависят от абстракции (интерфейс репозитория). Это и есть DIP из SOLID.
6. Practice
Заголовок раздела «6. Practice»-
Возьмите сервис, который вы пишете (или TODO-API), и переструктурируйте в Clean Architecture: domain/usecase/adapter/transport. Зафиксируйте граф зависимостей с
go-arch-lint. -
Напишите
OrderServiceс методамиCreate,Pay,Cancel,Ship. Все доменные правила — в entity (нельзя отменить отгруженный, нельзя оплатить дважды). Покройте unit-тестами с fake-репозиторием. -
Реализуйте
CachedOrderRepositoryкак декоратор поверх PostgreSQL-репозитория. Покажите, что use case ничего не знает о кэше. -
Добавьте gRPC-транспорт к существующему HTTP-сервису так, чтобы поменялся только
internal/transport/grpc/. Composition root инстанцирует оба сервера на тех же use case. -
Напишите линтер-правило (
depguardconfig), запрещающее importdatabase/sqlв пакетеdomain.
7. Источники
Заголовок раздела «7. Источники»- Robert C. Martin. Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Prentice Hall, 2017.
- Alistair Cockburn. Hexagonal Architecture — https://alistair.cockburn.us/hexagonal-architecture/
- Jeffrey Palermo. Onion Architecture — https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/
- Eric Evans. Domain-Driven Design. Addison-Wesley, 2003.
- Bill Kennedy. Service-Layer Pattern — https://www.ardanlabs.com/blog/2017/02/package-oriented-design.html
- Mat Ryer. How I write HTTP services in Go — https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/
- Peter Bourgon. Go Best Practices — https://peter.bourgon.org/go-for-industrial-programming/
- golang-standards/project-layout — https://github.com/golang-standards/project-layout