Немного цифр, прежде чем начать
Прежде чем погружаться в архитектуру, давайте посмотрим на контекст, в котором всё это происходит.
По данным исследования 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.gocmd/ 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.gopackage domainimport ( "errors" "time")type OrderStatus stringconst ( 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 Roottype 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.gopackage domainimport "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.gopackage domainimport "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.gopackage applicationimport ( "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.gopackage postgresimport ( "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.gopackage httpdeliveryimport ( "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.gopackage mainimport ( "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}// Логика вынесена в сервис — это антипаттерн DDDfunc (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-архитектура — это та, где зависимости явные, модули маленькие, а код читается сверху вниз без прыжков по десяти файлам.
Начните с хорошей доменной модели. Добавляйте слои только когда они действительно требуются. И помните: цель архитектуры — не красивая структура папок, а код, который легко читать, легко тестировать и легко менять.
ссылка на оригинал статьи https://habr.com/ru/articles/1025068/