One contract. Any format. Zero mapping.

Любой C+±проект, где данные не остаются внутри одного процесса, рано или поздно упирается в одно и то же: одну и ту же структуру нужно уметь показать в дебаге, записать в бинарный протокол, отдать по protobuf во внешний сервис и залогировать в JSON. Без общего механизма получается россыпь ручных мэппингов на каждый тип: toJson, toProto, debugPrint, writeBinary. Каждое изменение схемы приходится синхронизировать руками во всех них.

N классов данных × M форматов = N×M ручных мэппингов

Мы не единственные, кто в C++ уперся именно в эту стену. Рядом стоят reflect-cpp (C++20-рефлексия, JSON/BSON/CBOR/msgpack/TOML/XML/YAML/Avro/Cap’n Proto и другие), serde-cpp (вдохновлен Rust serde) и более старые Boost.Serialization/Cereal. Вопрос в том, что именно предлагает CONTRACT в дополнение к “одна схема - много форматов”, раз эта идея уже не нова.

Не еще один сериализатор

CONTRACT решает N×M иначе, чем библиотека сериализации:

N контрактов + M адаптеров

Схема объявляется один раз, прямо в C+±структуре:

struct Order {
    std::uint64_t id;
    std::string customer;
    double amount;
    bool paid;

    CONTRACT(Order,
        (id, 1),
        (customer, 2),
        (amount, 3),
        (paid, 4)
    )
};

CONTRACT(...) не сериализует ничего сам. Он дает стабильный список полей: id, имя, тип, порядок обхода. Им может воспользоваться сериализатор, а может и что-то другое: валидатор, экспортер схемы, аудит-дамп. CONTRACT - не рантайм-рефлексия, не generic-сериализатор и не schema-first кодогенератор. Ядро отвечает за то, что такое поле и как до него добраться, а не за то, как оно должно выглядеть в wire-формате. Это уже задача адаптера.

Отсюда и разница с reflect-cpp: reflect-cpp отвечает на вопрос “как сериализовать структуру в N форматов”. CONTRACT отвечает на другой вопрос: “как дать структуре стабильный контракт, которым сериализация может воспользоваться, а может и не воспользоваться”.

Было / стало

Без общей схемы Order из примера выше выглядела бы примерно так:

struct Order {
    std::uint64_t id;
    std::string customer;
    double amount;
    bool paid;

    std::string toJson() const {
        std::ostringstream out;
        out << "{\"id\":" << id
            << ",\"customer\":\"" << customer << "\""
            << ",\"amount\":" << amount
            << ",\"paid\":" << (paid ? "true" : "false") << "}";
        return out.str();
    }

    void debugPrint(std::ostream& out) const {
        out << "Order{id=" << id << ", customer=" << customer
            << ", amount=" << amount << ", paid=" << paid << "}";
    }

    void writeBinary(std::vector<std::uint8_t>& buf) const { /* ... */ }
};

Плюс отдельный order.proto, protoc, сгенерированные .pb.h/.pb.cc и ручной toProto/fromProto между Order и OrderProto. Четыре формата - четыре места, куда нужно не забыть внести любое изменение схемы.

С CONTRACT Order объявляется один раз (см. выше), а дальше формат - это просто выбор адаптера:

contract::cout << order;
contract::adapters::json::to_string(order);
binary_out << order;
proto_out << order;

Order в этом коде не меняется вообще - меняется только то, через что его пропускают.

Разница видна и в обратную сторону: если нужно добавить новое поле, в CONTRACT(...) это одна строка. В “было”-варианте это правка сразу в нескольких местах: toJson, debugPrint, writeBinary, .proto-файле и ручном toProto/fromProto. И в каждом легко забыть.

Чего мы хотели от модели

На этом простом примере уже видна модель шире, чем “одна декларация вместо N мэппингов”. Хотелось, чтобы:

  1. Контракт был стабильной схемой, объявленной один раз в самом C++ типе, независимо от формата:

    • id

    • имя

    • тип

    • способ доступа к полю.

  2. Поле не обязано было быть физическим членом структуры:

    • переиспользовать схему через наследование (BASE)

    • вычислять значение на лету (PROPERTY)

    • ссылаться на данные, которыми тип не владеет (REFERENCE).

  3. Формат и поведение целиком принадлежали адаптеру, а не ядру: сериализация тут только одна из возможных ролей, не единственная:

    • сериализация (protobuf, JSON, compact, binary, …)

    • валидация

    • экспорт схемы

    • аудит-дамп.

  4. Поверх полей был отдельный слой атрибутов: политика, которую разные адаптеры трактуют по-своему:

    • security

    • check

    • unit.

  5. Все это не создавало излишнюю нагрузку на рантайм.

Ядро контракта

В основе лежит то, что мы хотели первым пунктом - стабильная схема с id, именем, типом и способом доступа к полю. На практике это небольшой compile-time API, поверх которого построено все остальное:

contract::field_count<Order>();                        // сколько полей
contract::field_at<0, Order>();                        // дескриптор поля по индексу
contract::dispatch_field_by_id<Order>(2, fn);          // найти поле по id
contract::dispatch_field_by_name<Order>("amount", fn); // то же самое по имени
contract::type_name<Order>();                          // "Order"

Дескриптор поля несет id, имя и способ доступа (get/set/ref). Этого достаточно, чтобы адаптер построил вокруг него что угодно, от wire-кодека до дебаг-дампа, ни разу не заглянув внутрь самой структуры напрямую.

Кстати, зачем вообще id, а не просто имя: дело не только в размере на wire (имя длиннее, дольше сравнивать при упаковке) - реальная опасность в другом. Если один и тот же идентификатор, имя это или число, переиспользовать для поля с несовместимым типом, старый и новый код начнут по-разному трактовать одни и те же байты. Для этого случая в контракте можно явно зарезервировать id (contract::schema::reserved_id(...)) - сегодня это чисто декларативный маркер, ни один адаптер его пока не проверяет.

Гибкость контракта: BASE, PROPERTY и REFERENCE

Это и есть второй пункт: поле не обязано быть физическим членом структуры. У CONTRACT для этого есть три механизма.

BASE(Type, offset) подключает контракт другого C+±типа как часть текущего через обычное наследование, со сдвигом id, чтобы поля базового типа не столкнулись с полями производного:

struct Header {
    std::uint64_t request_id;

    CONTRACT(Header, (request_id, 1))
};

struct Event : public Header {
    std::string name;

    CONTRACT(Event,
        BASE(Header, 100),
        (name, 1)
    )
};

Event получает request_id под id 101 (100 + 1) и свое name под id 1. Общая часть схемы объявлена один раз в Header и переиспользуется, а не копируется в каждый тип, где она нужна.

PROPERTY(name, id, type) - поле контракта, за которым не стоит физический член структуры, а стоит пара contract_get/contract_set:

struct Metric {
    std::uint32_t raw_count = 0;

    CONTRACT(Metric,
        (raw_count, 1),
        PROPERTY(doubled_count, 2, std::uint32_t)
    )

    std::uint32_t contract_get(const contract_fields::doubled_count&) const {
        return raw_count * 2;
    }

    void contract_set(const contract_fields::doubled_count&, std::uint32_t value) {
        raw_count = value / 2;
    }
};

Адаптеры видят doubled_count как обычное поле: читают и пишут его тем же путем, что и raw_count, хотя в памяти Metric такого поля вообще нет. Значение вычисляется на лету через contract_get/contract_set.

REFERENCE(name, id) - третий вид поля: контракт на данные, которыми структура не владеет, а только ссылается. Этот механизм используется в структурном логгере CONTRACT, чтобы не копировать значение на горячем пути:

template<class T>
struct payload_field {
    std::string_view name;
    const T& value;

    CONTRACT(payload_field,
        (name, 1),
        REFERENCE(value, 2)
    )
};

value - ссылка, а не копия; адаптер читает ее как обычное поле контракта, но лог-вызов не платит за аллокацию/копирование логируемого значения.

Экосистема адаптеров

Здесь работает третий пункт - формат и поведение принадлежат адаптеру, а не ядру. Сегодня в CONTRACT шесть семейств адаптеров, и не все из них симметричны по чтению/записи. Ниже: по убыванию значимости и полноты реализации:

Адаптер

Запись

Чтение

Комментарий

protobuf

полный: wire-совместим с настоящим protobuf, обгоняет libprotobuf в 20/28 замеров (отдельная статья)

binary

полный: нативная раскладка без wire-оверхеда, самый быстрый вариант - но не кросс-платформенный формат по умолчанию

compact

полный: свой компактный wire-формат, единственный, кто сегодня реально пропускает незнакомые поля при чтении

JSON

-

только запись, зато с security-режимами (redact/omit) - на нем построен structured logging

structured logging

-

тонкая надстройка над JSON-адаптером для логов, не отдельный wire-формат

console/debug

-

человекочитаемый дебаг-вывод

YAML

-

только чтение: строгий config-reader, а не экспортный формат - писать в YAML CONTRACT пока не умеет

Общая для всех архитектура одна и та же: contract знает поля и их идентичность и ничего не знает про формат, io работает с байтами и курсором. А вот writer/reader и codec<T> уже принадлежат конкретному адаптеру и знают его wire-правила - у каждого формата свои.

Слой атрибутов

Четвертым пунктом был отдельный слой атрибутов поверх полей, который вешается на поле в списке рядом с id и интерпретируется каждым адаптером по-своему. Набор словарей расширяем - новый можно добавить, не трогая ядро; сегодня реально работают security и check.

Возьмем типичное событие авторизации с PII и секретом внутри:

struct AuthEvent {
    std::string user_email;
    std::string access_token;
    std::uint64_t duration_ns;

    CONTRACT(AuthEvent,
        (user_email, 1, contract::security::sensitive()),
        (access_token, 2,
            contract::security::secret(),
            contract::security::no_log(),
            contract::security::encrypt()),
        (duration_ns, 3)
    )
};

Один и тот же AuthEvent, без единого if в бизнес-коде, ведет себя по-разному в зависимости от адаптера. Console/debug и JSON пока учитывают secret/no_log/sensitive - у каждого свой дефолт, а как включить нужный режим через options, показывает пример ниже. А в binary encrypt() сегодня - это просто обфускация по ключу, не тяжелая криптография. Такая per-field политика возможна и у обычных сериализаторов (у protobuf есть свои field options); разница CONTRACT в том, что один и тот же атрибут одинаково понимают разные, независимо реализованные адаптеры - а не в том, что для остальных это принципиально недостижимо.

Так это выглядит в структурированном логе (упрощенный вариант examples/logging.cpp):

struct SecretPayment {
    std::uint64_t order_id;
    std::string_view token;

    CONTRACT(SecretPayment,
        (order_id, 1),
        (token, 2, contract::security::secret()))
};

contract::logging::options opt{};
opt.json.secret = contract::adapters::json::security_mode::redact;
contract::logging::logger log{out, opt};

SecretPayment secret_payment{18, "tok_live_123"};
log.info("payment_sensitive", "Captured sensitive payment metadata",
    contract::logging::attribute("payment", secret_payment));

Фрагмент вывода (полностью - см. examples/logging.cpp):

{"name":"payment_sensitive","attributes":[{"name":"payment","value":{"order_id":18,"token":"<redacted>"}}]}

token попал в лог как "<redacted>", потому что так решил вызывающий код через opt.json.secret.

Не в ущерб скорости

И последнее, пятое: ничего из этого не должно создавать лишнюю нагрузку на рантайм. Одна декларация вместо N×M - это, в первую очередь, про удобство, но это не покупается ценой производительности: protobuf-адаптер CONTRACT сравнивали с настоящим libprotobuf на 14 сценариях. CONTRACT оказался быстрее. Подробности, методология и исключения - в отдельной статье про protobuf-адаптер.

Чего CONTRACT не делает

Чтобы не создавать впечатления, что это решение “на все”:

  • не рантайм-рефлексия - обход полей раскрывается на этапе компиляции.

  • не generic-сериализатор - формат и его правила целиком принадлежат адаптеру, ядро формат не выбирает и не диктует.

  • не schema-first кодогенератор - нет отдельного файла схемы и шага генерации, схема - это сама C++ структура.

  • не место для буферов, SQL или стороннего рантайм-кода - это ответственность конкретного адаптера, а не ядра.

Пример: конфиг из YAML + дебаг-вывод

Напоследок - код из репозитория (examples/yaml_file_read.cpp): один и тот же контракт читает YAML-адаптер, а печатает - debug-адаптер.

struct PaymentConfig {
    std::string service;
    std::uint32_t port = 0;
    bool enabled = false;
    std::vector<std::string> tags;

    CONTRACT(PaymentConfig,
        (service, 1),
        (port, 2),
        (enabled, 3),
        (tags, 4))
};

contract::adapters::yaml::reader<contract::io::file_buffer_input> in(
    contract::io::file_buffer_input{"payment_config.yaml"});

PaymentConfig config{};
in >> config;

contract::cout.debug() << config;

При таком payment_config.yaml:

service: payment
port: 8080
enabled: true
tags:
  - api
  - payments
  - production

вывод - снят с собранного бинарника:

PaymentConfig:
  service: "payment"  # #1 std::string
  port: 8080          # #2 u32
  enabled: true       # #3 bool
  tags:               # #4 std::vector<std::string>, size=3
    - "api"           # [0]
    - "payments"      # [1]
    - "production"    # [2]

Тот же PaymentConfig, тот же контракт. Id и тип каждого поля попадают в вывод сами, без единой строчки кода, написанной специально под форматирование.

Почему макрос, а не C++26 reflection

Не отменит ли reflection нужность CONTRACT целиком? C++26 reflection умеет перечислять члены структуры без макроса. Это, скорее всего, действительно упростит объявление и реализацию контракта - меньше ручного текста на перечисление физических полей. Но сама модель никуда не денется: CONTRACT все равно должен определить, что такое стабильный id, который не меняется при эволюции схемы (см. выше), что такое атрибут-политика (security::secret(), schema::reserved_id()), и что считать полем, если физического члена за ним нет (PROPERTY). И адаптеров это вообще не касается: они как работали с уже собранным контрактом, так и продолжат работать, каким бы способом ни была объявлена схема - макросом или рефлексией. Reflect-cpp уже сегодня показывает, чего не хватает одной рефлексии для этой модели: ни стабильного id, ни attribute-слоя, ни вычисляемых полей у него нет.

А сами макросы - не плохая ли это практика? Отчасти справедливо: текстовая подстановка без области видимости - это реальная цена. А вот с нечитаемыми ошибками компиляции мы прицельно боролись: опечатался и дал двум полям один id - падает понятный static_assert ("CONTRACT field ids must be unique after BASE offsets are applied"), а не страница шаблонного мусора. Но CONTRACT(...) - не макрос, который прячет логику или control flow; он генерирует декларативные дескрипторы полей, тем же путем, что Q_OBJECT в Qt, TEST(...) в gtest или BOOST_DESCRIBE_STRUCT в Boost.Describe. И пока static reflection не стала мейнстримом, это самый практичный инструмент, чтобы объявить метаданные поля один раз, в самом C+±типе.

Итог

Смысл CONTRACT простой: схема объявляется один раз рядом с типом, после чего одни и те же данные можно писать в binary или protobuf, читать из YAML, выводить в debug-представлении или отправлять в структурированный лог — без отдельных списков полей и ручных мэппингов для каждого формата. При этом адаптеры не платят за удобство лишней работой в рантайме.

Один контракт, разные форматы, никаких ручных мэппингов.

CONTRACT - открытый проект. Если вам интересны compile-time метаданные, сериализация или разработка новых адаптеров, присоединяйтесь. Буду рад обратной связи, обсуждению архитектуры и участию в развитии библиотеки.

Код - github.com/antako76/Contract.

Комментарии (12)


  1. maaGames
    01.08.2026 05:33

    Я правильно понял, что нет поддержки версионности объектов? Это очень сильно ограничивает применимость, несмотря на все очевидные плюсы.


    1. Antako Автор
      01.08.2026 05:33

      Зависит от того, что именно понимать под версионностью.

      Сам CONTRACT задаёт основу для эволюции схемы: у полей есть стабильные ID, а формат и правила чтения принадлежат адаптеру. Поэтому конкретный адаптер может реализовать нужную стратегию — пропуск неизвестных полей, сохранение default-значений для отсутствующих, строгую проверку схемы или явные преобразования.

      В compact базовая эволюция схемы уже реализована: неизвестные поля пропускаются, а при отсутствии известных полей сохраняются их текущие или default-значения. Поэтому поля можно добавлять, удалять и переименовывать без изменения ID — старые и новые сообщения остаются читаемыми.

      Protobuf-адаптер сейчас намеренно строгий: неизвестное поле считается ошибкой. Для совместимости при развитии схемы reader должен пропускать неизвестные значения с учётом их wire type. Это локальное и вполне реализуемое расширение, которое логично оформить как настройку адаптера. Буду благодарен за issue, особенно с описанием вашего сценария.

      Полноценной системы миграций v1 → v2 → v3 для несовместимых изменений типов или семантики сейчас действительно нет. Это отдельный слой. Поэтому интересно, что требуется в вашем случае: rolling upgrade сервисов или чтение объектов, сохранённых несколько лет назад?


      1. Antako Автор
        01.08.2026 05:33

        На уровне схемы это дополняется словарём reserved_id/reserved_range/deprecated/alias — можно явно объявить, что конкретный id зарезервирован или устарел, прямо в контракте, а не в комментарии рядом с полем. Сегодня это декларативная информация: ни один адаптер её не проверяет в рантайме, но история схемы уже фиксируется в одном месте.


      1. maaGames
        01.08.2026 05:33

        То есть, если нужна версионность, то поле версии надо самостоятельно добавить, как обычный член класса и необходимое поведение реализовать в адаптере, в первую очередь проверяя это поле? Но мне показалось, что тут киллер-фича, что адаптер универсален и пишется один раз. Написал адаптер для какого-то формата (пусть json), передаёшь в него любые объекты с заданным CONTRACT и он их сохраняет-загружает.

        Пример... Вопрос возник сам-собой, как самая первая очевидная мысль, без конкретного примера. Допустим, бинарный формат. Была какая-то переменная во float и её заменили на double. Или, ещё лучше, был int32, потом его заменили на int64, а потом заменили вообще на текстовый BCD. И надо уметь считать все три версии сохранённых данных. Из БД или из файла сохранения игры, например. Откуда-то, куда данные могут быть прочитаны спустя месяцы и годы обновлений.

        Даже если в прямом эфире записать и сразу же прочитать, например, при передаче данных между устройствами, но на которых установлены разные версии приложения. Когда улиентское приложение ещё старое, а сервер уже новый и блокировать работу клиента до получения обновления не хочется...


        1. Antako Автор
          01.08.2026 05:33

          адаптер действительно универсален, его менять не нужно, ты просто используешь его. ну и клиллер фича еще в том что ты пишешь одну схему и работают все поддерживаемые адаптеры. ты можешь данные передавать в сеть по протобафу, выводить в лог в json, выводить в дебаг на экран, писать compact в файл. и это просто удобно)

          по поводу совместимости типов, тут уже дело адаптера конкретного, тот же протобаф и compact целые числа сжимают и любое целое можно распаковать в другое. с плавающей точкой сложнее, там это разные типы и каста нет. но при желании это можно сделать в кодеках типов адаптеров. BCD примерно тоже самое тоже нужно делать поддержку конверта в codec<T> - они в целом для этого и вынесены отдельно.

          а вот если добавилось новое поле или удалилось, то все проблемы решаются легко за счет ID в схеме, как я писал выше.

          на практике этого всегда почти хватало: типы полей меняются не часто, самый частый кейс int закрывается.
          расширение схемы работает, при получении старым клиентом новой схемы при некоторой осторожности все будет работать: новые поля, которых еще нет, пропустятся, старые которые удалили, получат дефолтные значения. и все будет работать.

          такая же схема когда данные сохранил в файл, и через год читаешь. что возможно прочитаешь. мы такую схему использовали с compact и сохраняли стейт приложений для бекапа или рестарта между версиями, это работало. Единственно ограничение когда у тебя схема меняется настолько что нужно писать конвертер от v1 к v2 потому что у тебя все поменялось и ни о какой совместимости речи уже на уровне схемы не идет и автоматически тут ничего не сделать. можно подумать над слоем версионности структур, но это усложнить CONTRACT и вводить в типы версии, и функции конверта... нада подумать.

          все твои пожелания можно закрыть через приведение типов в конкретных адаптерах в codec<T>. Думаю даже это не сильно снизит скорость работы, hot path можно сделать на совпадения типа, а попытку каста на fallback.

          если интересно попробовать, опиши где нужна лучшая совместимость, там очень небольшие фиксы)

          это сигнатура кодека:
          codec<T>::read(Reader&, Field&, wire_type wire, T& value)

          в ее придется добавить ветку на fallback

          if (wire == detail::wire_type::fixed64) {


          1. maaGames
            01.08.2026 05:33

            Пока что уровня понимания хватает только на то, что вместо версионности объекта следует делать отдельные версии объекта с последующей внешней конвертацией при необходимости. То есть делать Class_v1, Class_v2, Class_v3, а не изменять один и тот же Class с течением времени.


            1. Antako Автор
              01.08.2026 05:33

              на самом деле новый класс нужен далеко не всегда, это самый сложный путь, он нужен когда тебе нужно проводить major миграцию схемы по сути, давай на примерах: когда новый класс точно не нужен.

              давай на примерах:

              struct Order {    

              std::uint64_t id;    

              std::string customer;    

              double amount;

              CONTRACT(Order, (id, 1), (customer, 2),  (amount, 3) )

              };

              ты решил добавить новое поле  bool paid; ты решаешь какое значение по умолчанию к него будет paid = false; и записываешь его в контракт (paid, 4). и этого хватит, схема поменялось но все может работать.

              если старое приложение получило новые пакет, оно прочитает ID 4, не найдет его у себя в структуре и при желании сможет пропустить (так сейчакс делает compact адаптер, а протобаф можно просто научить).

              если новое приложение читает старые данные из бекапа, базы, импорта: оно не найдет ID 4 и останется значение по умолчанию.

              это поведение и есть ленивая миграция схемы.

              но она требует понимания что контракт данных сможет так работать, там бывает часто, но не всегда. а вот если это не подходит, как раз нужно делать Class_v1, Class_v2, Class_v3, а это отдельное боль и страдание. когда это точно нужно… когда ты поменял типы так что их даже нельзя автосконвертить через кодеки, например е тебя был int, а ты решил там хранить vector, и нужно явная функция конверта v1 - v2.

              в целом думаю через CONTRACT это на уровне схемы описать реально, и даже функуции конверта можно сделать удобные. я подумаю) это очень похоже на наследование BASE(Header, 100). типа:

              CONTRACT (Event_v2, #2

              OLD(Event_v1)

              )

              ну и функцию конверта придется явно писать на все дерево.


          1. maaGames
            01.08.2026 05:33

            Ещё показалось, что CONTRACT будет уязвим к ошибкам неосторожных модификаций, если используется формат без сохранения типов полей. Если по глупости модифицировал класс, поменяв типы полей, а при чтении старого файла попадётся поле другой размерности, то его ведь не удастся корерктн осчитать и будут испорчены все последующие поля. Либо так, либо сохраняется очень много служебных данных (столько же, сколько и в любом другом формате с сохранением типов полей, это не претензия к библиотеке, просто констатация факта). Когда выгружаешь большие объёмы данных, хочется и размер объекта сократить и плюшки не потерять. Обычно это делается как раз отдельным сохранением scheme или тому подобного в отдельном файле или прям в этом же. И вот стало интересно, как у CONTRACT с этим дела обстоят, можно ли разметку класса сохранить отдельно и при сохранении каждого поля объекта чтобы типы полей не сохранять? Раз есть обход всех полей класса, наверное, всё это можно закостылить внутри адаптера с сохранением типов в отдельный файл.


            1. Antako Автор
              01.08.2026 05:33

              тут конечно от адаптера все зависит.
              лучше всего это поддерживает в compact: там типы записываются так что любое поле может быть пропущено. то есть любую структуру можно прочитать, не знаю куда она должна быть декодирована, будь то простой тип, вектор, структура, вложенная древовидная структура. не нашли куда ее расспокавать по ID и ее просто скипнут из бинарной записи, и данные не будут испорчены, следующие данные будут прочитаны.  Но тут вопрос к кодекам, они не должны вернуть ошибку. как таковая схема не нужна, текущая схема у тебя уже в коде.

              типы пишутся вместе с данными как wire_type, но тут не имена типов конечно. и пишутся размеры которые облегчают декодирование.

              или у тебя вопрос а можно ли схему данных получить динамически и сохранить как либы протобафа делают при генерации? тут ответ да, но пока не сделано, но это достаточно просто, к примеру кодек дебаг адаптера почти все это делает и даже больше. в планах есть генерация схемы.

              PaymentConfig:

                service: "payment"  # #1 std::string

                port: 8080          # #2 u32

                enabled: true       # #3 bool

                tags:               # #4 std::vector<std::string>, size=3

                  - "api"           # [0]

                  - "payments"      # [1]

                  - "production"    # [2]


    1. Antako Автор
      01.08.2026 05:33

      Довели разбор до рабочей схемы на уровне типа:

      CONTRACT(Event_v1, VERSION(1))
      CONTRACT(Event_v2, VERSION(2, Event_v1))

      плюс явный конвертер:

      Event_v2 contract_upgrade(const Event_v1& old);

      Тег версии и связь с предыдущей версией объявляются в одном месте, конвертер ищется через ADL - как contract_get/contract_set у PROPERTY. Если конвертера нет, код просто не скомпилируется.
      И тут вроде бы все стройно, дерево конвертеров, понятная линковка функций, ошибки компиляции, можно убирать старые версии обрубив рут по дереву.

      Но есть честное ограничение, и оно не про CONTRACT конкретно. На уровне wire-формата (возьмём protobuf) версию как обычное поле надёжно не поставить: спецификация не гарантирует порядок полей на wire, а сторонний protobuf-райтер (Java, Python, любой не-CONTRACT) вообще не обязан класть тег версии первым.

      И тут не то чтобы непонятно, что делать... реальный protobuf-мир эту задачу и не решает "внутри сообщения". Она обходится: либо версия живёт в имени типа/эндпоинта (GetUserV2), либо через явный конверт вроде google.protobuf.Any, где тип - отдельное поле снаружи payload'а, а не внутри него.

      То есть это можно сделать до одного конкретного адаптера, который на уровне wire будет это поддерживать, и этот формат не что-то обще принятое... и все остальные проблему обкостыливают стороной. Но если кто знает формат где это поддерживается на wire уровне напишите, это прям приоритезирует поддержку в схеме.


  1. SilverTrouse
    01.08.2026 05:33

    Если нужно версии добавлять на контракты, то можно использовать аннотации рефлексии что поиде полностью покрывает ваши требования. Для реализации уже функций внутри структуры увы и ах, выносим за класс. А так можно тащить в проект тот же glaze или boost::pfr и не париться


    1. Antako Автор
      01.08.2026 05:33

      вот как C++26 будет в глубоком проде, сразу можно будет сильно переписать core, сам жду) а glaze/boost::pfr - сложно с приватными полями, виртуальными свойствами, наследованием.. хотя glaze это умеет, но тоже ценой перечисления как CONTRACT.
      Но ID нет у них для более гибкой эволюции схемы (не для версионирования), понятно что для логов эволюция не нужна, но для бекапа в файл или передачи по сети очень полезно. И имена в бекап и сеть дорого... одна из причин органиченного использования json.