aontu

Split a model across files

Load other source files with @"path" so a base model and its overrides unify into one document.

Rendered from docs/how-to/split-a-model-across-files.md in the engine repository, where a correction belongs, and where the test suite executes every example on this page.

One file holds a model well until two teams edit it, or until the environment-specific lines outnumber the shared ones. @"path" loads another source file and unifies it in place, so a split costs nothing: the parts mean exactly what the whole meant. Write the shared shape as base.aon:

server: { host:string port: *8080|integer debug: *false|boolean }

the production pins as override.aon:

server: { host:"app.corp.example" port:8443 }

and an entry file main.aon that loads both:

@"./base.aon"
@"./override.aon"
$ aontu main.aon
{
  "server": {
    "debug": false,
    "host": "app.corp.example",
    "port": 8443
  }
}

The override beats the *8080 default, debug falls back to its own, and host: string is satisfied rather than replaced. Unification is order-independent, so swapping the two include lines changes nothing: there is no cascade to reason about, only one merged document.

An include is an ordinary value, so it can land under a key. Write car.aon:

color: silver
doors: 4

and mount it in lot.aon, constraints attached:

car: @"./car.aon"
car: { doors:number wheels:4 }
$ aontu lot.aon
{
  "car": {
    "color": "silver",
    "doors": 4,
    "wheels": 4
  }
}

The loaded file never learns it was mounted: its doors: 4 meets the local doors: number the way any two conjuncts meet, and a conflict between a loaded value and a local one is a normal unification error (read one with read a conflict error).

Watch the paths. A relative include resolves against the including file’s own directory (the CLI starts the chain at the entry file’s directory), so a tree of split files moves as a unit. The embedding APIs accept a base directory and an in-memory resolver for tests; see AontuOptions. And one shape of path bypasses the filesystem entirely: a first segment carrying a dot and no ./ prefix (@"corp.example/schemas/service") is a module import, resolved from the project’s vendor tree under the lockfile’s pins. The path carries no version: compatibility is computed at publish, so the major left the name. That story starts in vendor a dependency closure.

The full loading contract, extension defaulting included, is source loading in the language reference. Change a value with an overlay uses this same split so machine edits land in a file of their own, and use-cases/11-shared-modules is the live version of a model split all the way across repositories.