Гексагональная архитектура в C++: как перестать бояться изменений и полюбить тесты
Вы когда-нибудь переписывали модуль работы с базой данных, а затем понимали, что нужно менять половину бизнес-логики? Или пытались покрыть юнит-тестами класс, который тянет за собой три тонны зависимостей из внешнего мира? Если да — добро пожаловать в клуб. К счастью, у нас есть инструмент, который решает эти проблемы раз и навсегда: гексагональная архитектура (она же архитектура Ports & Adapters).
В этой статье мы разберем, как применить эту мощную концепцию в C++. Вы увидите не абстрактные схемы, а реальный код, который можно использовать прямо сейчас. И да, мы сделаем это без фанатизма — так, чтобы и legacy проект не взорвался, и новый код был чистым.
Что такое гексагональная архитектура (и при чем тут шестиугольник)?
Представьте себе ядро вашего приложения — это чистая бизнес-логика, которая не знает ни про базы данных, ни про REST API, ни про графический интерфейс. Вокруг этого ядра располагаются порты (интерфейсы), а еще дальше — адаптеры (конкретные реализации). Визуально это напоминает шестиугольник (гексагон), отсюда и название.
Ключевая идея: зависимости направлены внутрь. Внешний мир зависит от ядра, а не наоборот. Это позволяет менять базу данных, протокол общения или UI без изменения бизнес-логики. Звучит как мечта? Давайте посмотрим, как это реализовать на C++.
Классическая проблема: тесная связность
Вот как часто выглядит типичный C++ код:
class OrderService {
public:
void createOrder(const OrderData& data) {
// Бизнес-логика
if (data.items.empty()) throw std::runtime_error("Заказ пуст");
// А вот и проблема — прямая зависимость от базы данных
Database db("localhost", 5432);
db.connect();
db.saveOrder(data);
db.disconnect();
// И еще от email-сервиса
EmailSender sender("smtp.example.com");
sender.sendInvoice(data);
}
};
Что здесь не так? Всё. Вы не можете протестировать createOrder без реальной БД и почтового сервера. Если завтра вы решите перейти с PostgreSQL на MongoDB — придется переписывать OrderService. А если email-сервис станет асинхронным через очередь — снова правки в бизнес-логике.
Решаем проблему с помощью гексагональной архитектуры
Шаг 1: Определяем порты (интерфейсы)
Порт — это контракт, который определяет, что нужно ядру от внешнего мира, но не как это реализовано.
// port/OrderRepository.h
class OrderRepository {
public:
virtual ~OrderRepository() = default;
virtual void save(const Order& order) = 0;
virtual std::optional<Order> findById(const std::string& id) = 0;
};
// port/NotificationService.h
class NotificationService {
public:
virtual ~NotificationService() = default;
virtual void sendInvoice(const Order& order) = 0;
};
Обратите внимание: это чистые абстрактные классы. Никаких зависимостей от фреймворков, библиотек или внешних сервисов. Только бизнес-сущности.
Шаг 2: Пишем ядро (бизнес-логику)
Теперь наш сервис зависит только от интерфейсов, а не от конкретных реализаций:
// core/OrderService.h
class OrderService {
public:
OrderService(OrderRepository& repo, NotificationService& notifier)
: repository_(repo), notifier_(notifier) {}
void createOrder(const OrderData& data) {
// Валидация — чистая бизнес-логика
if (data.items.empty()) {
throw std::invalid_argument("Заказ не может быть пустым");
}
Order order = Order::create(data);
repository_.save(order);
notifier_.sendInvoice(order);
}
private:
OrderRepository& repository_;
NotificationService& notifier_;
};
Видите? Никаких упоминаний базы данных, сети или протоколов. Только бизнес-логика и вызовы портов. Это делает код невероятно тестируемым и гибким.
Шаг 3: Создаем адаптеры (реализации)
Адаптеры — это мосты между портами и внешним миром. Вот пример для PostgreSQL:
// adapter/PostgresOrderRepository.h
class PostgresOrderRepository : public OrderRepository {
public:
explicit PostgresOrderRepository(const std::string& connectionString)
: connection_(std::make_shared<pqxx::connection>(connectionString)) {}
void save(const Order& order) override {
pqxx::work txn(*connection_);
txn.exec_params(
"INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)",
order.getId(), order.getCustomerId(), order.getTotal()
);
txn.commit();
}
std::optional<Order> findById(const std::string& id) override {
pqxx::work txn(*connection_);
auto result = txn.exec_params("SELECT * FROM orders WHERE id = $1", id);
if (result.empty()) return std::nullopt;
auto row = result[0];
return Order::restore(row["id"].as<std::string>(),
row["customer_id"].as<std::string>(),
row["total"].as<double>());
}
private:
std::shared_ptr<pqxx::connection> connection_;
};
А вот адаптер для отправки email через SMTP:
// adapter/SmtpNotificationService.h
class SmtpNotificationService : public NotificationService {
public:
void sendInvoice(const Order& order) override {
// Реальная отправка через SMTP
Email email;
email.setTo(order.getCustomerEmail());
email.setSubject("Ваш заказ #" + order.getId());
email.setBody(generateInvoiceHtml(order));
smtpClient_.send(email);
}
};
Шаг 4: Собираем всё вместе (Composition Root)
В точке входа приложения (main или инициализация фреймворка) мы связываем всё воедино:
int main() {
// Создаем адаптеры
PostgresOrderRepository orderRepo("postgresql://user:pass@localhost/db");
SmtpNotificationService notifier("smtp.example.com", 587);
// Создаем ядро с внедренными зависимостями
OrderService orderService(orderRepo, notifier);
// Запускаем HTTP-сервер (еще один адаптер)
HttpServer server([&orderService](const HttpRequest& req) {
if (req.path == "/orders" && req.method == "POST") {
auto data = parseOrderData(req.body);
orderService.createOrder(data);
return HttpResponse::ok("Order created");
}
return HttpResponse::notFound();
});
server.run();
return 0;
}
Почему это работает: преимущества гексагональной архитектуры в C++
- Тестируемость без боли. Вы можете подменить реальные адаптеры на mock-объекты:
// Тест для OrderService
class MockOrderRepository : public Order