29. Designing a Metamodel
The metamodel chapters (14-18) covered how types work. This chapter is about when and how to build them — designing types that capture your organization’s conventions, encoding domain-specific requirements, and getting the level of strictness right.
The audience is platform teams, type authors, and anyone in the position of telling other teams “this is how we model X in this organization.” If you only consume stdlib types, skip to Chapter 30 or stop reading.
A type library can be a meta-model, not just a convenience. There are two lenses on a custom type set. The nice-to-have lens treats it as a bundle of handy widgets and defaults — fewer keystrokes, prettier diagrams. The meta-model lens treats it as a notation that imposes discipline and drives how you think: adopting it shapes how you decompose and reason about systems. C4 notation packaged as an ArchLang library is the canonical example — its system / container / component vocabulary isn’t sugar, it’s a method. Your fintech library below is the same kind of thing: it makes “card processor” and “PCI service” first-class concepts the whole org reasons in. Pick (and build) a library for what it does to your thinking, not just the components it hands you.
Types are form templates, not classes. A type body is a form an instance fills in — defaults, required blanks, pre-filled sections that get stamped onto every instance. It is not an OOP class: there are no methods, no runtime behavior, no instantiation semantics. When you design a type, you’re designing a form, and the only questions are “what must every instance state?” and “what should every instance start with?”
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
Section titled “When to reach for the metamodel”If every team writes the same five aspects 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_servicetype that requires them. - You repeat aspects. Every analytics service is
domain: Analytics,security.zone: Internal,data.classification: pii. Define ananalytics_servicetype 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 types are enough.
A custom type must earn its keep. A type is justified only when it does at least one concrete thing: subtype something, attach a custom widget, define a custom requirement (required blank), add custom fields, or let you explicitly assert that something is that type (where the assertion itself is the value you query on). Those are the five ways. If a proposed type does none of them — a behaviorless service block that wraps a plain module and adds nothing — it’s noise. Don’t invent do-nothing types; reach for a plain module or service first and type it only when one of the five reasons appears.
Rule. Plain modules and interfaces are the default building blocks. Typing is sugar you add when it earns its keep. A bare type that adds no requirement, field, widget, subtype relationship, or assertable identity is worse than no type at all.
Designing the type chain
Section titled “Designing the type chain”Start with the base type from the stdlib, then layer constraints. For the fintech example:
service (stdlib) ↓payment_service (our type: shared PCI / runbook / contact requirements) ↓card_processor (our type: outbound card-vendor specific fields) ↓stripe_processor (instance hint type: 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_processorearns its keep only as an explicit assertion — its whole value is letting you query or filter “every Stripe processor across the estate.” If you never assert on it (no view, no policy, no report keys off it), it’s a do-nothing type: delete it and setext.processor_vendor: "Stripe"on a plaincard_processorinstead. A type that exists only to restate a field value is noise. - 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 aspects instead.
The base service type
Section titled “The base service type”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."}This adds two requirements on top of service (which only softly cascades aspect team and aspect domain — neither is required):
- A runbook URL.
- A security contact.
The two PCI aspects (data.classification, compliance.regime) are defaults that cascade automatically — every payment_service is PCI-scoped by construction, so there’s nothing to require there. Ownership itself stays a soft aspect team inherited from service, never a required field — see Chapter 20 for why the stdlib models it that way, and enforce “every service has a team” with a policy, not a required blank (Chapter 28).
Now any instance:
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}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 type
Section titled “The card-processor type”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 type 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 { ... } declared elsewhere in the package, as in Chapter 27). stripeWebhook is an async handler interface; the inbound events reach it through a process edge (Stripe > StripeIntegration.stripeWebhook), not a subscription field:
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}A regulated-webhook interface type
Section titled “A regulated-webhook interface type”The webhook handler above is just a bare webhook. We can do better — define a type that captures the regulated-webhook invariants. (Required blanks on user-defined interface types 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 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."}Now:
card_processor StripeIntegration { // ... as above ...
regulated_webhook stripeWebhook { signature.algorithm: "hmac-sha256" signature.header: "Stripe-Signature" retention_days: 365 }}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 type
Section titled “A datastore type”Cascading the same pattern to the data layer:
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."}Used as:
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}Where to draw the strictness line
Section titled “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 aspects (data.classification: "pci", compliance.regime: ["pci"]) 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 type.
Naming types
Section titled “Naming types”A few notes from organizations that get this right:
- Use the business word.
payment_service,card_processor,audit_log— notpci_service_v2. The type should read like the team talks. - Don’t overload technical types. A type named
databaseshould be a database. If your type 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. lower_snake_caseis the convention. It distinguishes user-defined types fromUpperCamelCaseinstance names at a glance.
Cascade vs append
Section titled “Cascade vs append”You have three propagation modes per field at the type level: local (no modifier), cascade, append. When designing a type, think about what should flow:
cascade version— every nested module inherits the parent’s version 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.
Aspects always cascade. Fields default to local; you opt into propagation explicitly.
Org-specific semantics, not just widgets
Section titled “Org-specific semantics, not just widgets”The stdlib is batteries-included, but a large organization usually wants its own library — and the reason is rarely widgets (stdlib widgets are reusable). It’s semantics the stdlib can’t know:
- Ownership requirements — a
required maintainerand arequired oncall_rotationon every owned service, because your org mandates them. - Department / division relationships — types that encode which org unit a module belongs to, so the model carries your reporting structure, not just your call graph.
- Mandatory governance fields — a
required ext.compliance.contacton anything regulated, arequired data.retention_policy_urlon anything storing user data.
These are real semantics that drive how the org models, not decoration. Extend a stdlib type rather than rewriting it — add your extra requirements on top of service instead of redefining service from scratch.
Where shared types live: the isolated types package
Section titled “Where shared types live: the isolated types package”Default: keep a type close to the instances that use it — same package, domainly adjacent. A type lives near the modules it stamps.
The escape hatch for a large org is a dedicated, isolated types package. When shared vocabulary (payment_service, card_processor, network_segment) is used across many teams, hoist it into its own package that exports only those types. The package is opaque — it exposes its vocabulary and nothing else — and any space can use it:
// In a consuming package's manifestdependencies { acme.archtypes: "../archtypes"}use payment_service, card_processor from acme.archtypesThis buys clean separation: the shared vocabulary lives in one owned, isolated place, and the rest of the model depends on it explicitly — use … from acme.archtypes — rather than each team copy-pasting type definitions. Pick this only when sharing is real; a single-team metamodel stays next to its instances.
Versioning the metamodel
Section titled “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 type.
card_processor_v2exists alongsidecard_processor; teams migrate at their own pace. The old type eventually getsdropped from the metamodel.
Both work. Pick based on the size of the migration cost.
A worked diff: tightening the metamodel
Section titled “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
Section titled “Summary”- A type library can be a meta-model — a notation that imposes discipline and drives how the org thinks (C4-as-a-library is the model), not just a convenience bundle.
- Types are form templates, not classes — defaults plus required blanks, no behavior.
- A type must earn its keep: subtype, custom widget, custom requirement, custom field, or explicit assertion. Don’t invent do-nothing types.
- Build it when fields and aspects repeat; layer 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.- Org libraries add semantics (ownership/maintainer requirements, department relationships), not just widgets — extend stdlib types, don’t rewrite them.
- Keep types near their instances; hoist shared vocabulary into a dedicated isolated types package only when sharing is real.
- Cascade aspects 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 types in
lower_snake_case.
What’s next
Section titled “What’s next”Chapter 30: Migrating from UML / ArchiMate tools → — the last worked design. For readers coming from enterprise-architecture tools, mapping their concepts to ArchLang.