29. Designing a Metamodel
The metamodel chapters (14-18) covered how types work. This chapter is about when and how to build them — designing kinds that capture your organization’s conventions, encoding domain-specific requirements, and getting the level of strictness right.
The audience is platform teams, kind authors, and anyone in the position of telling other teams “this is how we model X in this organization.” If you only consume stdlib kinds, skip to Chapter 30 or stop reading.
We’ll build a metamodel for a fintech organization: card processors, ledger services, audit-required services, regulated webhooks. By the end you’ll have ~6 type declarations and a clear sense of how to make these decisions for your own domain.
When to reach for the metamodel
If every team writes the same five labels and the same three fields on every service, you have a metamodel candidate. Specifically:
- You repeat fields. Every payments service has
ext.runbook_url,ext.processor_vendor,security.contact. Define apayment_servicekind that requires them. - You repeat labels. Every analytics service is
domain: Analytics,security.zone: Internal,data.classification: pii. Define ananalytics_servicekind that cascades them. - You have invariants people forget. Every external integration must have a vendor and contract URL. The stdlib
external_systemalready enforces this; your project can do the same for domain-specific invariants. - You want a vocabulary that matches the business. “Card processor” reads better than “service that is an external_system in PCI scope with a vendor URL.” Type it.
If none of those apply, the stdlib kinds are enough.
Designing the type chain
Start with the base kind from the stdlib, then layer constraints. For the fintech example:
service (stdlib) ↓payment_service (our kind: shared PCI / runbook / contact requirements) ↓card_processor (our kind: outbound card-vendor specific fields) ↓stripe_processor (instance hint kind: vendor=Stripe specifically)Three layers. Each adds one bundle of facts.
Two principles:
- Each layer should answer one question.
payment_serviceanswers “what’s true of every PCI-scope module?”card_processoranswers “what’s true of every card-processor specifically?”stripe_processoris barely a kind — it’s optional sugar. - Don’t nest deeper than three or four. Beyond that, readers can’t hold the chain in their head. If your conceptual hierarchy is genuinely five layers, consider whether some of them should be labels instead.
The base service kind
export type service payment_service { required cascade team required ext.runbook_url required security.contact
labels { security.zone: PCI compliance.regime.pci: true }
"A service in PCI scope. Required runbook URL, contact, and team owner."}This adds three requirements on top of service (which already required team and labels.domain):
- A runbook URL.
- A security contact.
- The two PCI labels cascade automatically.
Now any instance:
payment_service PaymentAuthorizer { team: Payments ext.runbook_url: "https://wiki.acme.com/auth-runbook" security.contact: "compliance@acme.com" labels { domain: Payments // still required from service }
command Charge}Try saving without ext.runbook_url: validation fails with “Required field ‘ext.runbook_url’ is not fulfilled and not dropped.” The vocabulary encodes the invariant.
The card-processor kind
A second layer for the card-processor specifically:
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."}Why a separate kind instead of folding the three fields into payment_service? Because not every payment service is a card processor. The hierarchy lets payment_service AccountVerification { ... } skip the card-specific fields while still being PCI-required.
Instance (assuming an external_system Stripe { ... event PaymentEvents } declared elsewhere in the package, as in Chapter 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 }}A regulated-webhook interface kind
The webhook handler above is just a command. We can do better — define a kind that captures the regulated-webhook invariants. (Required blanks on user-defined interface kinds are supported by the type system; their enforcement at parse time depends on validator version — re-test if you’re targeting an older toolchain.)
export type command 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."}Now:
card_processor StripeIntegration { // ... as above ...
regulated_webhook StripeWebhook { signature.algorithm: "hmac-sha256" signature.header: "Stripe-Signature" retention_days: 365 subscribes: Stripe.PaymentEvents }}The webhook now declares its own contract; reviewers can see at a glance that it’s signature-verified and retained for a year. Adding a new webhook without those three fields is a parse error.
A datastore kind
Cascading the same pattern to the data layer:
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 }
"Encrypted PCI-scoped datastore. Requires encryption algorithm, retention-policy document URL, runbook."}Used as:
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 // already in template — re-asserts it for clarity }
query Read command Write}Where to draw the strictness line
A required blank is mandatory. It enforces a fact. It also raises the cost of declaring an instance. Two questions to ask before making a field required:
- Will the instance always be able to answer? If 80% of instances have a runbook URL and 20% genuinely don’t (early-stage services, internal tools), required is too strict — it forces lies (
ext.runbook_url: "tbd") or theatrical drops. Use a default-empty field and surface a warning in a CI script instead. - Is the cost of forgetting high enough? A missing runbook URL on a Tier-1 payment service is genuinely bad. A missing tagline on a hobby project service is fine. Required is the language saying “we will not accept this fact being missing.” Make sure the cost matches.
The PCI labels (security.zone: PCI, compliance.regime.pci: true) are defaults, not requireds — because they’re correct for every payment_service by construction. If you ever have a non-PCI payment service, it shouldn’t be a payment_service; it should be a different kind.
Naming kinds
A few notes from organizations that get this right:
- Use the business word.
payment_service,card_processor,audit_log— notpci_service_v2. The kind should read like the team talks. - Don’t overload technical kinds. A kind named
databaseshould be a database. If your kind is “a service that owns a database and exposes CRUD over it,” call itcrud_serviceoraggregate_service, notdatabase. - Singular nouns.
card_processor, notcard_processors. Instances are singular things. - Snake_case is conventional. It distinguishes user-defined kinds from PascalCase instance names.
Cascade vs append
You have three propagation modes per field at the type level: local (no modifier), cascade, append. When designing a kind, think about what should flow:
cascade team— every nested module inherits the parent’s team unless it sets its own.cascade widget: arch-payment-service— every instance gets the same default visual; instances can override.append tags— accumulating tags across nested levels. A parent’stags: ["pci"]and a child’stags: ["audited"]resolve to["pci", "audited"]on the child.
Labels always cascade. Fields default to local; you opt into propagation explicitly.
Versioning the metamodel
The metamodel changes over time. Adding a new required blank breaks existing instances. Two safer paths:
- Add it as a default with no value first. Instances that already have it work; new ones inherit. Then run a CI grep ensuring every instance now has the value.
- Add a parallel kind.
card_processor_v2exists alongsidecard_processor; teams migrate at their own pace. The old kind eventually getsdropped from the metamodel.
Both work. Pick based on the size of the migration cost.
A worked diff: tightening the metamodel
You shipped payment_service six months ago without security.contact. You realize you need it. You can’t just add required security.contact — every existing instance breaks.
Steps:
- PR 1. Add
security.contactas a non-required default topayment_service. Land it. - PR 2. Run a CI grep: list every
payment_servicewithoutsecurity.contact. File issues with owning teams. - PRs N. Teams add
security.contactto their instances. - PR Final. When every instance has it, flip the field to
required security.contact.
The metamodel got tightened with zero broken builds. Each step is independently safe.
Summary
- The metamodel is your domain’s vocabulary. Build it when fields and labels repeat across instances.
- Layer kinds shallowly (3-4 levels max), one bundle of facts per layer.
requiredblanks enforce facts at parse time. Use them when the cost of forgetting is high.- Cascade labels and fields;
appendfor accumulating composites;local(no modifier) for instance-specific values. - Tighten the metamodel gradually — add default, migrate instances, then make it required.
- Naming is half the work. Use business words; user-defined kinds in snake_case.
What’s next
Chapter 30: Migrating from Sparx / RSM / UML → — the last worked design. For readers coming from enterprise-architecture tools, mapping their concepts to Archlang.