Перейти к содержимому

29. Проектируем метамодель

Главы о метамодели (14-18) рассказали, как работают типы. Эта глава о том, когда и как их строить — проектировать типы, отражающие соглашения вашей организации, кодировать предметные требования и выбирать правильный уровень строгости.

Аудитория — платформенные команды, авторы типов и все, кто оказался в положении «вот так мы моделируем X в нашей организации» по отношению к другим командам. Если вы только потребляете типы стандартной библиотеки, пропустите главу — переходите к главе 30 или закройте книгу.

Библиотека типов может быть метамоделью, а не просто удобством. На набор пользовательских типов можно смотреть через две линзы. Линза «приятно иметь» трактует его как связку удобных виджетов и значений по умолчанию — меньше нажатий клавиш, диаграммы покрасивее. Линза метамодели трактует его как нотацию, которая навязывает дисциплину и диктует, как вы думаете: её принятие формирует то, как вы декомпозируете системы и рассуждаете о них. Нотация C4, упакованная в библиотеку ArchLang, — канонический пример: её словарь system / container / component — не сахар, а метод. Ваша финтех-библиотека ниже — та же штука: она делает «карточный процессор» и «PCI-сервис» первоклассными понятиями, в которых рассуждает вся организация. Выбирайте (и стройте) библиотеку за то, что она делает с вашим мышлением, а не только за компоненты, которые она вам выдаёт.

Типы — это шаблоны форм, а не классы. Тело типа — это форма, которую заполняет экземпляр: значения по умолчанию, обязательные пустые слоты, предзаполненные секции, штампуемые на каждый экземпляр. Это не ООП-класс: нет методов, нет поведения во время выполнения, нет семантики инстанцирования. Проектируя тип, вы проектируете форму, и единственные вопросы здесь — «что должен заявить каждый экземпляр?» и «с чего должен стартовать каждый экземпляр?».

Мы построим метамодель для финтех-организации: карточные процессоры, сервисы леджера, сервисы с обязательным аудитом, регулируемые вебхуки. К концу у вас будет ~6 деклараций типов и ясное понимание, как принимать эти решения для своей предметной области.

Если каждая команда пишет одни и те же пять аспектов и одни и те же три поля на каждом сервисе, у вас есть кандидат в метамодель. Конкретно:

  • Вы повторяете поля. У каждого платёжного сервиса есть ext.runbook_url, ext.processor_vendor, security.contact. Объявите тип payment_service, требующий их.
  • Вы повторяете аспекты. У каждого аналитического сервиса domain: Analytics, security.zone: Internal, data.classification: pii. Объявите тип analytics_service, каскадирующий их.
  • У вас есть инварианты, о которых забывают. Каждая внешняя интеграция должна иметь поставщика и URL контракта. external_system из стандартной библиотеки уже это требует; ваш проект может делать то же самое для предметных инвариантов.
  • Вам нужен словарь, соответствующий бизнесу. «Карточный процессор» читается лучше, чем «сервис, являющийся external_system в области PCI с URL поставщика». Типизируйте это.

Если ничего из перечисленного не подходит, типов стандартной библиотеки достаточно.

Пользовательский тип должен оправдывать себя. Тип оправдан только тогда, когда делает хотя бы одну конкретную вещь: наследует что-то, прикрепляет кастомный виджет, задаёт кастомное требование (required слот), добавляет кастомные поля или позволяет явно утверждать, что нечто является этим типом (где само утверждение и есть значение, по которому вы запрашиваете). Вот эти пять способов. Если предлагаемый тип не делает ни одного из них — бесповеденческий блок service, оборачивающий простой модуль и ничего не добавляющий, — это шум. Не изобретайте пустые типы; сначала тянитесь к простому module или service и типизируйте только тогда, когда появляется одна из пяти причин.

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

Начните с базового типа из стандартной библиотеки, затем добавляйте слои ограничений. Для финтех-примера:

service (стандартная библиотека)
payment_service (наш тип: общие требования PCI / runbook / контакт)
card_processor (наш тип: специфичные поля для исходящего карточного поставщика)
stripe_processor (тип-подсказка для экземпляра: конкретно vendor=Stripe)

Три слоя. Каждый добавляет один пакет фактов.

Два принципа:

  • Каждый слой должен отвечать на один вопрос. payment_service отвечает: «что верно для каждого модуля в области PCI?» card_processor отвечает: «что верно конкретно для каждого карточного процессора?» stripe_processor оправдывает себя только как явное утверждение — вся его ценность в том, что он позволяет запрашивать или фильтровать «каждый Stripe-процессор по всему ландшафту». Если вы никогда на нём не утверждаете (ни проекция, ни политика, ни отчёт от него не зависят), это пустой тип: удалите его и поставьте ext.processor_vendor: "Stripe" на простой card_processor. Тип, существующий лишь чтобы повторить значение поля, — это шум.
  • Не вкладывайте глубже трёх-четырёх уровней. Дальше читатели не удержат цепочку в голове. Если ваша концептуальная иерархия действительно пятислойная, подумайте, не должны ли некоторые из слоёв быть аспектами.
types.arch
export type service payment_service {
required ext.runbook_url
required security.contact
aspect {
data.classification: "pci"
compliance.regime: ["pci"] // list value — membership in the PCI regime aspect
}
"A service in PCI scope. Required runbook URL and security contact."
}

Это добавляет два требования поверх service (который лишь мягко каскадирует aspect team и aspect domain — ни одно не обязательно):

  • URL runbook.
  • Контакт по безопасности.

Два аспекта PCI (data.classification, compliance.regime) — это значения по умолчанию, которые каскадируют автоматически: каждый payment_service находится в области PCI по построению, так что требовать здесь нечего. Само владение остаётся мягким aspect team, унаследованным от service, а не обязательным полем — почему стандартная библиотека моделирует это именно так, см. главу 20, и обеспечивайте «у каждого сервиса есть команда» через policy, а не обязательный пустой слот (глава 28).

Теперь любой экземпляр:

payment_service PaymentAuthorizer {
ext.runbook_url: "https://wiki.acme.com/auth-runbook"
security.contact: "compliance@acme.com"
aspect {
team: "Payments" // soft cascade from service — conventional, not required
domain: "Payments" // soft cascade from service — conventional, not required
}
rest_create charge
}

Попробуйте сохранить без ext.runbook_url: валидация падает с «Required field ‘ext.runbook_url’ is not fulfilled and not dropped». Словарь кодирует инвариант.

Второй слой конкретно для карточного процессора:

export type payment_service card_processor {
required ext.processor_vendor
required ext.contract.url
required ext.webhook_endpoint
"A card processor talking to an external vendor. Requires vendor name,
contract URL, and the URL we expose for inbound webhooks from them."
}

Почему отдельный тип, а не три поля внутри payment_service? Потому что не каждый платёжный сервис — карточный процессор. Иерархия позволяет payment_service AccountVerification { ... } обойтись без специфичных карточных полей, оставаясь обязательным для PCI.

Экземпляр (предполагая, что external_system Stripe { ... } объявлен где-то в пакете, как в главе 27). stripeWebhook — это асинхронный интерфейс-обработчик; входящие события достигают его через ребро процесса (Stripe > StripeIntegration.stripeWebhook), а не через поле подписки:

card_processor StripeIntegration {
ext.runbook_url: "https://wiki.acme.com/stripe-runbook"
ext.processor_vendor: "Stripe"
ext.contract.url: "https://stripe.com/docs/api"
ext.webhook_endpoint: "https://api.acme.com/webhooks/stripe"
security.contact: "compliance@acme.com"
aspect {
team: "Payments"
domain: "Payments"
}
rest_create charge
rest_create refund
webhook stripeWebhook
}

Обработчик вебхука выше — просто голый webhook. Можно лучше: объявить тип, фиксирующий инварианты регулируемого вебхука. (Обязательные пустые слоты на пользовательских типах интерфейсов поддерживаются системой типов; их принудительная проверка на этапе разбора зависит от версии валидатора — перепроверьте, если целитесь в более старый набор инструментов.)

export type webhook regulated_webhook {
required signature.algorithm // hmac-sha256, ecdsa, etc.
required signature.header // HTTP header carrying the signature
required retention_days // how long the raw payload is kept
"An inbound webhook that must be HMAC-verified and audit-logged."
}

Теперь:

card_processor StripeIntegration {
// ... as above ...
regulated_webhook stripeWebhook {
signature.algorithm: "hmac-sha256"
signature.header: "Stripe-Signature"
retention_days: 365
}
}

Вебхук теперь декларирует свой собственный контракт; ревьюеры видят с одного взгляда, что он проверяется по подписи и хранится год. Добавление нового вебхука без этих трёх полей — ошибка разбора.

Каскадирование того же паттерна на слой данных:

export type database pci_vault {
required data.encryption.algorithm
required data.retention_policy_url
required ext.runbook_url
aspect {
data.classification: "pci"
compliance.regime: ["pci"]
}
"Encrypted PCI-scoped datastore. Requires encryption algorithm,
retention-policy document URL, runbook."
}

Используется как:

pci_vault PaymentVault {
data.encryption.algorithm: "aes-256-gcm"
data.retention_policy_url: "https://wiki/pci-retention"
ext.runbook_url: "https://wiki/vault-runbook"
aspect {
team: "Payments"
domain: "Payments"
data.classification: "pci" // already in template — re-asserts it for clarity
}
db_read read
db_write write
}

Обязательный пустой слот обязателен. Он фиксирует факт. Он также повышает цену объявления экземпляра. Два вопроса, которые стоит задать, прежде чем сделать поле required:

  1. Сможет ли экземпляр всегда ответить? Если у 80% экземпляров есть URL runbook, а у 20% его честно нет (сервисы на ранней стадии, внутренние инструменты), required слишком строго — это вынуждает ко лжи (ext.runbook_url: "tbd") или к театральному drop. Используйте поле с пустым значением по умолчанию и поднимайте предупреждение в скрипте CI.
  2. Достаточно ли высока цена забывания? Отсутствующий URL runbook на платёжном сервисе Tier-1 — это правда плохо. Отсутствующий слоган на сервисе хобби-проекта — нормально. required — это язык, говорящий «мы не примем отсутствие этого факта». Убедитесь, что цена соответствует.

Аспекты PCI (data.classification: "pci", compliance.regime: ["pci"]) — это значения по умолчанию, а не required, потому что они корректны для каждого payment_service по построению. Если у вас когда-нибудь появится не-PCI платёжный сервис, он не должен быть payment_service; он должен быть другим типом.

Несколько заметок от организаций, которые делают это правильно:

  • Используйте слово из бизнеса. payment_service, card_processor, audit_log — а не pci_service_v2. Тип должен читаться так, как говорит команда.
  • Не перегружайте технические типы. Тип с именем database должен быть базой данных. Если ваш тип — это «сервис, владеющий базой данных и выставляющий CRUD над ней», называйте его crud_service или aggregate_service, а не database.
  • Существительные в единственном числе. card_processor, не card_processors. Экземпляры — это единичные вещи.
  • Принято использовать lower_snake_case. Это с одного взгляда отличает пользовательские типы от имён экземпляров в UpperCamelCase.

На уровне типа у вас три режима распространения для каждого поля: локальный (без модификатора), cascade, append. Проектируя тип, думайте о том, что должно растекаться:

  • cascade version — каждый вложенный модуль наследует версию родителя, если не задаёт свою.
  • cascade widget: arch-payment-service — каждый экземпляр получает тот же визуал по умолчанию; экземпляры могут переопределить.
  • append tags — накопление тегов через вложенные уровни. Родительские tags: ["pci"] и дочерние tags: ["audited"] разрешаются в ["pci", "audited"] на потомке.

Аспекты всегда каскадируют. Поля по умолчанию локальные; на распространение вы соглашаетесь явно.

Семантика организации, а не только виджеты

Заголовок раздела «Семантика организации, а не только виджеты»

Стандартная библиотека идёт «с батарейками», но большая организация обычно хочет собственную библиотеку — и причина редко в виджетах (виджеты стандартной библиотеки переиспользуемы). Причина — это семантика, которую стандартная библиотека знать не может:

  • Требования владенияrequired maintainer и required oncall_rotation на каждом сервисе с владельцем, потому что ваша организация это предписывает.
  • Отношения отделов / подразделений — типы, кодирующие, к какому подразделению организации принадлежит модуль, так что модель несёт вашу структуру подчинённости, а не только граф вызовов.
  • Обязательные поля управленияrequired ext.compliance.contact на всём регулируемом, required data.retention_policy_url на всём, что хранит пользовательские данные.

Это настоящая семантика, которая диктует, как организация моделирует, а не украшение. Расширяйте тип стандартной библиотеки, а не переписывайте его — добавляйте свои дополнительные требования поверх service, а не переопределяйте service с нуля.

Где живут общие типы: изолированный пакет типов

Заголовок раздела «Где живут общие типы: изолированный пакет типов»

По умолчанию: держите тип близко к экземплярам, которые им пользуются — тот же пакет, предметно смежно. Тип живёт рядом с модулями, которые он штампует.

Запасной выход для большой организации — выделенный изолированный пакет типов. Когда общий словарь (payment_service, card_processor, network_segment) используется многими командами, поднимите его в собственный пакет, который экспортирует только эти типы. Пакет непрозрачен — он выставляет свой словарь и ничего больше — и любое пространство может сделать use его:

// In a consuming package's manifest
dependencies {
acme.archtypes: "../archtypes"
}
use payment_service, card_processor from acme.archtypes

Это покупает чистое разделение: общий словарь живёт в одном владеемом, изолированном месте, а остальная часть модели зависит от него явно — use … from acme.archtypes — вместо того чтобы каждая команда копипастила определения типов. Выбирайте это только тогда, когда совместное использование реально; метамодель одной команды остаётся рядом со своими экземплярами.

Метамодель меняется со временем. Добавление нового required пустого слота ломает существующие экземпляры. Два более безопасных пути:

  • Сначала добавьте как значение по умолчанию без значения. Экземпляры, у которых оно уже есть, работают; новые наследуют. Затем прогоните CI-проверку, чтобы убедиться, что у каждого экземпляра теперь есть значение.
  • Добавьте параллельный тип. card_processor_v2 существует рядом с card_processor; команды мигрируют в своём темпе. Старый тип в итоге dropается из метамодели.

Оба работают. Выбирайте по размеру цены миграции.

Вы выкатили payment_service полгода назад без security.contact. Вы понимаете, что он нужен. Просто добавить required security.contact нельзя — каждый существующий экземпляр сломается.

Шаги:

  1. PR 1. Добавьте security.contact как необязательное значение по умолчанию в payment_service. Влейте.
  2. PR 2. Прогоните CI-проверку: перечислите каждый payment_service без security.contact. Заведите задачи на команды-владельцы.
  3. PR N. Команды добавляют security.contact к своим экземплярам.
  4. Финальный PR. Когда у каждого экземпляра он есть, переключите поле в required security.contact.

Метамодель затянулась с нулём сломанных сборок. Каждый шаг безопасен сам по себе.

  • Библиотека типов может быть метамоделью — нотацией, которая навязывает дисциплину и диктует, как думает организация (C4-как-библиотека — образец), а не просто связкой удобств.
  • Типы — это шаблоны форм, а не классы — значения по умолчанию плюс обязательные пустые слоты, без поведения.
  • Тип должен оправдывать себя: наследовать, кастомный виджет, кастомное требование, кастомное поле или явное утверждение. Не изобретайте пустые типы.
  • Стройте её, когда поля и аспекты повторяются; слой делайте неглубоким (максимум 3-4 уровня), один пакет фактов на слой.
  • Обязательные пустые слоты (required) фиксируют факты на этапе разбора. Используйте их, когда цена забывания велика.
  • Библиотеки организации добавляют семантику (требования владения/мейнтейнера, отношения отделов), а не только виджеты — расширяйте типы стандартной библиотеки, не переписывайте их.
  • Держите типы рядом с их экземплярами; поднимайте общий словарь в выделенный изолированный пакет типов только тогда, когда совместное использование реально.
  • Каскадируйте аспекты и поля; append для накапливаемых композитов; локально (без модификатора) для значений, специфичных экземпляру.
  • Затягивайте метамодель постепенно — добавьте значение по умолчанию, мигрируйте экземпляры, затем сделайте обязательным.
  • Половина работы — именование. Используйте бизнес-слова; пользовательские типы в lower_snake_case.

Глава 30: Миграция из UML- и ArchiMate-инструментов → — последний разобранный пример. Для читателей, приходящих из инструментов корпоративной архитектуры, отображение их концепций на ArchLang.