Skip to main content

Schema Reference

The canonical schema URL is https://modelith.sh/schema/domain-model/v1.json (JSON Schema, draft 2020-12). Add it to your file as a header:

# yaml-language-server: $schema=https://modelith.sh/schema/domain-model/v1.json
Schema URL not yet live

Serving the schema at modelith.sh is a roadmap item. Until it is, the URL doesn't resolve, so you won't get editor autocomplete from the header — but the header is harmless and modelith lint validates the file regardless (it embeds the schema). Print the schema any time with modelith schema.

Top level

FieldTypeRequiredNotes
kindstringyesMust be DomainModel.
versionstringyesSchema revision. Currently v1.
titlestringnoHeading used when rendering.
descriptionstringnoOne-paragraph summary.
glossarymapnoUbiquitous-language terms that aren't entities. See Glossary.
enumsmapnoFirst-class enumerated types. See Enum.
entitiesmapnoKeyed by canonical PascalCase entity name. If present, must contain at least one entity.
scenarioslistnoNarratives that exercise the model.
invariantslistnoModel-level rules that span several entities. Same shape as entity invariants. See Invariant.

kind and version make the file self-describing: tooling dispatches on them, and they let the format evolve without guesswork.

Glossary

glossary defines the ubiquitous-language terms that are not entities — roles (Owner, Member), states of being, domain nouns. Each key is the term (PascalCase) and the value is its definition. Defining a term here makes it part of the checked vocabulary rather than something the linter only infers from incidental use.

glossary:
Owner: "A `User` with full control of a `Project` — can transfer ownership and archive it."
Member: "A `User` granted access to a `Project` without ownership rights."

A term used as a relationship role or a scenario actor should be defined here; the linter warns on a role term that resolves to neither an entity nor a glossary term, and flags a glossary term nothing references.

Enum

enums defines first-class enumerated types, keyed by PascalCase name. An attribute selects one by naming it in its type (rather than burying values in a "enum[...]" string, which is unparseable and uncheckable).

enums:
ProjectStatus:
description: "Lifecycle state of a `Project`."
values:
- name: active
definition: "In normal use; `Policies` can be enabled."
- name: archived
definition: "Retired and read-only."
FieldTypeRequiredNotes
descriptionstringnoWhat the enumerated type represents.
valueslistyesEach value has a name and optional definition so a state like active has one agreed meaning.

Enums name the states; the legal transitions between them live in invariants and action preserves, not a separate transitions construct — that's a deliberate omission to keep the format light. (E.g. "can't archive while policies are enabled" is an invariant the archive action preserves, not an edge in a state machine.)

Entity

Each key under entities is the entity's canonical name (PascalCase, e.g. Project). Its value:

FieldTypeRequiredNotes
definitionstringyesTwo to four sentences: what it is, what it is not.
relationshipslistnoSee Relationship.
attributeslistnoSee Attribute.
actionslistnoMutations the system exposes. See Action.
invariantslistnoRules that must always hold. See Invariant.

Relationship

FieldTypeRequiredNotes
entitystringyesTarget entity name. Must reference a defined entity.
cardinalityenumyesOne of 1:1, 1:n, n:1, n:n.
rolestringnoThe role the related entity plays. Backtick entity names.
ownershipenumnoIs the related entity part of this one? owned = it can't exist independently (composition: created within, and deleted with, this entity); referenced = an independent entity this one points at. Omitted ⇒ referenced.
notestringnoFreeform note.

You can declare a relationship from one side or both. If you declare it from both — Project lists Policy and Policy lists Project — the cardinalities must be inverses (1:n one way ⇒ n:1 the other; 1:1 and n:n are symmetric). The linter errors on a contradiction, and the renderer collapses a matching pair into a single edge. Declaring it once is fine; the renderer shows the edge either way.

When there's an intuitive parent — the entity that owns or contains the other, or sits on the "one" side of a one-to-many — prefer declaring the relationship there (e.g. on Project, not Policy). It keeps each link in one obvious place and reads the way the domain does. Declare from both ends only when both views genuinely add clarity.

Attribute

FieldTypeRequiredNotes
namestringyesAttribute name.
typestringyesA primitive (lowercase, e.g. string, integer, boolean, timestamp) or the PascalCase name of a defined enum. A PascalCase type that names no enum is flagged.
descriptionstringno
derivedbooleannoTrue if computed from other state rather than stored. Forces derivation.
derivationstringnoHow a derived attribute is computed. Required when derived is true.

Attributes are the properties that matter for reasoning about the entity — not every database column. Mark computed values derived so they aren't mistaken for stored columns.

Action

Each item under an entity's actions is either a bare name or a structured object. Use the object form to tie an action to who performs it and the invariants it must preserve.

actions:
- create # bare
- name: archive # structured
actor: Owner # an entity or glossary term
preserves: [at-least-one-owner] # invariant ids
description: "Retire the project."
FieldTypeRequiredNotes
namestringyesThe action name.
actorstringnoWho performs it — a defined entity or glossary term.
preserveslist of stringnoIds of invariants this action must preserve.
descriptionstringno

Invariant

Each invariant carries a stable id and a statement. References (scenario invariants_touched, action preserves) point at the id, so rewording the statement never silently breaks them.

invariants:
- id: at-least-one-owner
statement: "Must have at least one `Owner` at all times"
FieldTypeRequiredNotes
idstringyesStable identifier, lowercase kebab-case. Unique across the model.
statementstringyesThe rule. Short, declarative, testable. Backtick entity names.

An invariant can be declared in one of two places:

  • On an entity (entities.<X>.invariants) — for a rule with a clear single owner, e.g. "a Project must always have at least one Owner."
  • At the top level (invariants, sibling to entities) — for a rule that spans several entities and has no natural owner, e.g. "when a Project is archived, none of its Policies remain enabled." Putting such a rule on one arbitrary entity would misattribute it.

Both forms use the identical shape and share one id namespace: ids must be unique across entity-level and model-level invariants alike, and a invariants_touched / preserves reference resolves against either scope. The renderer emits model-level invariants in a top-level ## Invariants section; entity-level ones render with their entity.

Scenario

FieldTypeRequiredNotes
namestringyesShort title.
actorslist of stringnoEntity names or glossary roles involved. Ad-hoc participants (e.g. TargetUser) are allowed and not required to be glossary terms.
stepslist of stringyesOrdered steps. Backtick entity names.
invariants_touchedlist of stringnoIds of invariants this scenario exercises. Each must reference a declared invariant.

A scenario is a diagnostic, not a backlog item: it tests whether the entities and actions actually hang together. If writing one reveals an invariant that can't be satisfied — or that doesn't exist yet — fix the model, not the scenario.

What the linter adds on top of the schema

The JSON Schema covers structure. modelith lint adds:

  • Semantic checks, which split by severity:
    • Errors (broken references — the model can't be right):
      • a relationship target that doesn't reference a defined entity;
      • a relationship declared from both sides with cardinalities that aren't inverses (e.g. ProjectPolicy 1:n but PolicyProject 1:1);
      • a duplicate invariant id (across entity-level and model-level invariants — they share one namespace);
      • a scenario invariants_touched or an action preserves that references an invariant id no entity or model-level invariant declares.
    • Warnings (likely-but-not-certainly wrong):
      • a backticked term in freeform text that resolves to no entity, glossary term, role, or actor;
      • a relationship role that resolves to neither an entity nor a glossary term — define it in the glossary;
      • an attribute type that looks like an enum reference (PascalCase) but names no defined enum;
      • an action actor that is neither a defined entity nor a glossary term.
  • Completeness checks (advisory warnings): entities with no invariants; entities no scenario exercises; a glossary term nothing references; an enum no attribute uses.