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

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

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

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

Мы построим метамодель для финтех-организации: карточные процессоры, сервисы леджера, сервисы с обязательным аудитом, регулируемые вебхуки. К концу у вас будет ~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 поставщика». Типизируйте это.

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

Проектирование цепочки типов

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

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

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

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

  • Каждый слой должен отвечать на один вопрос. payment_service отвечает: «что верно для каждого модуля в области PCI?» card_processor отвечает: «что верно конкретно для каждого карточного процессора?» stripe_processor едва ли вид — это опциональный сахар.
  • Не вкладывайте глубже трёх-четырёх уровней. Дальше читатели не удержат цепочку в голове. Если ваша концептуальная иерархия действительно пятислойная, подумайте, не должны ли некоторые из слоёв быть метками.

Базовый вид сервиса

kinds.arch
export type service payment_service {
required cascade team
required ext.runbook_url
required security.contact
labels {
security.zone: PCI
compliance.regime.pci: true
}
"Микросервис в области PCI. Обязательные URL runbook, контакт и команда-владелец."
}

Это добавляет три требования поверх service (который уже требовал team и labels.domain):

  • URL runbook.
  • Контакт по безопасности.
  • Две метки PCI каскадируют автоматически.

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

payment_service PaymentAuthorizer {
team: Payments
ext.runbook_url: "https://wiki.acme.com/auth-runbook"
security.contact: "compliance@acme.com"
labels {
domain: Payments // всё ещё обязательно от service
}
command 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
"Карточный процессор, общающийся с внешним поставщиком. Требует имя поставщика,
URL контракта и URL, который мы выставляем для входящих вебхуков от него."
}

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

Экземпляр (предполагая, что external_system Stripe { ... event PaymentEvents } объявлен где-то в пакете, как в главе 27):

card_processor StripeIntegration {
team: Payments
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"
labels {
domain: Payments
}
command Charge
command Refund
command StripeWebhook {
subscribes: Stripe.PaymentEvents
}
}

Вид интерфейса регулируемого вебхука

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

export type command regulated_webhook {
required signature.algorithm // hmac-sha256, ecdsa и т.д.
required signature.header // HTTP-заголовок с подписью
required retention_days // сколько хранится сырой payload
"Входящий вебхук, который должен проверяться по HMAC и логироваться в аудит."
}

Теперь:

card_processor StripeIntegration {
// ... как выше ...
regulated_webhook StripeWebhook {
signature.algorithm: "hmac-sha256"
signature.header: "Stripe-Signature"
retention_days: 365
subscribes: Stripe.PaymentEvents
}
}

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

Вид хранилища данных

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

export type database pci_vault {
required cascade team
required data.encryption.algorithm
required data.retention_policy_url
required ext.runbook_url
labels {
security.zone: PCI
data.classification: pci
compliance.regime.pci: true
}
"Зашифрованное хранилище в области PCI. Требует алгоритм шифрования,
URL документа политики хранения, runbook."
}

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

pci_vault PaymentVault {
team: Payments
data.encryption.algorithm: "aes-256-gcm"
data.retention_policy_url: "https://wiki/pci-retention"
ext.runbook_url: "https://wiki/vault-runbook"
labels {
domain: Payments
data.classification: pci // уже в шаблоне — перепроставляем для ясности
}
query Read
command Write
}

Где провести черту строгости

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

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

Метки PCI (security.zone: PCI, compliance.regime.pci: true) — это значения по умолчанию, а не 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. Экземпляры — это единичные вещи.
  • Принято использовать snake_case. Это отличает пользовательские виды от имён экземпляров в PascalCase.

Каскад против append

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

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

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

Версионирование метамодели

Метамодель меняется со временем. Добавление нового 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.

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

Итоги

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

Что дальше

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