Введение: Почему ваш 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) *