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

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

По данным исследования 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-архитектура — это та, где зависимости явные, модули маленькие, а код читается сверху вниз без прыжков по десяти файлам.

Начните с хорошей доменной модели. Добавляйте слои только когда они действительно требуются. И помните: цель архитектуры — не красивая структура папок, а код, который легко читать, легко тестировать и легко менять.

Комментарии (42)


  1. Mauya_Apps_CEO
    18.04.2026 17:16

    Как человеку, который изучает Go, очень полезно на будущее, оставлю в избранное


  1. p0is0n
    18.04.2026 17:16

    А в чем проблема если в го у меня много файлов? Вы предлагаете решить ее запихав агрегаты, vo и сущности в один файл? Что будет там через год? Даже у вас в статье уже начинает пахнуть мясом.

    Основная суть всего ддд это легко читаемый код, а особенно бизнес логика.


  1. Dhwtj
    18.04.2026 17:16

    Go плох для DDD, даже не пытайтесь

    Scala/F# > Rust > C#/Java > PHP 8+ > Go я бы так сказал


    1. Dhwtj
      18.04.2026 17:16

      Что ему не хватает для нормальной работы с ддд:

      • Sum types (ADT) с exhaustive match - моделирование состояний и команд

      • Иммутабельность и неизменяемые поля - VO без дисциплины не сделать

      • Конструкторы-инварианты - zero value ломает защиту

      • Generics нормальные - есть, но слабые (нет ограничений на методы со своим типом, нет higher-kinded)

      • Обобщённый Result/Option и монадическая композиция - if err != nil вместо цепочек

      • Настоящие enum вместо const iota

      • Перегрузка операторов или хотя бы трейты/интерфейсы с операторами - money.Add вместо +

      • Pattern matching с деструктуризацией

      • Non-nullable типы по умолчанию - любые ссылки и интерфейсы могут быть nil

      • Readonly коллекции - срезы и мапы всегда мутабельны

      • Private поля с фабрикой в том же пакете - есть, но нет per-type инкапсуляции внутри пакета


      1. AlexSpaizNet
        18.04.2026 17:16

        ДДД это все нахер не нужно... этого нету во многих языках. А там где все это есть - там не пишут ддд...


        1. Dhwtj
          18.04.2026 17:16

          Во многих случаях это, действительно, не нужно.

          Но обсуждается случай когда это всё-таки нужно


    1. Enfriz
      18.04.2026 17:16

      C# самый DDDшный язык, там очень много средств, которые будто специально сделаны для чистой архитектуры и развитого ООП-моделирования (тот же уровень доступа internalили всякие ковариантные/контрвариантные дженерики).

      Что касается Go, то определённая дисциплина от разработчика будет нужна, конечно. Но не сказал бы, что прям принципиально невозможно, и, если берёшь Go, нужна обязательно анемичная процедурщина с высоким зацеплением.


      1. Dhwtj
        18.04.2026 17:16

        Справедливо для классического/устаревшего ООП-DDD (Эванс, синяя книга): богатая иерархия, internal для bounded context внутри сборки, вариативность для репозиториев и т.п. Тут C# реально один из лучших.

        Но современный DDD (Влашин, “Domain Modeling Made Functional”) про другое: сделать невалидные состояния непредставимыми через типы. Здесь Rust сильнее: Sum types, new type pattern, Result<T, E> и ? делают ошибки домена частью сигнатуры, а не исключениями мимо типов


        1. Enfriz
          18.04.2026 17:16

          Ну это какое-то зумерское изобретение вы описываете. "Нео-DDD" для разработки в функциональном стиле. Вот у Ханонова книга 2022 года по DDD вполне опирается на Эванса.


          1. Dhwtj
            18.04.2026 17:16

            Вылезайте уже из легаси, наконец!

            Вы молитесь на инкапсуляцию и «защиту инвариантов внутри класса» как на священный грааль, но по факту вы просто строите крепость из костылей в ООП, который не даёт хороших гарантий. В классическом ООП-DDD по Ханонову ваш «чистый домен» это заложник рантайма. Вы надеетесь, что программист не забудет вызвать Validate(), не пропустит null и не поймает InvalidOperationException в самый неподходящий момент

            Ну и мне 50 лет. Спасибо за зумера, бггг


          1. Dhwtj
            18.04.2026 17:16

            Ну раз вы перешли на личности то отвечу:

            • вы сильно переоцениваете свои софтскилы, что для тимлида плохо

            • архитектурные и технологические знания у вас устаревают, рекомендую всё же прочитать про make illegal states impossible и современный DDD хотя бы в кратком изложении Беспоясова https://bespoyasov.ru/blog/domain-modelling-made-functional/


    1. SerafimArts
      18.04.2026 17:16

      А почему PHP на предпоследнем месте? Это вроде дефолтный подход в PHP при использовании Symfony.

      Я бы C#/Java/PHP вообще на первое место поставил, самые выразительные языки под это всё дело.


      1. Dhwtj
        18.04.2026 17:16

        Мой уточнённый порядок пригодности языка для DDD (писать можно на всём, но справа больше боли):

        Scala/F# > Rust > TS >= C#/Java > PHP 8+ > Go > JS

        Про PHP+symphony. Коротко: язык исторически хуже подходит по удобству моделирования домена, хоть Symfony и даёт архитектурную дисциплину

        1. Слабая и поздняя статическая типизация, нет ADT/exhaustiveness — сложнее гарантировать инварианты до выполнения

        2. Мутабельность и неоднозначная семантика объектов/копирования затрудняют безопасные VO и агрегаты.

        3. Большой пласт legacy и императивных практик — требует строгой дисциплины и внешних инструментов (PHPStan/PSalm), чтобы DDD работал.

        TS примерно на уровне C#/Java, может чуть выше за счёт structural typing и discriminated unions из коробки (type Result = Success | Failure работает естественно).

        JS - ниже Go. Без типов DDD превращается в боль: value objects, агрегаты, инварианты держать не на чем.

        TS выше Rust не ставлю - в Rust система типов сильнее (exhaustiveness, ownership помогает моделировать жизненный цикл агрегата), но Rust заставляет думать про память там, где для DDD это шум.

        Ну и, кстати, node.js + TS неплохой вариант для DDD. Rust, Scala, F# редко встречается в живой природе


        1. Enfriz
          18.04.2026 17:16

          TS >= C#/Java

          Транспилятор над однопоточным языком, у которого в базе вообще нет адекватного ООП, выше, чем хардкорные ООП энтерпрайз языки.

          В целом понятно, вы описываете пригодность для какого-то любимого вами определённого вида переосмысления DDD, но статья всё-таки про классический.


          1. wert_lex
            18.04.2026 17:16

            Зря вы так про TS. В плане выразительности он на две головы выше Java/C#.

            Строгая структурная типизация и всякие ништяки на типах ставят на колени традиционные языки с номинальной типизацией.

            Однопоточность - да, тут не поспоришь.


            1. Dhwtj
              18.04.2026 17:16

              Да он просто цитату вырвал из контекста. Я про языки применительно к ддд. А он про какой-то свой рейтинг.


  1. p0is0n
    18.04.2026 17:16

    Добавлю про интерфейсы: простое правило, интерфейс нужен не для «нескольких возможных реализаций», а для абстракции без которой невозможно организовать зависимости между слоями.

    Репы в слой домена, фабрика в слой апп (например если у нас сущности зависят от инфраструктуры, и описываются также интерфейсами).

    Также я не вижу проблемы если сущность анемична, делайте бизнес логику в handler/command прямо в домене, какая в этом проблема?


  1. Enfriz
    18.04.2026 17:16

    В целом всё по делу, но я бы сказал, что у вас сейчас юзкейс почти буквально делает только orders.Save. В моей практике на уровне Application всегда объявляли DTO для запроса и ответа. Соответственно на вход в юзкейс приходит десериализованный createOrderRequest, а все маппинги и вызовы фабрик как раз в юзкейсе.

    Сейчас хэндлер толстоват, имхо.


  1. Lewigh
    18.04.2026 17:16

    Забавная ирония судьбы.

    При разработке Go авторы сделали все чтобы язык был максимально прямолинейным и простым, местами до тупости. Не все получилось просто но зато получилось сделать язык с максимально коротким сроком обучения. Что позволили ему быстро набрать популярность за счет переучивающихся разработчиков с других языков.

    Но вот произошла типичная история про мигрантов - бежим из страны в которой невозможно жить чтобы в новой стране воссоздать ту страну из которой сбежали.

    Так же по факту произошло и с Go. Сбежавшие джависты и еже с ними, уставшие от задравшего всех многословного переабстрагированного энтерпрайз подхода, принялись в Go строить заново все то от чего убегали.

    По итогу Go, где пожертвовали всем ради простоты, с таким подходом превращается в кусок говна. Так как не остается ни простоты ни удобства.


    1. whoisking
      18.04.2026 17:16

      А это везде так куда джависты приходят, как-то приходилось видеть проекты на питоне после джавистов...


      1. Dhwtj
        18.04.2026 17:16

        Я пришёл из C# Rust в PHP легаси (правда, на больше на архитектора чем программиста). В результате, написал свой мини ОРМ :facepalm:

        Data mapper вроде dapper/SQLx + UoW (точнее, транзакционна обёртка)

        сознательно отказались от identity map/автотрекинга

        Doctrine и пара других не подошли: Active Record плохо ложится на DDD, остальные слишком лезут в домен и управление коннекшеном. А мне нужно было сохранить огромный легаси read side как есть, поэтому написал минимальный маппер.


        1. whoisking
          18.04.2026 17:16

          Через сколько ушёл и оставил ребят с этой самописной орм?


          1. Dhwtj
            18.04.2026 17:16

            Ещё не ушёл

            И все довольны: сильные программисты понимают, остальным и не надо туда лезть


            1. whoisking
              18.04.2026 17:16

              А как давно написал? Когда планируешь уйти?


              1. Dhwtj
                18.04.2026 17:16

                Тут долгая история...

                Пришёл в проект 2 года назад. Проект - 15 летние госы, 100kloc страшной лапши, система ответственная, качество в приоритете (смех в зале). Изначально я пришёл (перешёл с другого проекта и другого стека) при условии что я архитектор (то есть свободен в архитектурных решениях, но код писать самому), а на рефакторинг будет 30% ресурсов. Первый год их не дали, кстати. И ресурсов на было и не разобрались ещё как оно должно работать и инцидентов много. Потом с моей стороны было несколько попыток. Первая серия пошла в корзину. Археология, восстановление замысла, создание домена - пока одного, но со сложными безумными инвариантами и хитрым агрегатом который цепляет тысяч 30 строк кода. 15-20.000 строк кода в сумме удалено, столько же добавлено. Это если не считать создания тестов. Страшные merge request на +10kloc, -10kloc, остальные чуть меньше

                Характеризационные тесты. Меньше mr не получается, риски понимаю. ADR , глоссарий и описание схемы веду, но никто не читает.

                30.000 строк ... Это просто объем модулей которые читают или пишут в эти таблицы. В итоге read side просто не стал трогать и реальное число меньше

                И это всё без изменения функционала, только для надёжности.

                Уходить не собираюсь пока на рынке труда не потеплеет


    1. Dhwtj
      18.04.2026 17:16

      Просто Go не для этих задач. Он для сетевых утилит ака микросервисов


    1. Enfriz
      18.04.2026 17:16

      Так это не в мигрантах дело. Просто бизесы стали тащить Го в энтерпрайз со сложным доменом, ну потому что все ведь пишут на Го, значит и нам надо. И Авито переходит с C# на Go, переписывая тот бизнес, для которого был выбран C#. Результат понятен, но это же не разработчики решили так делать.


      1. Lewigh
        18.04.2026 17:16

        А кто решает если не разработчики? Бизнес приходит и говорит: "используете обязательно DDD"? Да бизнес это вообще не волнует. На Go можно писать хоть легкий домен хоть сложный домен хоть Kubernetes, если руки из правильного места.

        Проблема в том что в языке не успела сформироваться культура написания таких проектов а "мигранты" по больше части ничего кроме ООП, слоистых архитектур, чистых кодом, интерфейса на каждый чих и прочего мракобесия не видели и не умеют, вот они и начинают строить джаву в абсолютно другом языке, с другими свойствами, плюсами и минусами, закономерно получая кусок говна. Потому что в Go нужно вывозить за счет минимализма который язык дает и тупой как доска простоты и банальности. А на деле, подумаешь в Go нет классического ООП - будем изображать накручивая костыли, подумаешь в Go нет средств упрощения абстракций - конечно же будем городить 10 слойные архитектуры, писать кучу бойлерплейта там где его нечем победить, подумаешь в Go совершенно другой механизм как работы так и философии интерфейсов - пофиг будем делать как в Java.

        Не в бизнесе это дело а в людях который пришли со своим уставом в чужой монастырь. Такая же ровно история когда такие уходят в JS/TS или например в Rust.


  1. TarkWight
    18.04.2026 17:16

    "Статья", написанная LLM. Ответы некоторых пользователей тоже. Тяжело коммуницировать и, тем более, верить людям, которые для формирования и\или выражения собственных мыслей используют генератор буков. Сам факт генерации всего кода\слов ценность текста опускает чуть ли не до плинтуса. Потому что я пришёл сюда статью читать, а не полотно генерации, которое сверху фактчекать надо.


    1. Dhwtj
      18.04.2026 17:16

      Если вы про меня, то я сверяюсь со справочником. Трудно ориентироваться в десятке языков и множеству терминов. Но в целом всё указанное пробовал на практике.


  1. amcured
    18.04.2026 17:16

    ① Во всех без исключения примерах «как правильно» — гонки данных, это всё развалится при минимальной нагрузке.

    ② Единственный осмысленный вариант тестов для логики вокруг БД — это стресс-тест (всё остальное можно написать правильно с закрытыми глазами, а тут возникают те же гонки, плюс соединения могут закончиться); тестировать юнитами интерфейсы типа «Save» — это для второго класса средней школы.

    ③ Без конечного автомата (нормального, а не неподконтрольного коду набора состояний) — такую задачу решать — чистой воды вредительство.

    ④ Забудьте уже про Мартина, он — очень посредственный разработчик с тупыми обобщениями, никогда не выдерживающими минимальную практику.


    1. blackyblack
      18.04.2026 17:16

      Да, вот интересно, как автор планировал синхронизировать запись в БД и вызов Order.Confirm()


      1. amcured
        18.04.2026 17:16

        Никак не планировал, вестимо. Оне умные книжки читают, им не до эмпирики на живых проектах. Мартин ровно такой же.


  1. pkokoshnikov
    18.04.2026 17:16

    Нет разбора кейса где взаимодействует несколько aggregate root. Не всегда бизнес кейсы ложатся в один aggregate root. Иначе довольно простой кейс перевод денег превращает в корень все счета. Мы обычно используем доменные сервисы для таких кейсов в пакете domain. В целом описано хорошо в Learning Domain-Driven Design.


  1. Katasonov
    18.04.2026 17:16

    Опять нейрослоп. Жаль, а тема интересная.


  1. yegreS
    18.04.2026 17:16

    Кажется метод Save хорошо бы убрать из Order и в usecase явно вызывать orderRepo.Save(ctx, order)

    я раньше тоже описывал интерфейсы в пакете domain, но потом пришел к мысли, что это не по Гошному. И гораздо правильнее делать это в usecase, потому-что это позволяет минимизировать интерфейс. И в итоге получаются отлично изолированные сценарии, которые очень удобно разрабатывать и тестировать изолировано

    И на моем опыте агрегаты в Go это боль, поэтому я всегда стараюсь их избегать


    1. Dhwtj
      18.04.2026 17:16

      А если в го нет (нормального способа реализации) агрегатов (и нет parse don’t validate), то в го нет и (нормального способа реализации) ддд

      О чем и говорю


    1. Enfriz
      18.04.2026 17:16

      На C# тоже всегда делал интерфейсы в слое приложения.


  1. titan_pc
    18.04.2026 17:16

    "Никакого UseCase-слоя. Добавите его, когда появится реальная логика." - Это антипаттерн Clean Architect.

    Ваш бизнес сегодня действительно использует Ваш сервис как прокси. А завтра у него возникла идея, что логика всё же должна быть. От банальных проверок до жанглирования моделью данных через кучу адаптеров.

    Даже если у вас сейчас dal.save в логике. Завтра это уже не так. Задача юзкейса изолировать не только бизнеслогику от деталей. Но и адаптеры от хендлеров. Это даёт коду устойчивость к изменениям.

    То есть изолировать детали друг от друга тоже нужно. Хендлеры максимум понимают сигнатуру адаптеров (и то есть примеры, когда и это в DI заворачивают, что я бы назвал избыточным, но там дело вкуса). И просто доставляют их в юзкейс. То есть доставляют в логику то, чем она должна оперировать.

    Если хендлер сразу пошёл в адаптер - это значит, что хендлер обязан знать как с ним работать. И вы создали одну деталь. Это больше не хендлер и адаптер - а обычный код. Который потом придётся рефакторить. Потом настен через день два.

    Чистая архитектура это не просто разделение кода по папкам. А про разделение ответсвенности и границ кода в этих папках. Даже если одна строчка в логике - значит такова логика на момент сейчас. Но она есть)


  1. Sitro23
    18.04.2026 17:16

    На крошечные проектах всё понятно, но на остальных появляется много вопросов


  1. fredrsf
    18.04.2026 17:16

    Все статьи такого рода показывают очень простые примеры, которых на практике не бывает. Как только вам понадобится транзакция с логикой до/между/после вызовов нескольких репозиториев эта архитектура ломается. Появляются проброс транзакции через контекст, транзакшн менеджеры, юнит оф ворки и прочая ересь которых в домене в реальности не существуют да и clean уже такое назвать язык не поворачивается.


  1. DasMeister
    18.04.2026 17:16

    Впечатляет, как и всегда в статьях по DDD несколько вещей.

    1. ValueObject с публичными полями которые можно мутировать. Метод Add конечно здорово, но это не имеет значения - объект не иммутабелен, как бы в этом не убеждал комментарий к коду.
    2. Отсутствие валидации валюты в запросе (упрощение? удобная позиция).
    3. Очередной энумератор статуса - который из строки преобразуется в строку. Хотя можно реализовать методы поддерживаемые интерфейсами библиотеки sql. А еще можно и сгенерировать сразу (благо тулов для генерации - вагон), но удобнее ясность поддерживать (прямым приведением к строке). Так чтобы на стадии сериализации/десериализации гарантированно получать не консистентные состояния.
    4. Вишенка на торте обновить заказу статус. Полчаса назад отменили или ЗАВЕРШИЛИ. Ничего страшного - вернем к базовому статусу (а кто тут гарантирует, что снаружи не прилетит повтор того же заказа?).
    5. Классический defer на методе с ошибкой Rollback'a, там ведь никогда не будет ничего интересного. А потом это недиагностируемая история, при изменении кода.
    6. Неправильно работаем с ошибками - возвращаем ошибки из чужих модулей, коснтруируем статические.

    Одна из прекрасных особенностей "архитекторов", забывать о деталях. В итоге ни чистой архитектуры, ни чистого кода.