Clean Architecture + DDD в Go: как не превратить проект в 200 файлов ни о чём

от автора

Немного цифр, прежде чем начать

Прежде чем погружаться в архитектуру, давайте посмотрим на контекст, в котором всё это происходит.

По данным исследования 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/