golang

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

  • воскресенье, 19 апреля 2026 г. в 00:00:13
https://habr.com/ru/articles/1025068/

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

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

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

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