Гексагональная архитектура в C++: чистота кода без боли

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

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

Начать курс

Гексагональная архитектура в 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                

Блоги

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