Domain-Driven Design в Go: Как не утонуть в слоях абстракции

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему ваш Go-проект просит DDD?

Вы когда-нибудь писали на Go сервис, который начинался с пары эндпоинтов и трех таблиц в базе, а через полгода превращался в монстра с километровыми файлами handler.go и магическими числами, разбросанными по всему коду? Знакомо. Я тоже через это прошел.

Проблема классической Go-разработки — мы слишком рано думаем о базах данных и JSON, забывая о главном: бизнес-логике. Domain-Driven Design (DDD) приходит на помощь именно тогда, когда хочется навести порядок в хаосе. Но как приземлить DDD на Go, если язык не балует нас классами и наследованием? Давайте разбираться.

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

Что такое DDD и почему он нужен именно Go-разработчику?

Domain-Driven Design — это не про технологии. Это про язык. Про то, чтобы код говорил на языке бизнеса. Если ваша предметная область — интернет-магазин, в коде должны быть Order, Product, Cart, а не DataTransferObject и EntityManager.

Go, с его простотой и отсутствием магии, идеально подходит для DDD. Здесь нет прокси-классов и рефлексии, которые скрывают суть. В Go вы вынуждены быть честными: если у вас есть агрегат — это явная структура, если репозиторий — это интерфейс.

Основные строительные блоки DDD, которые мы рассмотрим:

  • Entity (Сущность) — объект с уникальным идентификатором (например, User).
  • Value Object (Объект-значение) — неизменяемый объект, описывающий характеристики (например, Address).
  • Aggregate (Агрегат) — кластер сущностей, гарантирующий консистентность (например, Order с OrderItem).
  • Repository (Репозиторий) — коллекция объектов, скрывающая детали хранения.
  • Domain Service (Доменный сервис) — операция, которая не вписывается в одну сущность.

Теперь давайте переведем это на Go.

Структура проекта: как не заблудиться в папках

Хорошая новость: Go не навязывает никакой структуры. Плохая новость: без структуры вы быстро утонете. Для DDD в Go я рекомендую следующую организацию кода:

your-project/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── domain/          # Чистое ядро: сущности, правила
│   │   ├── order.go
│   │   ├── product.go
│   │   └── errors.go
│   ├── application/     # Use cases / сервисы приложения
│   │   ├── order_service.go
│   │   └── product_service.go
│   └── infrastructure/  # Базы, внешние API, HTTP-хендлеры
│       ├── postgres/
│       │   └── order_repository.go
│       └── http/
│           └── handler.go
└── go.mod

Обратите внимание: domain не импортирует infrastructure. Это золотое правило DDD — домен ничего не знает о базах и HTTP. Он живет в вакууме чистых структур и интерфейсов.

Пишем домен: Order и Value Objects

Начнем с ядра. Допустим, мы строим систему управления заказами. Вот как может выглядеть сущность Order:

// internal/domain/order.go
package domain

import (
    "errors"
    "time"
)

type OrderStatus string

const (
    OrderStatusPending   OrderStatus = "pending"
    OrderStatusConfirmed OrderStatus = "confirmed"
    OrderStatusCancelled OrderStatus = "cancelled"
)

type OrderID string

type Order struct {
    ID         OrderID
    CustomerID string
    Items      []OrderItem
    Status     OrderStatus
    CreatedAt  time.Time
    UpdatedAt  time.Time
}

// OrderItem — Value Object (не имеет своего ID)
type OrderItem struct {
    ProductID string
    Quantity  int
    Price     Money
}

// Money — еще один Value Object
type Money struct {
    Amount   float64
    Currency string
}

// Бизнес-правило: нельзя подтвердить отмененный заказ
func (o *Order) Confirm() error {
    if o.Status == OrderStatusCancelled {
        return errors.New("cannot confirm cancelled order")
    }
    o.Status = OrderStatusConfirmed
    o.UpdatedAt = time.Now()
    return nil
}

func (o *Order) Cancel() error {
    if o.Status == OrderStatusConfirmed {
        return errors.New("cannot cancel confirmed order")
    }
    o.Status = OrderStatusCancelled
    o.UpdatedAt = time.Now()
    return nil
}

Видите? Здесь нет ORM, нет JSON-тегов. Это чистый домен. Методы Confirm() и Cancel() содержат бизнес-правила. Если вы измените базу данных, этот код не пострадает.

Репозиторий: интерфейс и реализация

Чтобы отделить домен от базы, используем интерфейс репозитория. Он живет в пакете domain, а реализация — в infrastructure:

// internal/domain/order_repository.go
package domain

import "context"

type OrderRepository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id OrderID) (*Order, error)
    FindByCustomer(ctx context.Context, customerID string) ([]*Order, error)
    Delete(ctx context.Context, id OrderID) error
}

А вот реализация для PostgreSQL:

// internal/infrastructure/postgres/order_repository.go
package postgres

import (
    "context"
    "database/sql"
    "your-project/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 {
    // Здесь мог быть ваш SQL-запрос
    // Но главное — мы маппим доменную модель в таблицы
    return nil
}

func (r *OrderRepository) FindByID(ctx context.Context, id domain.OrderID) (*domain.Order, error) {
    // Аналогично
    return nil, nil
}

Теперь, если мы захотим перейти на MongoDB, мы просто пишем новый репозиторий. Домен не узнает об этом.

Сервис приложения: соединяем всё вместе

Domain-Driven Design в Go проектах требует тонкого слоя между хендлерами и доменом. Это сервисы приложения (use cases). Они координируют работу репозиториев и доменных сервисов, управляют транзакциями:

// internal/application/order_service.go
package application

import (
    "context"
    "your-project/internal/domain"
)

type OrderService struct {
    repo domain.OrderRepository
}

func NewOrderService(repo domain.OrderRepository) *                

Блоги

Рекомендаций по книгам