The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Loadbearing listing page.
LoadBearing is a .NET architecture checker that enforces one C# spec and renders the same rules for coding agents.
A long-lived codebase has layers, boundaries and rules. They live in a few heads, no build step checks them, and the diagrams drift. Nothing fails when a change crosses a boundary. A coding agent makes that change quickly and plausibly, and it cannot see which walls are load-bearing. LoadBearing's answer is architecture-as-code. The rules become one C# spec, and the spec produces every surface this page names.
AGENTS.md block, per-directory rule cards and MCP query tools for coding agents.Write your architecture once. Use it everywhere.
LoadBearing is pre-alpha. Minor versions can still change the spec API before 1.0. Status holds the current inventory.
Install the tool, build your solution, and check it:
A rule is one statement:
That is the whole rule. It has an ID, a posture, a constraint, a reason and a fix. Here the posture is Enforce. The rule is committed in the clean-architecture example, and CI holds check green against the codebase it governs.
Rules like the one above govern this repository too. One of them keeps the CLI off stdout, because the MCP server speaks JSON-RPC over that channel. Suppose an agent adds a progress printer to the CLI and reaches for Console.WriteLine. When it tries to hand the work back, the Stop hook in hooks/ runs check over the working tree, the rule goes red, and the hook refuses the stop with this report on the agent's stderr:
Every line of that report is something the agent can act on without asking a human. The agent routes the output through the command's console instead, and the next stop is clean.
LoadBearing on itself shows this repository's own spec on the other surfaces too, from the committed rule and the prose it renders to the xUnit run, code scanning and the graph. It also says how every excerpt on both pages is held to its source.
Every rule carries one of the first two, and every scope one of the last two.
| Posture | What it is | What fails |
|---|---|---|
Enforce | the law | every violation, even ones predating the rule |
Migrate | a ratchet over a counted baseline | new violations; baselined sites stay quiet |
Quarantine | containment for a scope | a new reference into the scope |
Caution | dragons for code new callers are welcome to | nothing; a change set touching it draws a warning |
Enforce failing violations that predate it is what Migrate exists for: loadbearing baseline records a rule's current violations, new ones fail from the next commit, and the baseline only shrinks. At zero, the tool suggests promoting the rule to Enforce. Every scope carries a diff-aware tripwire: with check --diff-base <ref>, a change set that touches the scope draws a warning. For a Caution scope that tripwire is the whole posture: the dragons prose lands on the scope's directory as a card, explain and arch_context serve it, and no reference into the scope is ever a violation.
The three postures after Enforce each answer a way a coding agent goes wrong on an established codebase. Enforce is the law the code already keeps, and a change that crosses it fails wherever the check runs. The Meridian example walks three of those ways on real output.
SqlConnection, so inline SQL reads as house style and an agent writes its next controller the same way. Migrate answers it: the rendered context calls that pattern debt rather than style, in words the agent reads before it writes.Quarantine scope makes the gateway the only sanctioned way in.Quarantine or Caution scope answers it. The card on that scope's directory says what the code does, which part is load-bearing, and how to call in.Each target below consumes the same reified model, and every violation report carries the rule ID, the generated rule sentence, the reason, the fix, and the exact file:line.
| Target | What it is |
|---|---|
loadbearing check | one pass-or-fail verdict for the command line and CI |
check --sarif | that verdict as SARIF 2.1.0, for code scanning |
| xUnit adapter | every rule an individually named test |
| build analyzer | a broken rule as a squiggle in the editor and a compiler warning in the build |
loadbearing render | the managed AGENTS.md block, per-directory rule cards, and the model as a JSON file |
loadbearing mcp | arch_check, arch_status, arch_explain, arch_context, and arch_graph, plus a derive_spec prompt |
| agent hook | check when a turn ends; a red rule refuses the stop, report on stderr |
The adapter's failure text is byte-identical to the CLI's: the two share one renderer, and a product test pins them equal. The managed block plus loadbearing explain are also the generated architecture documentation, written for agents first and readable by people; LoadBearing on itself shows the gate that keeps it current.
Claude Code reads AGENTS.md on its own from version 2.1.277, including each per-directory card once it opens a file in that directory. It does so only where no CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md sits in the directory it was started in or any directory above it. Your own ~/.claude/CLAUDE.md does not count. A repository that already has a CLAUDE.md brings the managed block in with one line in that file, @AGENTS.md, and render warns when it finds such a file without the import. The import covers the root file only. The cards load beside a CLAUDE.md for each developer who sets Claude Code's Project instructions option to claude-md-and-agents-md in /config. That option is a personal setting, and a repository cannot commit it.
The compiler is the source of truth for your code. LoadBearing is the source of truth for your architecture.
NetArchTest and ArchUnitNET run architecture rules inside your unit tests, and they are good at it. LoadBearing moves the rules out of test code into one spec (architecture-as-code rather than architecture tests) and renders every surface above from it.
| Tool | What you write | Where it runs |
|---|---|---|
| NetArchTest | fluent assertions in test methods | your test runner |
| ArchUnitNET | ArchUnit-style rules in test classes | your test runner |
| LoadBearing | one spec in its own project | every target above |
The grammar comes from surveying that prior art, and GRAMMAR.md records each divergence. Constraints negate in the verb (MustNotReference), following ArchUnitNET. If you know ArchUnit's FreezingArchRule: what freezing does (accept a rule's current violations as a baseline) is Migrate with its counted baseline here. Quarantine contains a scope; it does not accept the scope's violations.
Because is mandatory. A rule without one is an invalid spec: check refuses to run it and reports every spec error in one pass. Even the predicate escape hatch, Must(condition, description:), does not compile without its description. Every reason ships to your agents in the rendered context, and in the Interchange example each of the twelve rules carries a Citation naming the learn.microsoft.com page its reason rests on. Nine of those twelve come from a shared rule pack, which is an ordinary class library of static methods: the pack owns the citation, the spec picks the posture. That pack ships in this repository as a working example. A pack is a pattern you own, so there is no registry to depend on.
LoadBearing is built for long-lived, business-critical .NET codebases: systems too important to rewrite, with an architecture that is real but written down nowhere. Start where the code is:
Migrate.loadbearing baseline records every current violation on a counted, committed baseline.Enforce.The first draft is usually an agent's work, not a blank file. loadbearing mcp ships one prompt, derive_spec, which walks an agent from a solution with no spec to a reviewed proposal. The recipe surveys the estate with arch_graph, scaffolds the spec project, drafts every hypothesis as a rule, and lets check count the violations that assign each rule its posture. The tool does not infer the architecture and the agent does not ratify it: every proposed rule crosses a curation gate where you accept, edit, or drop it, and recording the baseline stays a human command. The Meridian adoption walkthrough is that recipe replayed on a committed example codebase, one real command at a time.
Real solutions are rarely one shape. Each row is one such shape and what the checker does with it. The changelog records the release each landed in.
| Shape | What the checker does |
|---|---|
| Linked source files | reads a shared file's references once per compiling project |
| Multi-targeting projects | one project; its shared types take one framework's members and hierarchy and every framework's references |
| One type name in several projects, or in a package too | follows the compiler's binding for each reference |
| Generated code | counts it per project and per rule subject |
| Polyglot solutions | names each F#, VB or SQL project it cannot read |
| A repository on central package management | the spec project scaffold builds under Directory.Packages.props |
| Non-SDK-style .NET Framework projects | loads them through the Framework build host; see .NET Framework |
| A binlog from a real build | check --binlog replays it; see .NET Framework |
| Solution filters | stamps the projects a filtered run never checked; see Installing |
A project in a language the checker does not read makes the model smaller: every document names it, and the run still answers. The survey, loadbearing graph, names the framework a multi-targeting project's shared types follow and every type name that two projects, or a project and a package, both declare. .Authored() on a rule's subject keeps the rule off generated code.
The tool runs on .NET 10. The codebase it checks does not have to, and neither does the spec that governs it.
A spec project can target net48 and compile at that framework's default language level, C# 7.3. It references the same netstandard2.0 Zphil.LoadBearing package every other spec does, and the CLI loads the built DLL in an isolated load context. A typeof() anchor works from there while the anchored type's own closure stays inside netstandard2.0. Past that line, including .NET Framework types with no counterpart on .NET, a namespace pattern is the anchor, and it needs no assembly load at all.
Old project files load too. A non-SDK-style Framework project, the kind in the 2003 MSBuild XML namespace with explicit <Reference> items and a hand-maintained AssemblyInfo.cs, loads through the .NET Framework build host Roslyn ships and reports at file:line like anything else:
And the build server can stay where it is. check --binlog replays a binary log from a real build, including one produced by .NET Framework MSBuild.exe, so the machine that builds needs no .NET 10; only the machine that analyses does. Replaying that log and opening the workspace directly produce byte-identical output, which is what makes the replay a shortcut rather than a lesser reading.
Loading a non-SDK-style project and replaying a log from .NET Framework MSBuild.exe both need Windows, because that is where the Framework build host and MSBuild.exe come from. What the tool looks for is a vswhere-discoverable Visual Studio or Build Tools install carrying MSBuild\Current\Bin\MSBuild.exe. Where several are installed it prefers VS 2019 or 2022. When it has to take something outside that pair, it names what it took on stderr, beside any load failure. Set LOADBEARING_VS_INSTALL_PATH to an install root, the parent of MSBuild\Current\Bin, to choose one yourself. A net48 spec project carries no such requirement and builds anywhere.
Six worked examples in examples/ share one fictional freight-forwarding company. Four are solutions: CI builds each one, holds check green against the committed tree, and re-renders every managed block under examples/ to prove a zero diff. The other two walk a flow with captured output. Three are whole codebases:
Three go deeper on one surface each:
The CLI ships as a .NET global tool; the three commands at the top of this page install it, build your solution and check it. The checker never builds the code it checks, so restore and build the solution first. A stale build gives stale verdicts.
The machine running it needs a .NET 10 SDK: commands that load a solution (check, render, status, graph, baseline, mcp) do so through MSBuildWorkspace via MSBuildLocator, and a runtime-only environment cannot host that load. The codebase under check has no version requirement of its own: LoadBearing never builds or retargets it, and it can target .NET Framework 4.8 or anything newer. The spec project compiles against one package, Zphil.LoadBearing, which is netstandard2.0.
Run the install from outside any repository whose global.json pins an SDK below 10. Inside one, dotnet selects the pinned SDK, which cannot install a tool built for .NET 10.
A project that fails to load is treated as a wrong model rather than a smaller one. check, baseline, render, graph and status all exit 2 and say which projects failed, and --allow-workspace-diagnostics opts into the partial model. A project whose NuGet packages did not resolve is the same wrong model reached more quietly. It loads completely, and only the edges its package references would have produced are missing, so a rule about a package reports itself inert and the run goes green. That covers a restore that ran and failed and one that never ran at all. An SDK-style project writes obj/project.assets.json every time it restores, so not having one is a fact about that project, while a non-SDK-style .NET Framework project never writes one and is left alone. The unresolved case gates on the same terms and takes the same opt-out. Its refusal names those projects separately and sends you to dotnet restore, because a project that loaded and a project that never did ask for different repairs. The xUnit adapter answers the same way in test dress: one named test fails carrying both causes, every rule case skips, and an AllowWorkspaceDiagnostics override opts in.
A solution filter is the opposite case: the model is smaller, and still right. check, status, graph and the arch_context tool still answer, stamping which declared projects the filtered run never checked, and the JSON documents carry them as uncheckedProjects. A green over a subset can no longer read as a green over the solution. The verbs that read absence as evidence refuse instead. baseline --init, baseline --accept-reductions and render exit 2, because the files they would write vouch for projects the run never measured, and the fix they name is to run against the solution the filter references. The narrowing is measured from what actually loaded, not from the filter's text. A selection whose references pull in the whole solution narrows nothing and prints nothing. The xUnit adapter keeps every rule verdict and skips Workspace_LoadedCompletely, naming what was not checked. Committed baselines, render targets, arch_context paths, diff resolution and the reported project paths all resolve against the solution the filter references, never against the filter's own directory. A filter kept apart from its solution answers with the same paths as the solution itself. A rule whose whole selection lives in the unchecked projects reports as skipped, naming the filter, so an empty selection does not read as a failure. The fail-on-empty defaults stand untouched for unfiltered runs.
The command is loadbearing. Six lockstep-versioned packages make up a release, and a spec project references Zphil.LoadBearing at the version loadbearing --version prints:
| Package | What it is |
|---|---|
Zphil.LoadBearing.Cli | The loadbearing global tool: check, render, explain, status, graph, baseline, and the MCP server (loadbearing mcp). |
Zphil.LoadBearing | The spec contract, zero dependencies. It carries an analyzer that reports a spec's bad string literals as compiler warnings while you write it. |
Zphil.LoadBearing.Xunit | The xUnit adapter: every rule as an individually named test. |
Zphil.LoadBearing.Analyzers | The build analyzer: a broken rule as a squiggle in the editor and a compiler warning in dotnet build, read from the model file render --model writes. |
Zphil.LoadBearing.Roslyn | Workspace infrastructure: solution and binlog loading, the extraction cache, the baseline store. A dependency of the above, not for direct reference. |
Zphil.LoadBearing.Extraction | Per-compilation extraction machinery; a dependency of the above, not for direct reference. |
An MCP client launches the same tool with the mcp verb; the server speaks stdio. The
.mcp.json shape:
For Claude Code, one command writes that same entry into the project's .mcp.json:
The solution argument is optional, and most repositories should still pass it. Without it the
server reads LOADBEARING_SOLUTION_PATH, and when that is unset too it walks up from its
working directory to the first ancestor holding exactly one solution file, where a .slnf
filter counts only when no .sln or .slnx stands beside it. That resolves nothing where the
solution sits under src/, and refuses as ambiguous where several sit side by side, which
between them covers most real repositories. A solution file passed as the argument beats both.
When it cannot bind, the server starts anyway and every tool call returns the reason, naming
any solution it saw one level down. Each reply also names the CLI command that reads the same
model, because rebinding the server takes a client-config edit and a relaunch. The failure is
readable in the client rather than arriving as a server that would not start. However the
server is launched, the rule from Installing still applies: restore and build the solution
first; the checker never builds, and a stale build gives stale verdicts.
dnx launches the server straight from nuget.org without the global install; the -- hands
everything after it to the tool:
This is how MCP-registry clients run the server; note the mcp subcommand. Spell it dotnet dnx
in a shell: on Windows the short form is a .cmd, which a POSIX shell will
not resolve. A config generated from the registry manifest passes no solution argument, so a
repository whose walk-up resolves nothing gets an unbound server. That session still works from
the CLI, but not through the installed command, because dnx runs the package without installing
anything: the server's replies name dotnet dnx Zphil.LoadBearing.Cli@<version> --yes -- in front
of the verb, pinned to the build answering. Bind such a repository at install time instead: put the
solution in the config's args after mcp, or set LOADBEARING_SOLUTION_PATH in its env.
dnx ships with the .NET 10 SDK. A client that launches dnx itself needs only one installed on
the machine, because the dnx command picks the newest installed SDK and never reads
global.json. A repository pinning an older SDK therefore launches the server anyway. The
dotnet dnx spelling a shell needs goes through dotnet, which honours the nearest global.json.
Inside a repository pinning an SDK below 10 that spelling does not exist, so run it from outside
the repository and pass the solution's absolute path. Without any .NET 10 SDK there is no dnx
command, and a registry client sees a server that died before the handshake, with the reason on
stderr alone.
These parts exist: the reified model, the fluent builder, Roslyn extraction, the CLI verbs, the SARIF writer, the xUnit adapter, the build analyzer, the MCP server, and the render pipeline, with all four postures evaluating. LoadBearing on itself shows them running over this repository's own code, and CI uploads that check --sarif run to code scanning. The spec contract carries its own analyzer, which checks a spec's string literals, and a completion provider, which offers your projects' namespace and type names as you type. The fluent surface can still move; GRAMMAR.md is its spec.
The six packages version in lockstep under 0.x semver. Every spec-API change is listed under Breaking in the changelog and ships without a shim, and a patch version makes none. The JSON documents carry a schemaVersion that moves when their shape does, and the human-readable reports can gain lines in any release. The public surface of the two packages a consumer compiles against is pinned by tests and compared with the previous release at pack time, so nothing there moves by accident.