Clean Architecture + DDD в Go: как не превратить проект в 200 файлов ни о чём
- воскресенье, 19 апреля 2026 г. в 00:00:13
Прежде чем погружаться в архитектуру, давайте посмотрим на контекст, в котором всё это происходит.
По данным исследования McKinsey 2022 года, технический долг составляет до 40% всего технологического портфеля компаний. И это не просто цифра в отчёте. Согласно опросу 2024 года среди технических руководителей, у более чем 50% компаний технический долг занимает свыше четверти всего IT-бюджета, блокируя внедрение новых функций. (Источник: vFunction, 2025)
При этом исследование Carnegie Mellon выяснило, что наибольшим источником технического долга являются именно архитектурные проблемы — а не баги и не плохой код на уровне функций.
Теперь о Go. По данным Go Developer Survey 2024, главной проблемой команд, работающих с Go, названо поддержание единых стандартов кода — в том числе из-за разного уровня опыта участников и привнесения не-идиоматических паттернов из других языков. (Источник: go.dev/blog/survey2024-h2-results)
Это напрямую про нашу тему: люди приходят из Java, Python, C# и приносят с собой архитектурные привычки, которые в Go не работают. Clean Architecture и DDD — не исключение. Их часто реализуют "как в Java", а потом жалуются, что Go — многословный и неудобный язык.
Давайте разберёмся, как делать это правильно.
Как мы сюда попали?
Представьте: вы начинаете новый Go-сервис. Читаете статьи, смотрите видео, решаете "делать по-взрослому". Создаёте структуру:
internal/ domain/ application/ infrastructure/ delivery/ dto/ mappers/ interfaces/ services/
Через месяц у вас 200 файлов, пять слоёв абстракции и CreateOrderUseCase, который делает ровно одно: вызывает orderRepo.Save(). Бизнес-логики ноль. Зато интерфейсов — десять.
Знакомо? Это не Clean Architecture. Это тревожность, оформленная в папки.
Сегодня разберём, что такое DDD и Clean Architecture на самом деле, почему в Go их так часто делают неправильно, и как применять эти идеи прагматично — без оверинжиниринга.
Часть 1. Что вообще такое DDD?
Откуда всё взялось
Domain-Driven Design появился в 2003 году, когда Эрик Эванс написал книгу "Domain-Driven Design: Tackling Complexity in the Heart of Software" — Он работал с enterprise-системами и видел одну и ту же проблему: кодживёт в своём мире, а бизнес — в своём. Разработчики называют одно, менеджеры — другое, а потом все удивляются, почему система делает не то.
DDD — это моделирование предметной области. Набор практик для того, чтобы код говорил на языке бизнеса и отражал реальную предметную область.
Пять ключевых понятий DDD, которые вам нужны
1. Ubiquitous Language — единый язык
Суть: разработчики и эксперты предметной области должны использовать один и тот же язык. Не "мы говорим про entity, а они про клиента" — а одно слово для одного понятия везде: в разговорах, в документации, в коде.
Практически это означает: если менеджер говорит "подтвердить заказ" — в коде должен быть метод Confirm(), а не SetStatusConfirmed() или UpdateOrderState(). Если бухгалтер говорит "выставить счёт" — у вас должен быть Invoice, а не Bill или PaymentDocument.
Это кажется мелочью. Но когда новый разработчик читает код и понимает бизнес-логику без словаря — это и есть работающий Ubiquitous Language
2. Bounded Context — ограниченный контекст
Большие системы нельзя описать одной моделью. Понятие "клиент" в отделе продаж и в отделе поддержки — разные вещи. В продажах клиент — это лид с воронкой и статусами. В поддержке клиент — это тикет с историей обращений.
Bounded Context — это явная граница, внутри которой ваша модель последовательна и имеет смысл. За пределами этой границы та же сущность может быть другой — и это нормально.
В микросервисной архитектуре один сервис, как правило, и есть один Bounded Context. Но это не обязательно: один сервис может содержать несколько контекстов (если они слабо связаны), или один контекст может быть реализован несколькими сервисами.

3. Entity — сущность
Entity — объект, который имеет уникальную идентичность, сохраняющуюся во времени. Два объекта с одинаковыми атрибутами, но разными ID — это разные entity.
Order — entity. Даже если вы измените состав товаров или статус, это всё равно тот же самый заказ с тем же ID.
Важное свойство: entity содержит бизнес-логику, относящуюся к ней самой. Не просто данные, а данные плюс правила.
type Order struct { id string status OrderStatus items []OrderItem } // Бизнес-правило живёт в entity, а не в сервисе func (o *Order) Cancel() error { if o.status == StatusShipped { return errors.New("cannot cancel shipped order") } o.status = StatusCancelled return nil }
4. Value Object — объект-значение
Value Object — объект без идентичности. Два Value Object с одинаковыми атрибутами — одно и то же. Они неизменяемы: вы не меняете Value Object, вы создаёте новый.
type Money struct { Amount int64 // в минимальных единицах: копейки, центы Currency string } // Нет метода изменения — только создание нового 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 }
Другие примеры Value Object: адрес, координаты, диапазон дат, email, номер телефона. Всё, что определяется своими атрибутами, а не идентификатором.
5. Aggregate — агрегат
Агрегат — это кластер связанных объектов, которые обрабатываются как единица. У каждого агрегата есть Aggregate Root — корневой entity, через который происходит всё взаимодействие с кластером.
Order — Aggregate Root. OrderItem — часть агрегата. Вы никогда не меняете OrderItem напрямую — только через Order. Агрегат сам гарантирует свою консистентность.

Правило агрегатов: храните и загружайте агрегаты целиком. Транзакция должна затрагивать только один агрегат. Это — граница консистентности.
Часть 2. Clean Architecture — что это и зачем
История и идея
Clean Architecture предложил Роберт Мартин (Uncle Bob) в 2012 году, обобщив несколько похожих идей: Hexagonal Architecture Алистера Кокберна, Onion Architecture Джеффри Палермо и BCE-архитектуру Ивара Якобсона.
Все они об одном: бизнес-логика не должна зависеть от деталей реализации. База данных, HTTP-фреймворк, очереди сообщений — всё это детали. Детали меняются. Бизнес-логика должна оставаться стабильной.
Одно правило, которое важнее всего
В Clean Architecture есть Dependency Rule — правило зависимостей:
Зависимости в коде могут указывать только внутрь. Внутренние слои ничего не знают о внешних.

Что это означает на практике:
domain не импортирует ничего из вашего проекта
application знает только о domain
infrastructure знает о domain (через интерфейсы) и о внешних библиотеках
delivery знает об application, и иногда о domain для маппинга
Если у вас domain импортирует database/sql — вы нарушили правило. Если у вас HTTP-хендлер содержит SQL-запрос — вы тоже нарушили правило.
Зачем это нужно: три причины, а не абстрактная "чистота"
Тестируемость. Если доменная логика не зависит от базы данных — вы тестируете её без базы данных. Никаких test containers, никаких моков репозиториев для простых юнит-тестов. Просто вызываете метод и проверяете результат.
Замена деталей. Переехать с PostgreSQL на MongoDB или с REST на gRPC — это замена адаптера, а не переписывание бизнес-логики. В теории. На практике это работает именно тогда, когда вы честно соблюдали правило зависимостей.
Читаемость намерений. Когда бизнес-логика сосредоточена в домене, а не размазана по хендлерам и SQL-запросам — новый разработчик открывает domain/order.go и понимает, как работает заказ. Без погружения в детали инфраструктуры.
Clean Architecture & DDD
Это разные вещи, которые хорошо работают вместе.
DDD | Clean Architecture | |
Про что | Моделирование предметной области | Организация зависимостей |
Отвечает на вопрос | Как описать бизнес в коде | Как расположить слои и зависимости |
Главная идея | Ubiquitous Language, Aggregates | Dependency Rule |
Без чего работает | Без конкретной структуры папок | Без богатой доменной модели |
DDD даёт вам хорошую модель. Clean Architecture говорит, куда эту модель положить и как организовать зависимости вокруг неё.
Преимущества комбинации:
Аспект | Эффект | Метрика улучшения |
Тестируемость | Изолированное тестирование домена | +40% coverage |
Гибкость | Замена адаптеров за часы | -90% времени |
Понимание | Чёткие границы компонентов | -70% onboarding |
Часть 3. Как это выглядит в Go
Структура проекта
internal/ domain/ order.go ← агрегат, entity, value objects order_repo.go ← интерфейс репозитория (порт) errors.go ← доменные ошибки application/ create_order.go ← use case cancel_order.go infrastructure/ postgres/ order_repo.go ← реализация порта (адаптер) redis/ cache.go delivery/ http/ order_handler.go router.go config/ config.go cmd/ server/ main.go
Почему именно так:
domain — сердце. Здесь живёт всё, что описывает бизнес. Никаких сторонних импортов кроме стандартной библиотеки. application — оркестрация: собирает домен и вызывает его методы в нужном порядке. infrastructure — реализация всего, что имеет дело с внешним миром: базы данных, кеши, внешние API. delivery — точки входа: HTTP, gRPC, CLI, очереди.
Важно: интерфейс репозитория (order_repo.go) живёт в domain, а не в infrastructure. Именно это и реализует Dependency Rule — domain определяет, что ему нужно, а infrastructure реализует это. Не наоборот.
Полный пример: заказ в e-commerce
Разберём на конкретном примере, как это выглядит в живом Go-коде.
Domain: агрегат Order
// internal/domain/order.go package domain import ( "errors" "time" ) type OrderStatus string const ( StatusPending OrderStatus = "pending" StatusConfirmed OrderStatus = "confirmed" StatusCancelled OrderStatus = "cancelled" StatusShipped OrderStatus = "shipped" ) // Money — Value Object. Нет ID, неизменяем, сравнивается по значению. type Money struct { Amount int64 // всегда в минимальных единицах (копейки, центы) Currency string } func (m Money) Add(other Money) (Money, error) { if m.Currency != other.Currency { return Money{}, ErrCurrencyMismatch } return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}, nil } // OrderItem — часть агрегата, не Entity (нет самостоятельной идентичности) type OrderItem struct { ProductID string Name string Qty int UnitPrice Money } func (i OrderItem) Total() Money { return Money{ Amount: i.UnitPrice.Amount * int64(i.Qty), Currency: i.UnitPrice.Currency, } } // Order — Aggregate Root type Order struct { id string customerID string items []OrderItem status OrderStatus createdAt time.Time updatedAt time.Time } // NewOrder — фабричный метод, гарантирует создание валидного агрегата func NewOrder(id, customerID string, items []OrderItem) (*Order, error) { if id == "" { return nil, ErrInvalidOrderID } if customerID == "" { return nil, ErrInvalidCustomerID } if len(items) == 0 { return nil, ErrEmptyOrder } for _, item := range items { if item.Qty <= 0 { return nil, ErrInvalidItemQty } } now := time.Now() return &Order{ id: id, customerID: customerID, items: items, status: StatusPending, createdAt: now, updatedAt: now, }, nil } // Confirm — бизнес-операция. Правила живут здесь, а не в сервисе. func (o *Order) Confirm() error { if o.status != StatusPending { return ErrOrderAlreadyProcessed } o.status = StatusConfirmed o.updatedAt = time.Now() return nil } func (o *Order) Cancel() error { if o.status == StatusShipped { return ErrCannotCancelShipped } if o.status == StatusCancelled { return ErrOrderAlreadyCancelled } o.status = StatusCancelled o.updatedAt = time.Now() return nil } func (o *Order) TotalAmount() Money { if len(o.items) == 0 { return Money{} } total := o.items[0].Total() for _, item := range o.items[1:] { var err error total, err = total.Add(item.Total()) if err != nil { // items с разными валютами не должны попасть в один заказ — // это инвариант агрегата, гарантируем при создании panic("invariant violation: mixed currencies in order") } } return total } // Геттеры: поля приватные, доступ — только через методы func (o *Order) ID() string { return o.id } func (o *Order) CustomerID() string { return o.customerID } func (o *Order) Status() OrderStatus { return o.status } func (o *Order) Items() []OrderItem { return append([]OrderItem{}, o.items...) } func (o *Order) CreatedAt() time.Time { return o.createdAt }
Domain: доменные ошибки
// internal/domain/errors.go package domain import "errors" var ( ErrInvalidOrderID = errors.New("order id cannot be empty") ErrInvalidCustomerID = errors.New("customer id cannot be empty") ErrEmptyOrder = errors.New("order must have at least one item") ErrInvalidItemQty = errors.New("item quantity must be positive") ErrOrderAlreadyProcessed = errors.New("order is already processed") ErrCannotCancelShipped = errors.New("cannot cancel shipped order") ErrOrderAlreadyCancelled = errors.New("order is already cancelled") ErrCurrencyMismatch = errors.New("currency mismatch") )
Domain: порт репозитория
// internal/domain/order_repo.go package domain import "context" // OrderRepository — это порт (интерфейс в терминах Hexagonal Architecture). // Определяем здесь, в домене. Реализуем — в infrastructure. type OrderRepository interface { Save(ctx context.Context, order *Order) error FindByID(ctx context.Context, id string) (*Order, error) FindByCustomerID(ctx context.Context, customerID string) ([]*Order, error) }
Application: Use Case
// internal/application/create_order.go package application import ( "context" "fmt" "github.com/google/uuid" "yourapp/internal/domain" ) type CreateOrderInput struct { CustomerID string Items []domain.OrderItem } type CreateOrderOutput struct { OrderID string TotalAmount domain.Money } type CreateOrderUseCase struct { orders domain.OrderRepository // сюда можно добавить: eventPublisher, notifier, inventoryChecker — без страха } func NewCreateOrderUseCase(orders domain.OrderRepository) *CreateOrderUseCase { return &CreateOrderUseCase{orders: orders} } func (uc *CreateOrderUseCase) Execute(ctx context.Context, in CreateOrderInput) (CreateOrderOutput, error) { id := uuid.New().String() order, err := domain.NewOrder(id, in.CustomerID, in.Items) if err != nil { return CreateOrderOutput{}, fmt.Errorf("create order: %w", err) } if err := order.Confirm(); err != nil { return CreateOrderOutput{}, fmt.Errorf("confirm order: %w", err) } if err := uc.orders.Save(ctx, order); err != nil { return CreateOrderOutput{}, fmt.Errorf("save order: %w", err) } return CreateOrderOutput{ OrderID: order.ID(), TotalAmount: order.TotalAmount(), }, nil }
Infrastructure: адаптер
// internal/infrastructure/postgres/order_repo.go package postgres import ( "context" "database/sql" "fmt" "yourapp/internal/domain" ) type OrderRepository struct { db *sql.DB } func NewOrderRepository(db *sql.DB) *OrderRepository { return &OrderRepository{db: db} } func (r *OrderRepository) Save(ctx context.Context, order *domain.Order) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() _, err = tx.ExecContext(ctx, `INSERT INTO orders (id, customer_id, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET status = $3, updated_at = $5`, order.ID(), order.CustomerID(), string(order.Status()), order.CreatedAt(), order.UpdatedAt(), ) if err != nil { return fmt.Errorf("upsert order: %w", err) } // здесь же сохраняем items — агрегат сохраняется целиком for _, item := range order.Items() { _, err = tx.ExecContext(ctx, `INSERT INTO order_items (order_id, product_id, qty, unit_price, currency) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (order_id, product_id) DO NOTHING`, order.ID(), item.ProductID, item.Qty, item.UnitPrice.Amount, item.UnitPrice.Currency, ) if err != nil { return fmt.Errorf("insert order item: %w", err) } } return tx.Commit() } func (r *OrderRepository) FindByID(ctx context.Context, id string) (*domain.Order, error) { // маппинг из SQL → domain.Order // используем приватный конструктор или builder для восстановления агрегата // ... return nil, nil } func (r *OrderRepository) FindByCustomerID(ctx context.Context, customerID string) ([]*domain.Order, error) { // ... return nil, nil }
Delivery: HTTP-хендлер
// internal/delivery/http/order_handler.go package httpdelivery import ( "encoding/json" "errors" "net/http" "yourapp/internal/application" "yourapp/internal/domain" ) type OrderHandler struct { createOrder *application.CreateOrderUseCase } func NewOrderHandler(createOrder *application.CreateOrderUseCase) *OrderHandler { return &OrderHandler{createOrder: createOrder} } type createOrderRequest struct { CustomerID string `json:"customer_id"` Items []struct { ProductID string `json:"product_id"` Name string `json:"name"` Qty int `json:"qty"` PriceCents int64 `json:"price_cents"` Currency string `json:"currency"` } `json:"items"` } type createOrderResponse struct { OrderID string `json:"order_id"` TotalAmountCents int64 `json:"total_amount_cents"` Currency string `json:"currency"` } func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) { var req createOrderRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } items := make([]domain.OrderItem, len(req.Items)) for i, it := range req.Items { items[i] = domain.OrderItem{ ProductID: it.ProductID, Name: it.Name, Qty: it.Qty, UnitPrice: domain.Money{Amount: it.PriceCents, Currency: it.Currency}, } } out, err := h.createOrder.Execute(r.Context(), application.CreateOrderInput{ CustomerID: req.CustomerID, Items: items, }) if err != nil { // маппинг доменных ошибок в HTTP-статусы switch { case errors.Is(err, domain.ErrEmptyOrder), errors.Is(err, domain.ErrInvalidItemQty): http.Error(w, err.Error(), http.StatusBadRequest) default: http.Error(w, "internal error", http.StatusInternalServerError) } return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(createOrderResponse{ OrderID: out.OrderID, TotalAmountCents: out.TotalAmount.Amount, Currency: out.TotalAmount.Currency, }) }
Сборка в main.go — никакой магии
// cmd/server/main.go package main import ( "database/sql" "log" "net/http" _ "github.com/lib/pq" "yourapp/internal/application" httpdelivery "yourapp/internal/delivery/http" "yourapp/internal/infrastructure/postgres" ) func main() { db, err := sql.Open("postgres", "postgres://...") if err != nil { log.Fatal(err) } defer db.Close() // Сборка: каждая зависимость явная orderRepo := postgres.NewOrderRepository(db) createOrderUC := application.NewCreateOrderUseCase(orderRepo) orderHandler := httpdelivery.NewOrderHandler(createOrderUC) mux := http.NewServeMux() mux.HandleFunc("POST /orders", orderHandler.Create) log.Println("listening on :8080") log.Fatal(http.ListenAndServe(":8080", mux)) }

Часть 4. Антипаттерны — что делают неправильно
Интерфейсы "на всякий случай"
// Это бессмысленно, если реализация одна type OrderServiceInterface interface { Create(dto CreateOrderDTO) error Update(dto UpdateOrderDTO) error Delete(id string) error } type OrderService struct{} func (s *OrderService) Create(dto CreateOrderDTO) error { ... }
В Go интерфейсы — неявные. Их нужно определять там, где они потребляются, а не там, где они реализуются. Если у вашего сервиса одна реализация и нет планов делать вторую — интерфейс не нужен.
// Правильно: если нужно тестировать UseCase без реальной БД, // интерфейс определяется рядом с UseCase, в домене type OrderRepository interface { Save(ctx context.Context, order *Order) error } // А не рядом с Postgres-реализацией
Анемичная доменная модель
// Плохо: Order — просто мешок с данными type Order struct { ID string Status string Items []Item } // Логика вынесена в сервис — это антипаттерн DDD func (s *OrderService) Confirm(order *Order) error { if len(order.Items) == 0 { return errors.New("empty") } order.Status = "confirmed" return nil }
Когда вся логика в сервисах, а объекты — это только данные, вы получаете процедурный код с красивыми названиями классов. Анемичная модель — главный враг DDD.
// Хорошо: логика — часть объекта func (o *Order) Confirm() error { if len(o.items) == 0 { return domain.ErrEmptyOrder } if o.status != StatusPending { return domain.ErrOrderAlreadyProcessed } o.status = StatusConfirmed return nil }
Бизнес-логика в хендлере
// Плохо: хендлер принимает бизнес-решения func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) { var req Request json.NewDecoder(r.Body).Decode(&req) // Это не должно быть здесь if len(req.Items) == 0 { http.Error(w, "empty order", 400) return } if req.TotalAmount > 100_000_00 { http.Error(w, "amount too large", 400) return } if req.CustomerID == "banned_customer" { http.Error(w, "forbidden", 403) return } // ... }
Хендлер должен заниматься только двумя вещами: извлекать данные из HTTP-запроса и отдавать HTTP-ответ. Всё остальное — в домен или в UseCase.
"Четыре слоя в CRUD-сервисе"
Если у вашего сервиса три эндпоинта и вся "логика" — это SELECT * FROM users WHERE id = $1, то GetUserUseCase с торжественным именем — это церемония ради церемонии.
// Для простого CRUD это нормально func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") user, err := h.repo.FindByID(r.Context(), id) if err != nil { http.Error(w, "not found", 404) return } json.NewEncoder(w).Encode(user) }
Три строки, один файл. Никакого UseCase-слоя. Добавите его, когда появится реальная логика.
Часть 5. Где упростить — и не надо стесняться
Есть три ситуации, когда полная Clean Architecture избыточна.
Ситуация 1: Нет бизнес-логики. Если ваш сервис — это прокси к базе данных (создать / получить / обновить / удалить без правил), UseCase-слой ничего не даёт. Хендлер → репозиторий. Добавите оркестрацию, когда она появится.
Ситуация 2: Маленький сервис. До 10 эндпоинтов и 5 доменных объектов — слои добавляют больше сложности, чем снимают. Начните с плоской структуры и рефакторьте по мере роста.
Ситуация 3: MVP и прототипы. Скорость важнее структуры. Сначала докажите, что идея работает, потом приводите в порядок архитектуру.
Главное правило: начинайте с домена, а не со слоёв. Сначала ответьте на вопрос "Что такое Order? Какие у него правила?" Потом думайте о слоях.
Вывод
DDD и Clean Architecture решают разные проблемы и хорошо работают вместе — но только если вы понимаете, зачем они нужны.
DDD — это про смысл. Ваш код должен говорить на языке бизнеса. Если читая Order.Confirm() вы понимаете, что происходит в бизнесе — DDD работает. Если читая OrderService.SetStatusConfirmed() вы не понимаете, при каких условиях это вызывается — DDD нет.
Clean Architecture — это про зависимости. Одно правило: внешнее зависит от внутреннего, не наоборот. База данных знает про домен. Домен не знает про базу данных. Это и есть суть.
Go — это про простоту. Go не любит неявную магию, не любит глубокие иерархии классов, не любит интерфейсы ради интерфейсов. Хорошая Go-архитектура — это та, где зависимости явные, модули маленькие, а код читается сверху вниз без прыжков по десяти файлам.
Начните с хорошей доменной модели. Добавляйте слои только когда они действительно требуются. И помните: цель архитектуры — не красивая структура папок, а код, который легко читать, легко тестировать и легко менять.