Skip to main content

Vendoring a model from another repository

Imports let one model reference an item another one defines — but only across files that are already in your repository. Vendoring is how a model from somewhere else gets there: you fetch a copy, commit it, and modelith records where it came from.

modelith deps import https://github.com/acme/billing/blob/main/docs/payments.modelith.yaml docs/

That URL is the address of the file as it appears in your browser on github.com — open the model on GitHub and copy the address bar.

What you get​

A copy of the file, byte for byte, with a provenance header added at the top:

# yaml-language-server: $schema=https://modelith.sh/schema/domain-model/v1.json
# modelith-vendored: DO NOT EDIT — this file is a copy. Change it at its origin.
# modelith-fetch: git
# modelith-origin: https://github.com/acme/billing
# modelith-path: docs/payments.modelith.yaml
# modelith-ref: main
# modelith-commit: 4f2c1e9c8b3ad0e5f71b2c9a6d4e8f30ab5c7d21
# modelith-imported: 2026-07-27
# modelith-digest: sha256:9a1f…

The header is a comment, not part of the schema — a model you write yourself never has one, and never meets any of this.

KeyWhat it records
vendoredThat this file is a copy. Nothing enforces it; it is there so a person or an agent about to edit the file stops.
fetchHow to get it again. git today.
origin, path, refWhere it came from and what to track. A tag in ref pins the copy; a branch follows it.
commitThe commit that last touched this file at that ref — so it does not move when unrelated commits land.
importedWhen you fetched it.
digestSHA-256 of the file with the header lines removed, so stamping the header does not change it.

Then add it to your model​

deps import writes the file and stops. It does not edit your model's imports: — it prints the line to add:

imports:
- ./docs/payments.modelith.yaml

The printed path is relative to the directory you ran the command in, because that is the only thing deps import knows. An import path is relative to the model that declares it, so if that model does not sit beside your working directory, adjust it — exactly like any other import. Until you add that line, the copy is an inert file that nothing reads.

That second step is deliberate. A vendored model is content someone else wrote that will be rendered into your published Markdown, so deps import warns you and leaves the decision — and the diff — visible.

Only vendor from sources you trust

A vendored model's prose ends up in your rendered .md. modelith escapes HTML in prose fields, but the file is still somebody else's text landing in your docs. Vendoring is designed for projects that already trust each other.

How a vendored file is treated differently​

A provenance line marks the file as a copy whose home is elsewhere. lint reports every provenance-header defect as a semantic error. It still suppresses completeness findings for that copy, because those findings are about content owned by its origin.

Its own imports: do not receive semantic diagnostics. A vendored model's imports commonly name paths in its home repository that do not exist in yours. Missing or broken nested edges stay silent, along with references that resolve through them; readable local edges still participate in provenance verification.

Structural and other semantic checks still run. A vendored file that is not a valid domain model, or whose digest no longer matches its header, fails lint.

modelith render --check applies its exemptions only to a vendored copy with a clean provenance header. It skips a clean copy with no committed .md, and it also skips a clean copy this version cannot render, such as one using a newer schema version. If you commit the copy's .md for a deep link, --check verifies it for staleness. A malformed header does not qualify for those render-check exemptions, and lint reports the header error.

The GitHub Action applies the same lint and render --check behavior to every matching file.

What it will not overwrite​

The filename comes from the origin, so a copy can land on a file you already have. deps import refuses two cases rather than clobbering them:

  • A model you wrote. No provenance header means the file is yours, and no re-fetch could bring it back. Import into a different directory, or move the file aside first.
  • A copy of a different model with the same basename. Two payments.modelith.yaml files from two repositories cannot share a directory; give them separate ones.

A copy from the same repository at a different path is refused too, because modelith cannot tell a model that moved upstream from a second model whose file happens to share a name. The message offers both remedies: delete the copy and import again if it moved, or import into a different directory if they are two different models.

Re-importing over an existing copy of the same model at the same path is the ordinary refresh, and that goes through — it reports replaced rather than wrote. Only the origin's casing is ignored in that comparison, because GitHub treats an owner and repository name case-insensitively.

Keeping the copy honest​

Every modelith lint re-checks a vendored file against the digest in its own header. If someone edits the copy, lint says so:

error [semantic] (root): this vendored file no longer matches the digest its
provenance header records (recorded sha256:9a1f…, computed sha256:2c7b…) — it
has been edited since it was imported. Restore it with `modelith deps update
docs/payments.modelith.yaml`, or delete the provenance header if the change is
a deliberate fork, which makes this repository the file's home.

Both remedies are real. deps update puts the copy back to what its origin serves — and if the origin has not moved, that is byte for byte what the import wrote. Deleting the header makes the file an ordinary model of yours — an honest description of having forked it, and it re-enables the completeness checks, because now it is your document.

This is drift detection, not a security boundary: anyone editing the file can recompute the header. It catches the well-meaning typo fix, which is the thing that actually happens.

When lint starts from an importing model, it follows locally readable imports and verifies every vendored copy it reaches. A mismatch is reported against the copy that needs repair, not its importer. This stays offline and does not add new diagnostics for a nested import that cannot be read; lint does not become a recursive semantic validator.

Keeping the copy current​

The section above is about your copy. This one is about the model it came from, which moves on without you.

modelith deps check docs/*.modelith.yaml
docs/payments.modelith.yaml: up to date at v2.1.0
docs/ledger.modelith.yaml: stale at main — the origin is now at a91b0c3

checked 2 vendored copies, 1 stale

deps check writes nothing and exits non-zero when any copy is stale, so it works as a scheduled CI job. deps update takes the same arguments and brings the copies forward:

modelith deps update docs/ledger.modelith.yaml
docs/ledger.modelith.yaml: 4f2c1e9 → a91b0c3 at main

updated 1 of 1 vendored copy

Then read git diff to see what actually changed, and run modelith lint. An item the copy used to define may have been renamed or removed upstream, which breaks references in your model — update cannot see those, because it does not know which of your models import the copy.

Both commands take file arguments, the same way lint does, and skip any file with no provenance header. That means the glob you already lint works unchanged; the closing line tells you how many files were skipped, so a glob that matched none of your copies does not read as good news. To find them:

git grep -l '# modelith-vendored'

Two ways to track a model​

Which one you are on is whatever # modelith-ref: records.

  • Tracking a branch. deps update fetches whatever that branch has now. You get upstream's changes as they land, and deps check tells you when there are some.

  • Pinned to a tag. deps update alone does nothing, because the tag still points where it did. Moving to a new version is explicit:

    modelith deps update --ref v2.2.0 docs/payments.modelith.yaml

    --ref re-pins one copy at a time. A single ref names a different version in every other repository, so it is refused with several files.

A pinned copy is always "up to date"

deps check compares content against the ref your header records. On a tag, that never changes, so a copy pinned to v2.1.0 reports as up to date for as long as v2.1.0 exists — even after v2.3.0 ships. modelith does not look for newer releases, which is why every line of output names the ref it checked against.

What "stale" means​

A copy is stale when its origin serves different content, compared against the digest in the copy's own header. It is not a commit comparison: a merge or a whitespace-only touch upstream moves the commit without changing the model, and that is not something you need to act on.

That has one consequence worth knowing: an upstream change to a description: counts, because the digest covers the whole file. You will be told a documentation-only change is waiting for you, and git diff after the update is how you find out that is all it was.

deps update writes only where something changed, so running it over a glob produces a diff exactly where one belongs. A copy that was hand-edited is not holding what its origin serves, so it gets rewritten too, and the edits go — make the change at the origin instead.

Vendoring is one file, not a dependency tree​

If the model you fetch imports models of its own, those are not fetched. deps import tells you they exist, and deps update says the same thing again if a refresh brings imports the copy did not have before:

Note: payments.modelith.yaml declares an import of its own (./ledger.modelith.yaml).
modelith vendors one file, not a dependency tree, and resolution is not
transitive — if you need items from those models, import them directly.

This matches how resolution already works: payments.Thing reaches only items defined directly in the file bound to payments. If the item you want lives one hop further away, vendor that model too and give it its own scope. The linter says so at the reference site when it can tell:

attribute type "payments.Carrier" names no enum "Carrier" in
"./payments.modelith.yaml" — that model imports a model of its own, and
resolution is not transitive: if "Carrier" is defined in one of them, add that
model to this model's `imports:` too and reference it with its own scope.

Fetching a tree would mean a directory layout, rewritten paths, and an answer for what happens when two models want different versions of the same third model — a package manager, for a problem an explicit second deps import already solves.

Requirements and limits​

  • gh must be installed and authenticated. modelith implements no network transport of its own; it delegates to the GitHub CLI, which already solves authentication for private and internal repositories.
  • GitHub only, for now. A URL on another host is an error that asks you to open an issue. That is not a brush-off: the header records how it was fetched, so adding another transport is straightforward — what is missing is a real user to build it for, and an issue is how you become one.
  • lint and render never touch the network, whatever you pass them (ADR-0011). Everything under modelith deps is opt-in, and nothing else fetches.
  • No newer-release detection. deps check tells you whether the ref you pinned still serves what you have. It does not tell you a newer tag exists, because deciding which tags count as newer means guessing at a versioning scheme modelith has no way to know.

The design and its trade-offs are ADR-0010, ADR-0015, and ADR-0016.