Почему DDD — это не про скучные диаграммы, а про живую архитектуру
Когда проект на C++ перерастает размеры «игрушечного» приложения, разработчики часто сталкиваются с хаосом: бизнес-логика размазана по контроллерам, база данных диктует структуру классов, а новички боятся трогать legacy-код. Domain-Driven Design (DDD) — это не набор абстрактных правил, а практический инструмент, который помогает обуздать сложность. В этой статье мы разберём, как внедрить DDD в C++, используя конкретные примеры кода, и почему это спасёт ваш проект от «большого комка грязи».
Что такое DDD и зачем он C++ разработчику?
Domain-Driven Design (предметно-ориентированное проектирование) — это подход, где основное внимание уделяется модели предметной области, а не техническим деталям. В C++ это особенно актуально: язык даёт мощные инструменты (шаблоны, RAII, умные указатели), но без правильной архитектуры проект превращается в монолит, где изменение одной фичи ломает всё.
DDD предлагает два уровня:
- Стратегическое моделирование: разделение системы на контексты (Bounded Contexts) и настройка их взаимодействия.
- Тактическое моделирование: строительные блоки — Entity, Value Object, Aggregate, Repository, Domain Service.
Мы сосредоточимся на тактическом моделировании, так как именно оно даёт конкретные паттерны для кода на C++.
Слоистая архитектура: фундамент DDD в C++
DDD предполагает чёткое разделение на слои. Типичная структура для C++ проекта выглядит так:
- Domain Layer (ядро): сущности, value object, агрегаты, доменные сервисы. Никаких зависимостей от фреймворков или внешних библиотек.
- Application Layer: сценарии использования (use cases), оркестрация. Зависит только от Domain.
- Infrastructure Layer: реализация репозиториев (SQL, Redis, файлы), работа с внешними API. Реализует интерфейсы из Domain.
- Presentation Layer: пользовательский интерфейс (консоль, REST API, GUI).
Это не жёсткое правило, а рекомендация. Главное — зависимости направлены внутрь: внешние слои могут зависеть от внутренних, но не наоборот.
Пример: как не надо делать
// ❌ Плохо: бизнес-логика смешана с SQL
class UserController {
void createUser(const std::string& name) {
// SQL запрос прямо здесь
db.execute("INSERT INTO users (name) VALUES (?)", name);
// Логика проверки тоже здесь
if (name.empty()) throw std::runtime_error("Name required");
}
};
Пример: DDD-стиль
// ✅ Хорошо: чёткое разделение
class User : public Entity<User, int> {
std::string name_;
public:
explicit User(int id, std::string name) : Entity(id), name_(std::move(name)) {
if (name_.empty()) throw std::invalid_argument("User name cannot be empty");
}
const std::string& name() const { return name_; }
};
// Интерфейс репозитория (Domain)
class IUserRepository {
public:
virtual ~IUserRepository() = default;
virtual void save(const User& user) = 0;
};
// Реализация (Infrastructure)
class SqlUserRepository : public IUserRepository {
void save(const User& user) override {
// Только SQL, без бизнес-логики
}
};
Строительные блоки DDD на C++
Entity (Сущность)
Это объект с уникальным идентификатором, который сохраняется во времени. В C++ удобно сделать базовый шаблонный класс:
template<typename T, typename IdType>
class Entity {
IdType id_;
public:
explicit Entity(IdType id) : id_(std::move(id)) {}
IdType id() const { return id_; }
bool operator==(const Entity& other) const { return id_ == other.id_; }
bool operator!=(const Entity& other) const { return !(*this == other); }
};
class Order : public Entity<Order, std::string> {
// Бизнес-логика заказа
};
Value Object (Объект-значение)
Неизменяемые объекты, которые определяются своими атрибутами, а не идентификатором. Классический пример — деньги:
class Money {
long long cents_; // Всегда в копейках, чтобы избежать проблем с float
public:
Money(long long cents) : cents_(cents) {}
Money operator+(const Money& other) const {
return Money(cents_ + other.cents_);
}
bool operator==(const Money& other) const {
return cents_ == other.cents_;
}
// Без сеттеров — только конструирование
};
Совет: используйте constexpr и noexcept где возможно — это улучшит производительность и безопасность.
Aggregate (Агрегат)
Группа сущностей и value object, которые рассматриваются как единое целое. У агрегата есть корень (Aggregate Root), через который происходит доступ к внутренним объектам.
class OrderAggregate {
std::string orderId_;
std::vector<OrderLine> lines_; // Value Object
Money total_;
public:
void addLine(const OrderLine& line) {
// Бизнес-правило: нельзя добавить товар с нулевой ценой
if (line.price() == Money(0)) {
throw std::invalid_argument("Price must be positive");
}
lines_.push_back(line);
total_ = total_ + line.totalPrice();
}
// Никаких геттеров для lines_ — только методы, выражающие бизнес-намерения
};
Repository (Репозиторий) и Domain Service
Репозиторий — это коллекция объектов для доступа к данным, но с точки зрения предметной области. Не путайте с DAO (Data Access Object) — репозиторий оперирует доменными объектами, а не таблицами.
// Интерфейс репозитория (Domain)
class IOrderRepository {
public:
virtual std::optional<OrderAggregate> findById(const std::string& id) = 0;
virtual void save(const OrderAggregate& order) = 0;
};
// Domain Service — когда логика не помещается в один агрегат
class PricingService {
public:
Money calculateDiscount(const OrderAggregate& order, const Customer& customer) {
// Бизнес-правила скидок
if (customer.isPremium() && order.total() > Money(10000)) {
return Money(order.total().cents() * 10 / 100); // 10% скидка
}
return Money(0);
}
};
Реальный пример: система управления заказами
Соберём всё вместе. Представьте, что мы пишем микросервис для интернет-магазина на C++.
Шаг 1. Определяем Bounded Context
У нас есть контекст «Заказы» и контекст «Каталог товаров». Мы сосредоточимся на первом.
Шаг 2. Моделируем ядро
// Value