Skip to content

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 a payment_service kind that requires them.
  • You repeat labels. Every analytics service is domain: Analytics, security.zone: Internal, data.classification: pii. Define an analytics_service kind that cascades them.
  • You have invariants people forget. Every external integration must have a vendor and contract URL. The stdlib external_system already 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_service answers “what’s true of every PCI-scope module?” card_processor answers “what’s true of every card-processor specifically?” stripe_processor is 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

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
}
"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:

  1. 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.
  2. 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 — not pci_service_v2. The kind should read like the team talks.
  • Don’t overload technical kinds. A kind named database should be a database. If your kind is “a service that owns a database and exposes CRUD over it,” call it crud_service or aggregate_service, not database.
  • Singular nouns. card_processor, not card_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’s tags: ["pci"] and a child’s tags: ["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_v2 exists alongside card_processor; teams migrate at their own pace. The old kind eventually gets dropped 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:

  1. PR 1. Add security.contact as a non-required default to payment_service. Land it.
  2. PR 2. Run a CI grep: list every payment_service without security.contact. File issues with owning teams.
  3. PRs N. Teams add security.contact to their instances.
  4. 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.
  • required blanks enforce facts at parse time. Use them when the cost of forgetting is high.
  • Cascade labels and fields; append for 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.