Привет, Хабр!
Данная статья посвящена описанию реализации учебного проекта. Проект является С++ реализацией сервиса по распределению позиций заказов внутри партий. Исходная реализация данного сервиса представлена на Python в книге «Паттерны разработки на Python: TDD, DDD и событийно-ориентированная архитектура».
Читателю рекомендуется ознакомиться с оригиналом проекта и книгой «Паттерны разработки на Python: TDD, DDD и событийно-ориентированная архитектура».
Содержание
Соглашения в проекте
Окончание
T_Ptr
в имени типа означаетstd::shared_ptr<T>
.-
Каждый слой приложения вынесен в отдельный
namespace
:Allocation
— глобальныйDomain
— доменServiceLayer
— сервисный слойAdapters
— адаптерыEntrypoints
— точки входа
Структура проекта

Используемые термины и директории:
Первичные адаптеры — точки входа (директория
Entrypoints
)Вторичные адаптеры — адаптеры (директория
Adapters
)Бизнес-логика — домен (директория
Domain
)Сервисный слой — директория
ServiceLayer
утилиты - директория
Utilities
Инфраструктура
PostgreSQL — объектно-реляционная система управления базами данных.
Redis — нереляционная система управления базами данных класса NoSQL.
Библиотеки и фреймворки
Poco — библиотека для разработки сетевых приложений.
Google Test — фреймворк для модульного тестирования.
pytest — фреймворк для тестирования кода на Python.
Архитектура приложений
Трёхуровневая архитектура
Классическим подходом, описанным Мартином Фаулером, является использование трёхуровневой архитектуры. В ней каждый вышестоящий слой использует нижестоящий. Выделяют три основных слоя:
Представление — отображение данных, обработка событий пользовательского интерфейса, обслуживание HTTP-запросов, поддержка командной строки и пакетных API-вызовов.
Домен — бизнес-логика приложения.
Источник данных — доступ к базе данных, обмен сообщениями, управление транзакциями и т. д.

Пример:
// Слой источника данных
class DataGateway {
public:
std::string getData() {
// Имитация получения данных из базы
return "Данные из базы данных";
}
};
// Слой домена
class BusinessLogic {
public:
DataGateway dataGateway;
std::string processData() {
std::string data = dataGateway.getData();
// Простая обработка данных
return "Обработанные данные: " + data;
}
};
// Слой представления
class UserInterface {
public:
BusinessLogic businessLogic;
void showData() {
std::string result = businessLogic.processData();
std::cout << result << std::endl;
}
};
Такая структура приложения имеет недостатки: каждый слой «сверху» знает о всех «нижележащих», бизнес-логика оказывается связана с конкретным источником данных, а слой представления включает в себя бизнес-логику и транзитивно слой данных. Эти недостатки приводят к связанности, снижают гибкость и усложняют тестирование.
Гексагональная архитектура
Развитием идей трёхслойной архитектуры является гексагональная архитектура (луковичная архитектура), где в центре приложения находится бизнес-логика (домен), выраженная тактическими паттернами DDD.
Домен предоставляет интерфейсы — порты, которые реализуются в остальных частях проекта — адаптерах. Такой подход реализует принцип Dependency Inversion Principle (DIP, SOLID), позволяя, например, подменять настоящий адаптер фиктивным или заменять один адаптер другим.

Адаптеры делятся на два типа:
Первичные, ведущие адаптеры (driving adapters) — с которых начинается поток выполнения (например, HTTP-запрос, команда из CLI, сообщение из очереди).
Вторичные, ведомые адаптеры (driven adapters) — которые вызываются из домена (например, работа с БД или отправка сообщений).
Пример потока выполнения:

Сервисный слой
Кроме задач бизнес-логики и адаптеров, в приложении возникают и общие инфраструктурные задачи:
управление транзакциями во время выполнения бизнес-сценариев;
единое место сбора и запуска бизнес-сценариев;
вызов обработчиков по событиям, полученным из доменного слоя;
координация работы нескольких адаптеров.
Для таких задач вводят дополнительный слой — сервисный слой (Service Layer, Application Layer).
Определение:
«Слой служб (сервисный слой) определяет границы приложения и множество операций, предоставляемых им для интерфейсных клиентских слоёв кода. Он инкапсулирует бизнес-логику приложения, управляет транзакциями и координирует реакции на действия».
— Мартин Фаулер, Шаблоны корпоративных приложений.
Сервисный слой — слой, который координирует выполнение бизнес-сценариев, управляет транзакциями и инфраструктурными аспектами, не относящимися напрямую к предметной области. Он служит связующим звеном между доменом и адаптерами, обеспечивая целостность сценариев и правильный порядок их выполнения.

Важно: сервисный слой должен работать не с конкретными адаптерами, а с их абстракциями (портами). Зависимости внедряются через Dependency Injection.
Доменный слой
Важно: в параграфе рассматривается необходимая часть понятий методологии DDD в контексте проекта.
Определения:
Область (domain) — предметная область, для которой разрабатывается программное обеспечение.
Модель (model) — описывает отдельные аспекты области и может быть использована для решения проблемы.
Единый язык (Ubiquitous Language) — однозначно определённая система понятий используемых стейкхолдерами для описания модели.
Ограниченный контекст (Bounded Context) — чётко определённая граница, внутри которой действует модель.
Описание модели
Партия поставки — набор продукции одного наименования, заказанный отделом закупок организации.
Атрибуты:
Артикул (sku) — идентификатор продукции.
Количество (qty) — объём продукции в партии.
Ссылка (ref) — уникальный идентификатор партии.
Ожидаемая дата поставки (eta) — указывается, если партия находится в пути; отсутствует, если продукция уже на складе.
Клиентский заказ — заказ конечного клиента на продукцию организации.
Атрибуты и особенности:
Идентификатор заказа (orderid) — уникальный идентификатор.
Может включать позиции с разными наименованиями продукции.
Состоит из одной или нескольких позиций заказа.
Позиция заказа — элемент клиентского заказа, описывающий потребность в конкретном товаре.
Атрибуты:
Идентификатор заказа (orderid) — ссылка на заказ, частью которого является позиция.
Артикул (sku) — идентификатор запрашиваемой продукции.
Количество (qty) — количество запрашиваемой продукции.
Ограничения и сценарии
Клиенты оформляют заказы, которые идентифицируются ссылкой и состоят из позиций заказа (order lines).
Отдел закупок оформляет партии поставок (batches) продукции.
Задача: разместить позиции заказов в партиях поставки.
Правила размещения
После размещения x единиц товара в партия поставки её доступное количество уменьшается на x.
Пример: партия поставки СТОЛ-МАЛЫЙ, 20 шт.; размещено 2 шт. → остаётся 18 шт.Нельзя разместить позиций заказа, если доступное количество меньше требуемого.
Пример: партия поставки ПОДУШКА-СИНЯЯ, 1 шт.; позиция заказа — 2 шт. → размещение невозможно.Одну и ту же позиций заказа нельзя разместить дважды в одной партии поставки.
Пример: партия поставки ВАЗА-СИНЯЯ, 10 шт.; позиция заказа — 2 шт.; если попытаться разместить снова, остаток будет 8 шт., а не 6.-
Партии имеют предполагаемое время прибытия (eta, estimated arrival time):
партия поставки на складе имеют приоритет при размещении;
для партии поставки в пути приоритет определяется по eta: чем раньше прибытие, тем выше приоритет.
Паттерны и их реализация
Для реализации описанной модели в DDD применяются паттерны:
Сущность (Entity)
Объект-значение (Value Object)
Агрегат (Aggregate)
Доменные события и команды
Сущность
Сущность (Entity) — объект, идентифицируемый уникальным свойством. Партии поставки заказа уникальны и определяются по ссылке (ref).
Это проявляется:
в операторе проверки равенства партий;
в схеме БД.
Код
namespace Allocation::Domain
{
/// Представляет партию поставки продукции для распределения.
class Batch
{
public:
...
/// Проверяет, можно ли распределить позицию заказа в партии.
[[nodiscard]] bool CanAllocate(const OrderLine& line) const noexcept;
/// Распределяет позицию заказа в партии.
void Allocate(const OrderLine& line) noexcept;
private:
std::string _reference;
std::string _sku;
size_t _purchasedQuantity;
std::optional<std::chrono::year_month_day> _eta;
std::unordered_set<OrderLine> _allocations;
};
bool operator==(const Batch& lhs, const Batch& rhs) noexcept
{
return lhs.GetReference() == rhs.GetReference();
}
bool operator<(const Batch& lhs, const Batch& rhs) noexcept
{
if (!lhs.GetETA().has_value())
return true;
if (!rhs.GetETA().has_value())
return false;
return lhs.GetETA().value() < rhs.GetETA().value();
}
}
Схема БД
CREATE TABLE allocation.batches (
id SERIAL PRIMARY KEY,
reference VARCHAR(255) UNIQUE NOT NULL,
sku VARCHAR(255) NOT NULL REFERENCES allocation.products(sku),
_purchased_quantity INTEGER NOT NULL CHECK (_purchased_quantity > 0),
eta DATE
);
Объект-значение
Объект-значение (Value Object) — объект, который не имеет собственной идентичности. Его уникальность определяется набором полей. Два объекта-значения считаются равными, если их значения полей совпадают.
Классический пример — деньги: если два объекта «деньги» имеют одинаковое значение, они равны.
Позиции заказа (Order line) не имеют собственной идентичности и определяется артикулом продукта (sku), количеством (qty) и ссылкой на заказ (orderid).
Код
namespace Allocation::Domain
{
/// Представляет позицию заказа для распределения.
struct OrderLine
{
/// Ссылка на заказ клиента.
std::string orderid;
std::string sku;
size_t quantity;
bool operator==(const OrderLine&) const = default;
};
}
Схема БД
-- Таблица позиций заказов
CREATE TABLE allocation.order_lines (
id SERIAL PRIMARY KEY,
sku VARCHAR(255) NOT NULL,
qty INTEGER NOT NULL CHECK (qty > 0),
orderid VARCHAR(255) NOT NULL
);
-- Таблица распределений (связь OrderLine -> Batch)
CREATE TABLE allocation.allocations (
id SERIAL PRIMARY KEY,
orderline_id INTEGER NOT NULL REFERENCES allocation.order_lines(id),
batch_id INTEGER NOT NULL REFERENCES allocation.batches(id)
);
Агрегат
Агрегат (Aggregate) — согласованная совокупность связанных сущностей и объектов-значений, объединяемая в единицу консистентности для управления изменениями. Управление доступом к агрегату осуществляется через его корень, который гарантирует соблюдение бизнес-инвариантов и определяет границы возможных операций над агрегатом.
В сервисе агрегатом является Product, который объединяет все партии поставок продукции с общим артикулом. Корнем агрегата выступает сущность Product, через которую осуществляется доступ ко всем изменениям внутри агрегата.
Код
namespace Allocation::Domain
{
/// Агрегат-продукт, содержит партии поставок с общим артикулом продукции.
/// Реализует бизнес-логику распределения позиций заказа в партиях заказа.
class Product
{
public:
explicit Product(const std::string& sku, const std::vector<Batch>& batches = {},
size_t versionNumber = 0, bool isNew = true);
...
/// Распределяет позицию заказа в партии заказа агрегата.
std::optional<std::string> Allocate(const OrderLine& line);
/// Изменяет количество продукции в партии заказа.
bool ChangeBatchQuantity(const std::string& ref, size_t qty);
[[nodiscard]] size_t GetVersion() const noexcept;
/// Возвращает сообщения, сгенерированные во время выполнения бизнес-логики.
[[nodiscard]] const std::vector<Domain::IMessagePtr>& Messages() const noexcept;
void ClearMessages() noexcept;
private:
std::string _sku;
std::unordered_map<std::string, Batch> _referenceByBatches;
std::unordered_set<std::string> _modifiedBatchRefs;
std::vector<Domain::IMessagePtr> _messages;
size_t _versionNumber;
bool _isModified;
};
}
Схема БД
-- Таблица агрегатов продуктов
CREATE TABLE allocation.products (
sku VARCHAR(255) PRIMARY KEY CHECK (sku <> ''),
version_number BIGINT NOT NULL DEFAULT 0
);
Доменные события и команды
Доменные события (Domain Event) — структуры, описывающие произошедшие в агрегате изменения. Используются для уведомления компонентов системы о произошедших событиях в агрегате.
События обрабатываются подписчиками, описанными в сервисном слое.
Пример:
namespace Allocation::Domain::Events
{
/// Событие "Распределена позиция заказа".
struct Allocated final : public AbstractEvent
{
Allocated(std::string orderid, std::string sku, size_t qty, std::string batchref)
: orderid(std::move(orderid)),
sku(std::move(sku)),
qty(qty),
batchref(std::move(batchref)){};
[[nodiscard]] std::string Name() const override { return "Allocated"; };
std::string orderid;
std::string sku;
size_t qty;
std::string batchref;
};
}
Команды — объекты, описывающие намерение изменить состояние домена. Команды формулируют что нужно сделать, но не содержат реализации бизнес-логики.
Поступают из первичных адаптеров (REST, Redis и др.).
Обрабатываются сервисным слоем, который делегирует выполнение соответствующему агрегату.
Не описывают результат, только действие.
Пример:
namespace Allocation::Domain::Commands
{
/// Команда "Распределить позицию заказа".
struct Allocate final : public AbstractCommand
{
Allocate(std::string orderid, std::string sku, size_t qty)
: orderid(std::move(orderid)), sku(std::move(sku)), qty(qty)
{
}
[[nodiscard]] std::string Name() const override { return "Allocate"; };
std::string orderid;
std::string sku;
size_t qty;
};
}
Сообщение - обобщающее понятие команд и событий. Агрегат возвращает сообщения Messages()
, метод ClearMessages()
очищает сообщения в агрегате.
Описание портов
Абстракции и интерфейсы определяют контракты для всех реализаций, что соответствует принципу подстановки Барбары Лисков (LSP, SOLID). В гексагональной архитектуре контракты формируют порты — интерфейсы, определяющие взаимодействие между доменом и внешними компонентами.
Сообщение
Сообщение - обобщающее понятие команд и событий. Домен предоставляет следующий порт:
namespace Allocation::Domain
{
struct IMessage
{
/// Типы сообщений.
enum class Type : int
{
Event,
Command
};
virtual ~IMessage() = default;
/// Возвращает имя сообщения.
[[nodiscard]] virtual std::string Name() const = 0;
/// Возвращает тип сообщения.
[[nodiscard]] virtual Type GetType() const = 0;
};
}
Репозиторий
Репозиторий (Repository) — абстракция над системой хранения данных. Он отвечает за сохранение, загрузку и обновление агрегатов, полученных из конкретного хранилища.
Порт для работы с агрегатом Product
:
namespace Allocation::Domain
{
/// Интерфейс репозитория для работы с агрегатами-продуктами в хранилище.
class IRepository
{
public:
IRepository() = default;
virtual ~IRepository() = default;
/// Общий интерфейс для добавления или обновления агрегат-продукта.
virtual void Add(Domain::ProductPtr product) = 0;
/// Возвращает агрегат-продукт по его артикулу.
[[nodiscard]] virtual Domain::ProductPtr Get(const std::string& sku) = 0;
/// Возвращает агрегат-продукт по идентификатору партии включённого в него.
[[nodiscard]] virtual Domain::ProductPtr GetByBatchRef(const std::string& batchRef) = 0;
...
};
}
Пример использования:
void Example(IRepository& repo)
{
auto product = repo.Get("Amazing-table"); // Загружаем агрегат по артикулу
if (!product)
product = std::make_shared<Domain::Product>("Amazing-table"); // Или создаём новый
Domain::Batch batch("b-add", "Amazing-table", 100);
product->AddBatch(batch);
repo.Add(product); // Сохраняем изменения
}
В оригинале проекта метод Add
используется и для добавления, и для обновления.
В C++ это можно рассматривать как нарушение принципа SRP.
Для разделения обязанностей вводится расширение — IUpdatableRepository
:
namespace Allocation::Domain
{
/// Расширенный интерфейс репозитория для обновления агрегатов.
class IUpdatableRepository : public IRepository
{
public:
/// Обновляет агрегат-продукт в репозитории.
virtual void Update(ProductPtr product, size_t oldVersion) = 0;
...
};
}
Применение интерфейса IUpdatableRepository
описано далее в сервисном слое.
Единица работы
Единица работы (Unit of Work, UoW) — паттерн, который обеспечивает атомарность выполнения операций. Все изменения в рамках UoW должны быть либо зафиксированы целиком, либо отменены.
При работе с СУБД UoW управляет транзакцией: при ошибке на любом этапе бизнес-процесса или при отсутствии фиксации выполняется откат.
/// Интерфейс единицы работы.
class IUnitOfWork
{
public:
...
/// Подтверждает изменения.
virtual void Commit() = 0;
/// Откатывает изменения.
virtual void RollBack() = 0;
/// Проверяет, были ли изменения зафиксированы.
[[nodiscard]] virtual bool IsCommited() const noexcept = 0;
...
/// Возвращает репозиторий хранилища продуктов.
[[nodiscard]] virtual IRepository& GetProductRepository() = 0;
/// Возвращает новые сообщения из обработанных агрегатов.
[[nodiscard]] virtual std::vector<IMessagePtr> GetNewMessages() noexcept = 0;
};
Пример 1 (успешный сценарий):
void Example(IUnitOfWork& uow)
{
auto& repo = uow.GetProductRepository();
auto product = repo.Get("Amazing-table");
if (!product)
product = std::make_shared<Domain::Product>("Amazing-table");
Domain::Batch batch("b-add", "Amazing-table", 100);
product->AddBatch(batch);
repo.Add(product);
uow.Commit(); // фиксируем изменения
}
Пример 2 (с откатом):
void Example()
{
try
{
IUnitOfWork& uow = Allocation::SomeUoW(); // конкретная реализация UoW
auto& repo = uow.GetProductRepository();
auto product = repo.Get("Amazing-table");
if (!product)
product = std::make_shared<Domain::Product>("Amazing-table");
Domain::Batch batch("b-add", "Amazing-table", 100);
product->AddBatch(batch);
repo.Add(product);
throw std::runtime_error("Boom");
uow.Commit(); // не будет вызвано
}
catch(...)
{
// изменения будут отменены автоматически
}
}
Точки входа
Типы первичных адаптеров
В приложении используется два типа первичных адаптеров:
REST-адаптер — обрабатывают входящие HTTP-запросы.
Redis-адаптер — принимают сообщения из Redis-каналов.
REST-адаптер
REST-адаптер обрабатывает входящие HTTP-запросы. После маршрутизации фабрика (HandlerFactory
) вызывает соответствующий обработчик. Обработчик разбирает данные и запускает бизнес-сценарий через сервисный слой.
Пример обработчика:
namespace Allocation::Entrypoints::Rest::Handlers
{
void AllocateHandler::handleRequest(
Poco::Net::HTTPServerRequest& request, Poco::Net::HTTPServerResponse& response)
{
response.set("Access-Control-Allow-Origin", "*");
std::istream& bodyStream = request.stream();
std::ostringstream body;
body << bodyStream.rdbuf();
Poco::JSON::Parser parser;
auto result = parser.parse(body.str());
auto json = result.extract<Poco::JSON::Object::Ptr>();
try
{
auto command = Domain::FromJson<Domain::Commands::Allocate>(json);
ServiceLayer::MessageBus::Instance().Handle(command);
response.setStatus(Poco::Net::HTTPResponse::HTTP_ACCEPTED);
response.setContentType("application/json");
response.send();
return;
}
catch (const Poco::Exception& ex)
{
response.setStatus(Poco::Net::HTTPResponse::HTTP_INTERNAL_SERVER_ERROR);
response.setContentType("application/json");
std::string msg = ex.displayText();
response.send() << "{\"message\":\"" << msg << "\"}";
Allocation::Loggers::GetLogger()->Error(msg);
}
catch (const std::runtime_error& ex)
{
response.setStatus(Poco::Net::HTTPResponse::HTTP_CONFLICT);
response.setContentType("application/json");
std::ostream& ostr = response.send();
std::string msg = ex.what();
ostr << "{\"message\": \"" << msg << "\"}";
Allocation::Loggers::GetLogger()->Error(msg);
}
// ... обработка исключений
}
}
Принятое соглашение:
инфраструктурные ошибки (например, проблемы с БД) передаются через
Poco::Exception
;ошибки бизнес-логики — через
std::exception
и его наследников.
Redis-адаптер
В проекте Redis используется в качестве брокера сообщений и служит каналом для публикации событий между сервисами.
В сервисе реализован компонент RedisListener
, который позволяет подписывать обработчики на нужные каналы.
namespace Allocation::Entrypoints::Redis
{
/// Концепт для обработчика Redis-сообщений.
template <typename Handler>
concept RedisMessageHandler = requires(Handler h, const std::string& payload) {
{ h(payload) } -> std::same_as<void>;
};
/// Слушает сообщения из Redis и перенаправляет их в обработчики.
class RedisListener
{
public:
RedisListener()
: _connection(Adapters::Redis::RedisConnectionPool::Instance().GetConnection()),
_reader(*static_cast<Poco::Redis::Client::Ptr>(_connection))
{
_reader.redisResponse += Poco::delegate(this, &RedisListener::OnRedisMessage);
}
...
/// Запускает асинхронное чтение сообщений из Redis.
void Start() { _reader.start(); }
/// Останавливает асинхронное чтение сообщений из Redis.
void Stop() { _reader.stop(); };
/// Подписывается на канал Redis и регистрирует обработчик сообщений.
template <RedisMessageHandler Handler>
void Subscribe(const std::string& channel, Handler&& handler)
{
Poco::Redis::Array subscribe;
subscribe.add("SUBSCRIBE").add(channel);
static_cast<Poco::Redis::Client::Ptr>(_connection)->execute<void>(subscribe);
static_cast<Poco::Redis::Client::Ptr>(_connection)->flush();
_handlers.try_emplace(channel, std::forward<Handler>(handler));
}
private:
/// Обрабатывает входящие сообщения от Redis и вызывает обработчики.
void OnRedisMessage(const void* sender, Poco::Redis::RedisEventArgs& args)
{
if (const Poco::Exception* exception = args.exception(); exception)
{
Allocation::Loggers::GetLogger()->Error(
"Redis exception: " + exception->displayText());
return;
}
try
{
if (auto msg = args.message(); msg && msg->isArray())
{
Poco::Redis::Type<Poco::Redis::Array>* arrayType =
dynamic_cast<Poco::Redis::Type<Poco::Redis::Array>*>(args.message().get());
if (!arrayType)
return;
Poco::Redis::Array& array = arrayType->value();
if (array.size() == 3)
{
Poco::Redis::BulkString type = array.get<Poco::Redis::BulkString>(0);
if (type != "message")
return;
auto channel = std::string(array.get<Poco::Redis::BulkString>(1));
auto payload = std::string(array.get<Poco::Redis::BulkString>(2));
if (auto it = _handlers.find(channel); it != _handlers.end())
it->second(payload);
}
}
}
catch (const Poco::Exception& e)
{
Allocation::Loggers::GetLogger()->Error(
"RedisListener exception: " + std::string(e.displayText()));
}
...
}
...
}
};
Пример подписки:
void HandleChangeBatchQuantity(const std::string& payload)
{
if (payload.empty())
return;
Poco::JSON::Parser parser;
auto parsed = parser.parse(payload);
auto json = parsed.extract<Poco::JSON::Object::Ptr>();
auto command = Domain::FromJson<Domain::Commands::ChangeBatchQuantity>(json);
ServiceLayer::MessageBus::Instance().Handle(command);
}
void Example()
{
Entrypoints::Redis::RedisListener redisListener;
redisListener.Subscribe("change_batch_quantity", HandleChangeBatchQuantity);
redisListener.Start(); // прослушивает в отдельном потоке
}
Сервисный слой
Для цельности повествования необходимо рассмотреть вспомогательные классы — TrackingRepository
и AbstractUnitOfWork
.
Описание вспомогательных классов
TrackingRepository
TrackingRepository
это обёртка (Decorator / Proxy) над репозиторием. Его задача отслеживать агрегаты с которыми работали в контексте репозитория и разделять вызов метода IRepository::Add(Domain::ProductPtr product)
на обновление и добавление нового агрегата.
namespace Allocation::Adapters::Repository
{
/// Репозиторий для отслеживания изменений агрегатов продуктов.
class TrackingRepository final : public Domain::IUpdatableRepository
{
public:
/// repo - Отслеживаемый репозиторий.
TrackingRepository(Domain::IUpdatableRepository& repo);
/// Общий интерфейс для добавления или обновления агрегат-продукта.
void TrackingRepository::Add(Domain::ProductPtr product)
{
if (!product)
throw std::invalid_argument("The nullptr product");
auto sku = product->GetSKU();
if (auto it = _skuToProductAndOldVersion.find(sku); it != _skuToProductAndOldVersion.end())
Update(product, it->second.second);
else
{
_repo.Add(product);
product->SetModified(false);
_skuToProductAndOldVersion.insert({sku, {product, product->GetVersion()}});
}
}
...
/// Возвращает отслеживаемые агрегаты.
[[nodiscard]] std::vector<std::pair<Domain::ProductPtr, size_t>> GetSeen() const noexcept;
/// Очищает наблюдаемые агрегаты.
void Clear() noexcept;
/// Обновляет агрегат-продукт в репозитории.
void Update(Domain::ProductPtr product, int oldVersion) override
{
if (!product)
throw std::invalid_argument("The nullptr product");
_repo.Update(product, oldVersion);
product->SetModified(false);
}
private:
Domain::IUpdatableRepository& _repo;
std::unordered_map<std::string, std::pair<Domain::ProductPtr, size_t>>
_skuToProductAndOldVersion;
};
}
AbstractUnitOfWork
AbstractUnitOfWork
отвечает за централизованный сбор сообщений и обновление модифицированных агрегатов в контексте текущего UoW при вызове метода Commit()
.
namespace Allocation::ServiceLayer::UoW
{
/// Абстрактный базовый класс для реализации паттерна "Единица работы".
/// Отвечает за контроль транзакций и отслеживание изменений в агрегатах через TrackingRepository.
class AbstractUnitOfWork : public Domain::IUnitOfWork
{
public:
/// repo - Репозиторий, который будет отслеживаться в TrackingRepository.
explicit AbstractUnitOfWork(Domain::IUpdatableRepository& repo);
/// Подтверждает изменения.
void Commit() override
{
for (const auto& [product, _] : _tracking.GetSeen())
if (product->IsModified())
_tracking.Add(product);
_isCommitted = true;
}
/// Откатывает изменения.
void RollBack() override;
/// Проверяет, были ли изменения зафиксированы.
bool IsCommitted() const noexcept override;
/// Возвращает репозиторий для работы с агрегатами-продуктами.
[[nodiscard]] Domain::IRepository& GetProductRepository() override
{
return _tracking;
}
/// Возвращает новые сообщения, сгенерированные продуктами
/// в рамках текущей единицы работы.
[[nodiscard]] std::vector<Domain::IMessagePtr> GetNewMessages() noexcept override
{
std::vector<Domain::IMessagePtr> newMessages;
for (const auto& [product, _] : _tracking.GetSeen())
{
auto messages = product->Messages();
newMessages.insert(newMessages.end(), messages.begin(), messages.end());
product->ClearMessages();
}
return newMessages;
}
private:
Adapters::Repository::TrackingRepository _tracking;
bool _isCommitted{false};
};
}
Компоненты сервисного слоя
Шина сообщений
Шина сообщений (MessageBus) реализует паттерн «издатель–подписчик», принимает сообщение (IMessage
) и вызывает соответствующие ему обработчики.
Существуют различия в обработке доменных событий и команд:
Команды могут иметь только один обработчик.
Исключения в обработчиках команд останавливают дальнейшую обработку.
События могут иметь несколько обработчиков.
Исключения в обработчиках событий не останавливаю дальнейшую обработку.
Обработчики событий должны поддерживать концепцию:
/// Концепция для обработчиков событий конкретного типа.
template <typename F, typename T>
concept EventHandlerFor =
std::derived_from<T, Domain::Events::AbstractEvent> &&
(
std::is_invocable_v<F, Domain::IUnitOfWork&, std::shared_ptr<T>> ||
std::is_invocable_v<F, std::shared_ptr<T>>
);
Обработчики команд должны поддерживать концепцию:
/// Концепция для обработчиков команд конкретного типа.
template <typename F, typename T>
concept CommandHandlerFor =
std::derived_from<T, Domain::Commands::AbstractCommand> &&
std::is_invocable_v<F, Domain::IUnitOfWork&, std::shared_ptr<T>>;
Точкой входа в обработку сообщений является методы Handle(...)
.
namespace Allocation::ServiceLayer
{
/// Шина сообщений для обработки событий и команд.
class MessageBus
{
using EventHandler = std::function<void(Domain::IUnitOfWork&, Domain::Events::EventPtr)>;
using CommandHandler =
std::function<void(Domain::IUnitOfWork&, Domain::Commands::CommandPtr)>;
public:
static MessageBus& Instance();
/// Подписывает обработчик на событие конкретного типа.
/// T - Тип события, производный от Domain::Events::AbstractEvent.
/// F - Тип обработчика.
template <typename T, typename F>
requires EventHandlerFor<F, T>
void SubscribeToEvent(F&& handler) noexcept
{
auto& handlers = _eventHandlers[typeid(T)];
handlers.emplace_back(
[h = std::forward<F>(handler)](
Domain::IUnitOfWork& uow, Domain::Events::EventPtr event)
{
if constexpr (std::is_invocable_v<F, Domain::IUnitOfWork&, std::shared_ptr<T>>)
h(uow, std::static_pointer_cast<T>(event));
else if constexpr (std::is_invocable_v<F, std::shared_ptr<T>>)
h(std::static_pointer_cast<T>(event));
});
}
/// Устанавливает обработчик для команды конкретного типа.
/// T - Тип команды, производный от Domain::Commands::AbstractCommand.
/// F - Тип функции-обработчика.
template <typename T, typename F>
requires CommandHandlerFor<F, T>
void SetCommandHandler(F&& handler) noexcept
{
_commandHandlers[typeid(T)] =
[h = std::forward<F>(handler)](Domain::IUnitOfWork& uow,
Domain::Commands::CommandPtr cmd)
{
h(uow, std::static_pointer_cast<T>(cmd));
};
}
/// Обрабатывает входящее доменное сообщение.
void Handle(Domain::IMessagePtr message, Domain::IUnitOfWork& uow)
{
std::queue<Domain::IMessagePtr> queue;
queue.push(message);
while (!queue.empty())
{
auto message = queue.front();
queue.pop();
if (message->GetType() == Domain::IMessage::Type::Command)
{
if (!_commandHandlers.contains(typeid(*message)))
throw std::runtime_error(
std::format("The {} command doesn`t have a handler", message->Name()));
HandleCommand(uow,
std::static_pointer_cast<Domain::Commands::AbstractCommand>(message), queue);
}
else if (message->GetType() == Domain::IMessage::Type::Event &&
_eventHandlers.contains(typeid(*message)))
{
HandleEvent(
uow, std::static_pointer_cast<Domain::Events::AbstractEvent>(message), queue);
}
}
}
/// Обрабатывает входящее доменное сообщение.
/// Автоматически создаёт единицу работы SqlUnitOfWork.
void Handle(Domain::IMessagePtr message);
/// Очищает все зарегистрированные обработчики событий и команд.
void ClearHandlers() noexcept;
private:
...
/// Обрабатывает входящее событие.
/// uow - Единица работы для обработки события.
/// event - Доменное событие.
/// queue - Очередь для новых сообщений.
void HandleEvent(Domain::IUnitOfWork& uow, Domain::Events::EventPtr event,
std::queue<Domain::IMessagePtr>& queue) noexcept;
{
for (auto& handler : _eventHandlers[typeid(*event)])
{
try
{
Loggers::GetLogger()->Debug(std::format("Handling event {} with handler {}",
event->Name(), handler.target_type().name()));
handler(uow, event);
for (auto& newMessage : uow.GetNewMessages())
queue.push(newMessage);
}
catch (...)
{
Loggers::GetLogger()->Error(
std::format("Exception handling event {}", event->Name()));
}
}
}
/// Обрабатывает входящую команду.
/// uow - Единица работы для обработки команды.
/// command - Доменная команда.
/// queue - Очередь для новых сообщений.
void HandleCommand(Domain::IUnitOfWork& uow, Domain::Commands::CommandPtr command,
std::queue<Domain::IMessagePtr>& queue)
{
Loggers::GetLogger()->Debug(std::format("handling command {}", command->Name()));
try
{
_commandHandlers.at(typeid(*command))(uow, command);
for (auto& newMessage : uow.GetNewMessages())
queue.push(newMessage);
}
catch (...)
{
Loggers::GetLogger()->Error(
std::format("Exception handling command {}", command->Name()));
throw;
}
}
std::unordered_map<std::type_index, std::vector<EventHandler>> _eventHandlers;
std::unordered_map<std::type_index, CommandHandler> _commandHandlers;
};
}
Пример:
void AddBatch(Domain::IUnitOfWork& uow, std::shared_ptr<Domain::Commands::CreateBatch> message);
void AddAllocationToReadModel(Domain::IUnitOfWork& uow, std::shared_ptr<Domain::Events::Allocated> event);
void PublishAllocatedEvent(Domain::IUnitOfWork& uow);
void Example()
{
auto& messagebus = ServiceLayer::MessageBus::Instance();
messagebus.SubscribeToEvent<Domain::Events::Allocated>(AddAllocationToReadModel);
messagebus.SubscribeToEvent<Domain::Events::Allocated>(PublishAllocatedEvent);
messagebus.SetCommandHandler<Domain::Commands::CreateBatch>(AddBatch);
messagebus.Handle(Make<Domain::Events::Allocated>); // вызовет AddAllocationToReadModel и PublishAllocatedEvent
messagebus.Handle(Make<Domain::Commands::CreateBatch>); // вызовет AddBatch
}
SqlUnitOfWork
SqlUnitOfWork
является реализацией паттерна единицы работы для СУБД PosgresSQL.
namespace Allocation::ServiceLayer::UoW
{
/// Реализация единицы работы для SQL хранилища.
class SqlUnitOfWork final : public AbstractUnitOfWork
{
public:
/// При создании объекта открывает сессию к БД и начинает транзакцию.
SqlUnitOfWork()
: _session(Adapters::Database::DatabaseSessionPool::Instance().GetSession()),
_repository(_session),
AbstractUnitOfWork(_repository)
{
_session.setTransactionIsolation(Poco::Data::Session::TRANSACTION_REPEATABLE_READ);
_session.begin();
}
/// Откатывает незафиксированные изменения.
~SqlUnitOfWork()
{
_session.rollback();
}
/// Возвращает сессию подключения к базе данных.
[[nodiscard]] Poco::Data::Session GetSession() noexcept override
{
return _session;
}
/// Подтверждает внесённые изменения.
/// После фиксаций изменений запускает новую транзакцию.
void Commit() override
{
AbstractUnitOfWork::Commit();
_session.commit();
_session.begin();
}
/// Откатывает внесённые изменения.
/// После отката изменений запускает новую транзакцию.
void RollBack() override
{
_session.rollback();
AbstractUnitOfWork::RollBack();
_session.begin();
}
private:
Poco::Data::Session _session;
Adapters::Repository::SqlRepository _repository;
};
}
Описание CQRS
В приложении используются две модели хранения данных:
Модель команд обеспечивает корректное и достоверное изменение данных.
Модель чтения отвечает за оптимальный доступ к данным.
Модель чтения может быть реализована на любой системе хранения данных.
Внимание: Разделение моделей хранения данных приводит к временной несогласованности между ними. Вопрос глубже рассматривается, например, в книге «Микросервисы. Паттерны, разработка и рефакторинг» Криса Ричардсона.
Связь между моделями реализуется через обработчики событий в шине сообщений:
namespace Allocation::ServiceLayer::Handlers
{
/// Добавляет в модель чтения распределённую позицию заказа.
/// uow - Единица работы.
/// event - Событие "Распределена позиция заказа".
void AddAllocationToReadModel(
Domain::IUnitOfWork& uow, std::shared_ptr<Domain::Events::Allocated> event);
/// Удаляет в модели чтения распределённую позицию заказа.
/// uow - Единица работы.
/// event - Событие "Отменено распределение позиции заказа".
void RemoveAllocationFromReadModel(
Domain::IUnitOfWork& uow, std::shared_ptr<Domain::Events::Deallocated> event);
}
Модель чтения
В приложении модель чтения представлена отдельной таблицей в базе данных:
CREATE TABLE allocation.allocations_view (
orderid VARCHAR(255),
sku VARCHAR(255),
batchref VARCHAR(255)
);
Эта таблица является проекцией (read model) и синхронизируется моделью команд исключительно через обработчики событий AddAllocationToReadModel
и RemoveAllocationFromReadModel
.
при распределении позиции заказа в партии поставок агрегат генерирует событие
Domain::Events::Allocated
,при отмене распределение позиции заказа агрегат генерирует событие
Domain::Events::Deallocated
,
оба события обрабатываются шиной сообщений и вызывают соответствующие обработчики для обновления модели чтения.
Чтение данных реализовано:
namespace Allocation::ServiceLayer::Views
{
/// Получает распределённые позиции заказа по идентификатору заказа клиента.
std::vector<std::pair<std::string, std::string>> Allocations(
std::string orderid, Domain::IUnitOfWork& uow)
{
std::vector<std::pair<std::string, std::string>> results;
auto session = uow.GetSession();
Poco::Data::Statement select(session);
select << "SELECT sku, batchref FROM allocation.allocations_view WHERE orderid = $1",
Poco::Data::Keywords::use(orderid), Poco::Data::Keywords::now;
Poco::Data::RecordSet rs(select);
for (bool more = rs.moveFirst(); more; more = rs.moveNext())
results.emplace_back(rs["sku"].toString(), rs["batchref"].toString());
return results;
}
}
Модель команд
Обработчики доменных команд в шине сообщений являются реализацией взаимодействия с моделью команд.
Пример:
namespace Allocation::ServiceLayer::Handlers
{
/// Добавляет новую партию заказа.
/// uow - Единица работы.
/// command - Команда "Создать партию заказа".
void AddBatch(Domain::IUnitOfWork& uow, std::shared_ptr<Domain::Commands::CreateBatch> message)
{
auto& repo = uow.GetProductRepository();
auto product = repo.Get(message->sku);
if (!product)
{
product = std::make_shared<Domain::Product>(message->sku);
repo.Add(product);
}
product->AddBatch(
Allocation::Domain::Batch(message->ref, message->sku, message->qty, message->eta));
uow.Commit();
}
}
Слой адаптеров
SQL адаптеры
Адаптеры включают:
Пул сессий подключений к СУБД.
Data Mappers
для отображения доменных объектов в базу данных и из неё.Репозиторий
SqlRepository
для работы с PostgreSQL СУБД.
Репозиторий
SqlRepository
— реализация порта IUpdatableRepository
для хранилища PostgreSQL. Он отображает объекты в БД и обратно с помощью Data Mapper
-ов.
namespace Allocation::Adapters::Repository
{
/// Реализация репозитория для работы с PostgreSQL СУБД.
class SqlRepository final : public Domain::IUpdatableRepository
{
public:
explicit SqlRepository(const Poco::Data::Session& session);
/// Добавляет новый агрегат-продукт в репозиторий.
void Add(Domain::ProductPtr product) override;
/// Возвращает агрегат-продукт по его артикулу.
[[nodiscard]] Domain::ProductPtr Get(const std::string& sku) override;
/// Возвращает агрегат-продукт по идентификатору партии включённого в него.
[[nodiscard]] Domain::ProductPtr GetByBatchRef(const std::string& batchRef) override;
/// Обновляет агрегат-продукт в репозитории.
void Update(Domain::ProductPtr product, size_t oldVersion) override;
private:
Database::Mapper::ProductMapper _mapper;
};
}
Используется совместно с TrackingRepository
из дочерних классов AbstractUnitOfWork
.
В приложении используется принцип мягкого удаление. Созданные ранее агрегаты не удаляются. Удаление партий поставок производится модификацией агрегата.
Data Mapper
Data Mapper
-ы отвечают за отображение объектов домена в БД и из неё. Они формируют SQL-запросы для поиска, вставки, удаления и обновления объектов доменов в реляционную БД. Мапперы могут включать друг друга в реализациях.
Для демонстрации продемонстрирован метод ProductMapper::FindBySKU(...)
:
/// Маппер для отображения агрегата-продукт в базе данных и обратно.
class ProductMapper
{
public:
/// session - Сессия подключения к базе данных.
explicit ProductMapper(const Poco::Data::Session& session);
/// Находит агрегат-продукт по артикулу.
[[nodiscard]] Domain::ProductPtr FindBySKU(const std::string& sku) const
{
if (sku.empty())
return nullptr;
int version;
Poco::Data::Statement selectProduct(_session);
selectProduct << R"(
SELECT version_number
FROM allocation.products
WHERE sku = $1
)",
useRef(sku), into(version);
bool found = selectProduct.execute() > 0;
if (!found)
return nullptr;
auto batches = _batchMapper.Find(sku);
return std::make_shared<Domain::Product>(sku, batches, version, false);
}
/// Находит агрегат-продукт по идентификатору партии включённого в него.
[[nodiscard]] Domain::ProductPtr FindByBatchRef(const std::string& ref) const;
/// Обновляет агрегат-продукт.
/// true - успешное обновление, иначе false.
[[nodiscard]] bool Update(Domain::ProductPtr product, size_t oldVersion);
/// Сохраняет агрегат-продукт.
void Insert(Domain::ProductPtr product);
/// Удаляет агрегат-продукт.
bool Delete(Domain::ProductPtr product);
private:
...
mutable Poco::Data::Session _session;
BatchMapper _batchMapper;
};
Redis адаптер
Redis адаптер
реализует паттерн Publisher и используется как вторичный (ведомый) адаптер для интеграции с внешними системами.
namespace Allocation::Adapters::Redis
{
/// Публикует события в Redis.
template <typename T>
requires std::derived_from<T, Domain::Events::AbstractEvent>
class RedisEventPublisher
{
public:
RedisEventPublisher() : _connection(RedisConnectionPool::Instance().GetConnection()) {}
/// Публикует событие в указанный канал.
void operator()(const std::string& channel, std::shared_ptr<T> event) const
{
Poco::JSON::Object json;
/// формирует JSON на основе аттрибутов события
for (const auto& [name, value] : GetAttributes<T>(event))
json.set(name, value);
std::stringstream ss;
json.stringify(ss);
Poco::Redis::Command publish("PUBLISH");
publish << channel << ss.str();
try
{
/// отправляет событие в канал
static_cast<Poco::Redis::Client::Ptr>(_connection)->execute<Poco::Int64>(publish);
}
catch (const Poco::Exception& e)
{
Allocation::Loggers::GetLogger()->Error(
std::format("Redis publish failed: {}", e.displayText()));
}
catch (...)
{
Allocation::Loggers::GetLogger()->Error("Redis publish failed: unknown error");
}
}
private:
mutable Poco::Redis::PooledConnection _connection;
};
}
В Domain
для каждого события специализирован шаблон функции GetAttributes
, который возвращает пары «имя атрибута - значение».
Пример применения:
namespace Allocation::ServiceLayer::Handlers
{
/// Концепция для отправителей сообщений в канал Redis.
template <typename T, typename Message>
concept PublisherSender =
requires(T t, const std::string& channel, std::shared_ptr<Message> event) {
{ t(channel, event) } -> std::same_as<void>;
};
/// Отправитель сообщений в канал Redis.
template <typename Message, PublisherSender<Message> Publisher>
requires std::derived_from<Message, Domain::Events::AbstractEvent>
class PublisherHandler
{
public:
/// publisher - Отправитель сообщений.
PublisherHandler(Publisher publisher = {}) : _publisher(std::move(publisher)) {}
/// Публикует сообщение в канал Redis.
void operator()(std::shared_ptr<Message> event) const
{
Allocation::Loggers::GetLogger()->Debug(
std::format("publishing: channel={}, event={}", "line_allocated", event->Name()));
_publisher("line_allocated", event);
}
private:
Publisher _publisher;
};
/// Публикует событие в Redis "Распределена позиция заказа".
using PublishAllocatedEvent = PublisherHandler<Domain::Events::Allocated,
Allocation::Adapters::Redis::RedisEventPublisher<Domain::Events::Allocated>>;
/// Далее регистрируем PublishAllocatedEvent в шине сообщений как функтор
}
Система уведомлений
Для упрощения реализации система отправки уведомлений по электронной почте заменена заглушкой.
Это пример Stub-реализации адаптера. В реальном приложении он заменяется SMTP-клиентом или API внешнего сервиса.
namespace Allocation::Adapters::Notification
{
/// Заглушка отправителя email-уведомлений.
class EmailSenderStub
{
public:
/// Имитирует отправку email-уведомления.
void operator()(const std::string& to, const std::string& message) const
{
Allocation::Loggers::GetLogger()->Debug(
std::format("Sending email to {}: {}", to, message));
}
};
}
Пример применения:
namespace Allocation::ServiceLayer::Handlers
{
/// Концепция для отправителей уведомлений.
template <typename T>
concept NotificationSender = requires(T t, const std::string& to, const std::string& msg) {
{ t(to, msg) } -> std::same_as<void>;
};
/// Отправитель уведомлений.
template <typename Message, NotificationSender Notifier>
requires std::derived_from<Message, Domain::Events::AbstractEvent>
class NotificationHandler
{
public:
/// notifier - Отправитель уведомлений.
NotificationHandler(Notifier notifier = {}) : _notifier(std::move(notifier)) {}
/// Отправляет уведомление.
/// В данном примере адрес получателя и текст сообщения захардкожены.
void operator()(std::shared_ptr<Message> event) const
{
_notifier("stock@made.com", std::format("Out of stock for {}", event->sku));
}
private:
Notifier _notifier;
};
/// Отправляет уведомление по электронной почте,
/// по событию "Нет в наличии товара".
using SendOutOfStockNotification = NotificationHandler<Domain::Events::OutOfStock,
Allocation::Adapters::Notification::EmailSenderStub>;
/// Далее регистрируем SendOutOfStockNotification в шине сообщений как функтор
}
Тестирование. Методология TDD
Материал параграфа основан на книгах: «Паттерны разработки на Python: TDD, DDD и событийно-ориентированная архитектура», «Микросервисы. Паттерны разработки и рефакторинга» и «Экстремальное программирование: разработка через тестирование».
Методология TDD
Правила TDD:
Новый код пишется только после того, как создан автоматизированный тест, который изначально падает.
Любое дублирование кода устраняется как можно скорее.
Эти правила влекут за собой требования к процессу и окружению: быстрый цикл сборки/запуска тестов, ответственность разработчиков за тесты, архитектура с малосвязанными компонентами.
Цикл разработки в TDD:
Red — написать тест, который не проходит.
Green — реализовать минимально необходимый код, чтобы тест прошёл.
Refactor — улучшить структуру и устранить дублирование, сохранив проход тестов.
Цикл повторяется до достижения требуемой функциональности.
Типы тестов
В книге «Микросервисы. Паттерны разработки и рефакторинга» вводятся уровни тестов:
Модульные тесты (Unit Tests)
Интеграционные тесты (Integration Tests)
Компонентные тесты (Component Tests)
Сквозные тесты, E2E тесты (End-to-End Tests)
1. Модульные тесты
Модульные тесты применяются для проверки поведения отдельных компонентов приложения. В них проверяются инварианты, граничные условия и другие особенности логики. Важно, что такие тесты не взаимодействуют с внешними системами — например, с СУБД или брокером сообщений.
Гексагональная архитектура позволяет использовать фейковые реализации портов через инъекции зависимостей. Такой подход позволяет изолировать тестируемый код от внешней инфраструктуры.
Пример — фейковый репозиторий:
namespace Allocation::Tests
{
/// Фейковый репозиторий для тестирования.
class FakeRepository final : public Domain::IUpdatableRepository
{
public:
FakeRepository() = default;
/// init - Инициализирующий список продуктов.
FakeRepository(const std::vector<Domain::ProductPtr>& init)
{
for (const auto& prod : init)
_skuByProduct.insert({prod->GetSKU(), prod});
}
/// Добавляет или обновляет продукт в репозитории.
void Add(Domain::ProductPtr product) override
{
_skuByProduct.insert_or_assign(product->GetSKU(), product);
}
/// Получает продукт по артикулу.
[[nodiscard]] Domain::ProductPtr Get(const std::string& SKU) override
{
auto it = _skuByProduct.find(SKU);
return (it != _skuByProduct.end()) ? it->second : nullptr;
}
/// Получает продукт по ссылке на партию.
[[nodiscard]] Domain::ProductPtr GetByBatchRef(const std::string& batchRef) override
{
for (const auto& [_, product] : _skuByProduct)
if (product->GetBatch(batchRef) != std::nullopt)
return product;
return nullptr;
}
/// Обновляет продукт (используется с TrackingRepository).
void Update(Domain::ProductPtr product, int) override
{
_skuByProduct.insert_or_assign(product->GetSKU(), product);
}
std::unordered_map<std::string, Domain::ProductPtr> _skuByProduct;
};
}
Фейковая реализация Unit of Work:
namespace Allocation::Tests
{
/// Фейковая реализация Unit of Work для тестирования.
class FakeUnitOfWork final : public ServiceLayer::UoW::AbstractUnitOfWork
{
public:
FakeUnitOfWork() : AbstractUnitOfWork(_repo) {}
/// Получение сессии базы данных.
/// Возвращает фейковую сессию подключения к базе данных.
Poco::Data::Session GetSession() noexcept
{
return new FakeSessionImpl("connection_string");
}
private:
FakeRepository _repo;
};
}
Пример теста:
namespace Allocation::Tests
{
class Handlers_TestAddBatch : public testing::Test
{
public:
static void SetUpTestSuite()
{
ServiceLayer::MessageBus::Instance()
.SetCommandHandler<Allocation::Domain::Commands::CreateBatch>(
ServiceLayer::Handlers::AddBatch);
}
};
TEST_F(Handlers_TestAddBatch, test_for_new_product)
{
FakeUnitOfWork uow;
ServiceLayer::MessageBus::Instance().Handle(
Make<Domain::Commands::CreateBatch>("b1", "CRUNCHY-ARMCHAIR", 100), uow);
/// проверка обработчика в изоляции от инфраструктуры
EXPECT_TRUE(uow.GetProductRepository().Get("CRUNCHY-ARMCHAIR"));
EXPECT_TRUE(uow.IsCommited());
}
}
2. Интеграционные тесты
Интеграционные тесты проверяют корректность взаимодействия компонентов системы с внешними элементами — например, с СУБД.
Для подготовки инфраструктуры удобно использовать фикстуры, которые настраивают окружение перед тестами и очищают его по завершению.
Пример фикстуры для работы с СУБД:
/// Фикстура для инициализации БД.
class Database_Fixture : public testing::Test
{
public:
/// Настраивает пул сессий.
static void SetUpTestSuite()
{
if (auto& sessionPool = Adapters::Database::SessionPool::Instance();
!sessionPool.IsConfigured())
{
auto config = ReadDatabaseConfigurations();
sessionPool.Configure(config);
}
}
protected:
/// Выполняется перед выполнением каждого теста
void SetUp() override
{
_session = Adapters::Database::SessionPool::Instance().GetSession();
_session.begin();
}
/// Выполняется по завершению каждого теста
void TearDown() override
{
try
{
_session.rollback();
}
catch (...)
{
}
}
Poco::Data::Session _session{Adapters::Database::SessionPool::Instance().GetSession()};
};
Пример интеграционного теста:
namespace Allocation::Tests
{
TEST_F(Database_Fixture, test_get_by_batchref)
{
Adapters::Repository::SqlRepository repo(_session);
Domain::Batch b1("b1", "sku1", 100);
Domain::Batch b2("b2", "sku1", 100);
Domain::Batch b3("b3", "sku2", 100);
auto p1 = std::make_shared<Domain::Product>("sku1", std::vector<Domain::Batch>{b1, b2});
auto p2 = std::make_shared<Domain::Product>("sku2", std::vector<Domain::Batch>{b3});
/// Сохраняем агрегаты в БД
repo.Add(p1);
repo.Add(p2);
/// Проверяем работу SqlRepository
/// Загружаем ранее сохранённые агрегаты
EXPECT_EQ(repo.GetByBatchRef("b2"), p1);
EXPECT_EQ(repo.GetByBatchRef("b3"), p2);
}
}
Так как в фикстуре сессия базы данных открывается, но изменения не фиксируются (rollback
в TearDown
), изменения откатываются в БД.
3. Компонентные тесты
Компонентные тесты применяются для проверки самодостаточных единиц приложения.
В контексте микросервисной архитектуры такой единицей является отдельный сервис.
При реализации компонентных тестов взаимодействующие с тестируемым сервисом системы заменяются заглушками или упрощёнными эмуляторами.
Описываемом проекте разрабатывается один сервис, поэтому компонентные тесты как отдельный уровень тестирования не применяются.
4. Сквозные тесты
Сквозные тесты (end-to-end, E2E) применяются для проверки работы всего приложения.
На этом уровне приложение рассматривается как «чёрный ящик»: тесты взаимодействуют с ним через API, проверяют корректность поведения с точки зрения внешнего клиента.
В проекте для написания e2e-тестов используется ЯП Python.
Пример Е2Е теста:
def test_happy_path_returns_202_and_batch_is_allocated():
orderid = random_orderid()
sku, othersku = random_sku(), random_sku("other")
earlybatch = random_batchref(1)
laterbatch = random_batchref(2)
otherbatch = random_batchref(3)
api_client.post_to_add_batch(laterbatch, sku, 100, "2011-01-02T00:00:00")
api_client.post_to_add_batch(earlybatch, sku, 100, "2011-01-01T00:00:00")
api_client.post_to_add_batch(otherbatch, othersku, 100, None)
r = api_client.post_to_allocate(orderid, sku, qty=3)
assert r.status_code == 202
r = api_client.get_allocation(orderid)
assert r.ok
assert r.json() == [{"sku": sku, "batchref": earlybatch}]
Количество тестов
Для описания распределения количества тестов по уровням используют геометрические фигуры. Наиболее распространены:
Пирамида
Трапеция
Перевёрнутая пирамида
Уровни тестов располагаются послойно: начиная с модульных тестов в основании и заканчивая E2E-тестами на вершине.
Каждая фигура отражает пропорцию тестов в системе:
Пирамида — основное количество приходится на модульные тесты, меньше — на интеграционные, ещё меньше — на компонентные и совсем немного на E2E.
Трапеция — упор делается на интеграционные тесты, при этом модульных относительно меньше.
Перевёрнутая пирамида — большая часть тестов пишется на уровне E2E, при этом модульные тесты практически отсутствуют.
Перевёрнутая пирамида считается антипаттерном, так как такие тесты:
выполняются медленно,
широко охватывают систему,
сложно поддерживаются.
В проекте придерживаемся пирамидальной стратегии: основной упор делаем на модульные тесты, дополняя их интеграционными и минимальным количеством E2E
Заключение
В статье была рассмотрена реализация сервиса на C++ с опорой на идеи книги «Паттерны разработки на Python: TDD, DDD и событийно-ориентированная архитектура». Обсуждены ключевые слои приложения, архитектурные решения, подходы к тестированию и особенности практической реализации.
В дальнейшем планируется перенести проект на фреймворк userver.
Список литературы
Гарри Персиваль, Боб Грегори. Паттерны разработки на Python: TDD, DDD и событийно-ориентированная архитектура.
Мартин Фаулер. Шаблоны корпоративных приложений.
Крис Ричардсон. Микросервисы. Паттерны, разработка и рефакторинг.
Клаус Игльбергер. Проектирование программ на C++. Принципы и паттерны.
Влад Хононов. Изучаем DDD — предметно-ориентированное проектирование.
Кент Бек Экстремальное программирование: разработка через тестирование
Благодарность
Автор благодарит организацию «Тис-Центр», в которой работает, за поддержку инициативы, а также коллег, чьи советы помогли в подготовке материала.
Отдельная благодарность:
Номхоеву Владимиру Николаевичу
Галчину Дмитрию Андреевичу
Jijiki
так получается книга ценная, её можно повторить и сделать - движок сущностей, прикрутить отрисовку. как я понял в каждой сущности еще может быть своя логика.
а если по стандарту идти то это сущности для магазина