Перейти к содержимому

Clean Architecture, Hexagonal и Onion в Go

Зачем знать: Архитектурные паттерны — основа сопровождаемых сервисов. На middle-уровне от вас ждут умения выделять слои, прятать инфраструктуру за интерфейсами и писать код, который легко тестировать и менять. Это разница между «работающим прототипом» и системой, которая живёт 5+ лет.

  1. Базовая концепция
  2. В Go идиоматично (как применять)
  3. Gotchas
  4. Best practices
  5. Вопросы на собесе
  6. Practice
  7. Источники

Роберт Мартин в книге 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
Frameworksnet/http, pgx, kafkaAdapters

Та же идея, но термины другие:

  • 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) └──────────┘
Adapter

Hexagonal — это, по сути, тот же Clean Architecture, только без 4 слоёв, а через метафору «шестиугольника» (домен в центре, любое число адаптеров вокруг).

Третий вариант, тоже похож:

┌──────────────────────────────────────────┐
│ Infrastructure (DB, HTTP, External) │
│ ┌────────────────────────────────────┐ │
│ │ Application Services │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Domain Services │ │ │
│ │ │ ┌────────────────────────┐ │ │ │
│ │ │ │ Domain Model │ │ │ │
│ │ │ └────────────────────────┘ │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘

В Go реализация всех трёх практически идентична. Различия терминологические:

Понятие в Clean ArchOnionHexagonal
EntitiesDomain ModelDomain
Use CasesApplication ServicesApplication
Interface AdaptersInfrastructureAdapters
FrameworksInfrastructure(внешний мир)

Главное: домен в центре, инфраструктура снаружи, зависимости — внутрь через интерфейсы.


Типовая структура для 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.mod

Entity — это объект с идентичностью и поведением.

internal/domain/order.go
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. Это чистая бизнес-логика.

internal/domain/money.go
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
}

Use case — это конкретный сценарий приложения. Он принимает зависимости через интерфейсы.

internal/usecase/create_order.go
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
}
internal/adapter/postgres/order_repo.go
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
}
internal/transport/http/order_handler.go
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,
})
}

Все зависимости собираются ТОЛЬКО в main.go:

cmd/api/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)
}
internal/usecase/create_order_test.go
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 не трогают БД, сеть и фреймворки. Они выполняются за миллисекунды.


В Java/C# обычно интерфейс рядом с реализацией. В Go идиома обратная: интерфейс там, где его используют.

// ПЛОХО: интерфейс в пакете postgres
package postgres
type OrderRepository interface { ... }
type OrderRepo struct {...}
// ХОРОШО: интерфейс в usecase (или domain), реализация в postgres
package usecase
type OrderRepository interface { ... }
package postgres
type 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 знает про БД и HTTP
type Order struct {
ID uuid.UUID `db:"id" json:"id"`
// ...
}

Решение — отдельные DTO в адаптерах:

internal/adapter/postgres/dto.go
type orderRow struct {
ID uuid.UUID
UserID uuid.UUID
// ...
}
func (r orderRow) toDomain() *domain.Order { ... }

На практике многие проекты идут на компромисс и держат JSON/DB-теги прямо на domain — это допустимо, если вы понимаете trade-off.

// ПЛОХО: 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
}
// ХОРОШО: поведение на entity
func (o *Order) Cancel() error {
if o.Status == StatusShipped {
return ErrCannotCancelShipped
}
o.Status = StatusCanceled
return nil
}

Use case оркеструет, но инварианты живут в entity.

Самая частая ошибка — domain начинает импортировать usecase или infrastructure. Это запрещено правилом зависимостей. Используйте линтер go vet / golangci-lint depguard.

internal/ — компилятор не даст импортировать снаружи модуля. Используйте его для всей бизнес-логики. pkg/ — публичные либы; многие гайдлайны прямо советуют не использовать pkg/ в простых сервисах (Go style, Rakyll, Bill Kennedy).

// ПЛОХО: 5 слоёв для CRUD-эндпоинта
GET /users/{id}:
HandlerUseCaseServiceRepositoryMapperORM

Для тривиального CRUD-проекта Clean Architecture — overkill. Это уместно при:

  • средней/высокой сложности домена (биллинг, заказы, ML pipelines);
  • проекте, который живёт 2+ года;
  • команде 3+ человек.

Альтернатива — Transaction Script (handler сразу пишет в БД). Это anti-pattern для домена, но валидный выбор для админок и lookup-сервисов.

CreateOrderUseCase, UpdateOrderUseCase, DeleteOrderUseCase — нормально. SetOrderStatusToCanceledIfNotShippedUseCase — нет. Это инвариант домена, который должен быть в Order.Cancel().

// ПЛОХО: domain зависит от sql.DB
package domain
import "database/sql"
type Order struct { ... }
func (o *Order) SaveTo(db *sql.DB) error { ... }

Domain не знает, КАК он сохраняется. Это работа адаптера.

// ПЛОХО: 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).

Это запах. Use case — конкретный сценарий, и они не должны зависеть друг от друга. Если есть пересечение — выделите доменный сервис (domain.Pricer) или privatе функцию.


  1. Domain в центре. Bizlogic — в entity и domain service, а не в use case или handler.
  2. Интерфейсы — у потребителя. В usecase/ или domain/, не в infrastructure/.
  3. DTO на каждой границе. Request → input → entity → output → response. Не пробрасывайте entity на HTTP.
  4. Композиция в main.go. Никаких глобальных переменных, синглтонов или service locator.
  5. Тестируйте use case с fake-репозиториями. Это даст 80% покрытия за 10% времени.
  6. Линтер на правила зависимостей. go-arch-lint, depguard в golangci-lint.
  7. Не плодите тривиальные обёртки. Если репозиторий вызывает 1 SQL и возвращает entity — это нормально.
  8. Errors из domain — типизированные. var ErrOrderEmpty = errors.New(...) — handler сможет различить.
  9. Pure use case. Use case не должен иметь логирования внутри (это side effect). Логирование — в декораторах/middleware.
  10. Конструкторы валидируют инварианты. NewOrder(...) либо возвращает валидный объект, либо ошибку.
  11. Одна публичная функция на use case. Execute(ctx, input) (output, error) — стандарт.
  12. Контекст всегда первым параметром.
  13. Не используйте orm-теги на entity. ORM-аннотации связывают домен с конкретной либой.
  14. Composition root знает всё, остальные — ничего. Только main.go знает, какой Kafka-producer и какой Postgres-pool используются.

  1. Что такое Dependency Rule в Clean Architecture? Зависимости направлены только внутрь, наружу — никогда.

  2. Чем Hexagonal Architecture отличается от Clean Architecture? Концептуально — почти ничем. Hexagonal делит мир на «домен» и «адаптеры», Clean — 4 концентрических слоя. Реализация в Go идентична.

  3. Где должен быть определён интерфейс репозитория — в domain, usecase или infrastructure? В usecase (или domain), потому что интерфейс принадлежит потребителю. Реализация — в infrastructure.

  4. Что такое анемичная модель и почему это antipattern? Entity без поведения, только данные. Логика «размазана» по сервисам, инварианты нарушаются, тестировать сложнее. В DDD/Clean — антипаттерн.

  5. Может ли use case вызывать другой use case? Нет, это запах. Use case — конкретный сценарий приложения. Пересечение логики выносится в domain service.

  6. Что такое Composition Root? Единственное место в приложении (main.go), где создаются все зависимости и собираются граф. Обычно use case → adapter → infrastructure инстанцируются здесь.

  7. Зачем нужен слой adapter, если можно вызвать pgxpool напрямую из use case? Чтобы use case не зависел от конкретной СУБД. Завтра поменяем postgres на mongo — поменяется только адаптер.

  8. Что делает internal/ в Go? Это keyword: компилятор запрещает импорт пакетов из internal/ за пределами родительского модуля. Используется для приватной бизнес-логики.

  9. Когда Clean Architecture — overkill? Для CRUD-сервисов, тривиальных админок, прототипов. Если проект живёт меньше года и домена нет.

  10. Что такое Value Object и чем отличается от Entity? Value Object — immutable, equality by value (две Money равны, если равны Amount и Currency). Entity имеет identity (ID), equality by reference.

  11. Как тестировать use case без поднятия БД? Fake/mock-реализацию интерфейса репозитория. Тест проходит за миллисекунды, не зависит от инфраструктуры.

  12. Что такое Repository Pattern? Абстракция доступа к persistence, инкапсулирующая запросы. Use case работает с OrderRepository интерфейсом, не зная про SQL/Mongo.

  13. Куда положить логирование в Clean Architecture? В middleware (HTTP/gRPC) или в декораторы вокруг репозиториев/use case. В сам use case — не рекомендуется (impure).

  14. Чем pkg/ отличается от internal/? pkg/ — публичные пакеты, доступные снаружи модуля. internal/ — приватные, компилятор enforce ограничение.

  15. Можно ли в domain использовать time.Now()? Прямой вызов — anti-pattern, потому что не тестируется. Лучше пробрасывать Clock интерфейс или передавать now параметром в use case.

  16. Что такое Onion Architecture и чем она отличается от Clean? Концептуально похожа: домен в центре, инфраструктура снаружи. Различия — терминологические.

  17. Куда положить транзакции БД (BEGIN/COMMIT)? В use case через Unit-of-Work/TxManager интерфейс. Сам SQL — в репозитории, но управление транзакцией — на уровне use case.

  18. Как организовать domain events? Entity накапливает события (order.events), use case достаёт их и публикует через EventPublisher. В критичных случаях — outbox pattern.

  19. Что такое Bounded Context (DDD)? Граница, в которой термины domain однозначны. Например, «Order» в контексте billing и shipping — разные сущности. Часто = отдельный микросервис.

  20. Где живёт валидация — в handler или в use case? Технические проверки (длина строки) — в handler. Доменные инварианты (статус заказа, ненулевая сумма) — в entity. Use case — оркестратор.

  21. Что такое Ports & Adapters? Hexagonal Architecture. Ports — интерфейсы домена (primary — что вызывает домен; secondary — что вызывает домен). Adapters — реализации.

  22. Чем CleanArch от DDD? Clean Architecture — про слои и зависимости. DDD — про моделирование домена (entity, aggregate, bounded context). Часто используются вместе.

  23. Что такое Transaction Script и когда уместен? Pattern, где вся логика операции — в одной функции (обычно handler), без слоёв. Уместен для тривиального CRUD, где домена нет.

  24. Как добавить новый source (CLI вместо HTTP) к существующему сервису по Clean Arch? Добавить новый адаптер в internal/transport/cli/, который вызывает существующие use case. Никаких изменений в domain/usecase.

  25. Объясните Dependency Inversion в Clean Arch. High-level (use case) не зависит от low-level (postgres). Оба зависят от абстракции (интерфейс репозитория). Это и есть DIP из SOLID.


  1. Возьмите сервис, который вы пишете (или TODO-API), и переструктурируйте в Clean Architecture: domain/usecase/adapter/transport. Зафиксируйте граф зависимостей с go-arch-lint.

  2. Напишите OrderService с методами Create, Pay, Cancel, Ship. Все доменные правила — в entity (нельзя отменить отгруженный, нельзя оплатить дважды). Покройте unit-тестами с fake-репозиторием.

  3. Реализуйте CachedOrderRepository как декоратор поверх PostgreSQL-репозитория. Покажите, что use case ничего не знает о кэше.

  4. Добавьте gRPC-транспорт к существующему HTTP-сервису так, чтобы поменялся только internal/transport/grpc/. Composition root инстанцирует оба сервера на тех же use case.

  5. Напишите линтер-правило (depguard config), запрещающее import database/sql в пакете domain.