The blueprint: your design, checked at build time

Every pull request changes the shape of the code a little: a new package, an import that points the wrong way, one more outside system. The blueprint draws the whole codebase as layers, before and after the change, and fails the build when the change breaks the rules your team set. It is read in your pipeline, where the code already is, so the source never leaves it.

Blueprint · Changes · @ 0e91fb0 #214 feat(billing): usage-based billing with Meterly Layered service · Express · a composition root · middleware for cross-cutting concerns 31 modules · 19 packages · 8 routes · 5 outside systems · 6 findings · tests 20% of source Arch B 76 −20 Deps A 100 Config A 90 −10 Security A 100 Resil. A 90 Observ. A 100 Tests A 92 Build A 100 Maint. A 100 A 98 → 94 ▼ −4 c0dd2e9 → 0e91fb0 1 the wrong way 3 findings introduced +1 outside system +3 packages 7 changed +15 import paths 1 resolved Entry src/server.ts server Application shell Entry points: where the process starts. 1 package · 1 file Composition root Where the parts are assembled. 1 package · 1 file assembles 10 packages Interface Routes, controllers and handlers: how requests arrive. 5 packages · 7 files · 8 r… all under api/ Business Domain rules and services. 4 packages · 7 files ↻ 4 imports inside Cross-cutting Auth, middleware and logging, used across requests. 1 package · 3 files Infrastructure Databases, queues and clients for other systems. 5 packages · 8 files ↻ 3 imports inside Shared Utilities and types any layer may use. 1 package · 3 files Configuration Settings every layer may read. 1 package · 1 file Beyond the boundary What the code reaches: datastores by driver, services by config, SaaS… PostgreSQL database plans-catalog 2 callers Sentry monitoring Stripe payments api.meterly.io hard-coded host new a package, sized by lines of code routes it serves findings, coloured by the worst imports downward (colour of the layer they leave) imports the wrong way one request, followed added by the change changed removed imports cut from a run finding introduced finding resolved visualdiff.ai · blueprint Repository acme/subscriptions-api Revision c0dd2e9819eb → 0e91fb00010a Read sample Source JavaScript/TypeScript · src · 8 l… 7 imports from business 5 imports from business 5 imports from application shell 3 imports from interface 2 imports from application shell 2 imports from interface 1 import from application shell 1 import from interface 1 import from cross-cutting 1 import from cross-cutting 1 the wrong way, new (root)changed1 file · 29 lines wiringchanged1 file · 37 lines · ◇1 health1 file · 10 lines⇄ 1 plans1 file · 9 lines⇄ 1 subscriptions2 files · 35 lines⇄ 3 usagenew2 files · 33 lines⇄ 2 webhooks1 file · 13 lines⇄ 11 billingchanged1 file · 25 lines meteringnew2 files · 54 lines · ◇2 plans2 files · 26 lines · ◇1 subscriptions2 files · 55 lines · ◇2 middleware3 files · 27 lines adapters/meterlynew1 file · 13 lines · ↗2 adapters/plans-catalogchanged1 file · 13 lines · ↗ adapters/stripechanged2 files · 40 lines · ◇11 db1 file · 7 lines repositorieschanged3 files · 75 lines1 lib3 files · 20 lines configchanged1 file · 24 lines · ◇1 1 2 3 POST /usage/events
acme/subscriptions-api #214, written with an agent. The grade moves A 98 → A 94: 3 packages added, 7 changed, 1 import against the layer order (the dashed red line on the left), and 1 new outside system. The violet line follows POST /usage/events from the route to Meterly.

Where it fits

The same drawing serves every stage of delivery. Each stage gets something it can act on, from the same build step.

  1. Plan

    Start from the building as it is

    Every main-branch build publishes the as-built blueprint. A feature is planned against the real layers and outside systems, not last year’s diagram.

    --as-built → after.svg, blueprint.json

  2. Code

    Give the agent the rules before it writes

    agent-brief.md lists the layers, which way imports may point, and what the last run flagged. Put it in the agent’s instructions; it runs the CLI itself and fixes what it introduced before anyone reviews.

    agent-brief.md

  3. Build

    Fail the job on a design regression

    The CI step reads the shape of both revisions where the code already is. An import that runs the wrong way, a new package cycle or a grade drop fails the gates you set, and --fail-on hold exits 1.

    exit code, job summary

  4. Review

    Open the pull request on what moved

    The comment leads with the design delta: packages added, imports against the layer order, new outside systems, findings introduced and resolved, and the drawing one click away.

    PR comment, changes.svg

  5. Release

    Keep a design record per release

    Upload the blueprint with the build’s artifacts. Every tag keeps its drawing and its JSON, so “when did billing start calling Meterly?” has an answer.

    build artifact per tag

  6. Operate

    Know what the code reaches

    Beyond the boundary lists every datastore, SaaS, service and hard-coded host the code calls, and which packages call it: the inventory on-call, vendor review and ArchCanvas start from.

    boundary in blueprint.json

At build time

The CLI that reviews your pull requests also reads the shape of both revisions: the head from the checkout, the base through one git cat-file call. It sends that shape with the diff. The server builds a design model for each side: packages are the directories under the source root, placed in layers from the application shell down to configuration. It compares the two models and checks the result against architecture in your .visualdiff.json. A failed block gate holds the pull request, so --fail-on hold fails the job.

Run it on your machine first. Nothing is stored without a key:

terminal
curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o visualdiff.mjs
node visualdiff.mjs --blueprint-out visualdiff-blueprint
OptionWhat it does
--blueprint-out <dir>Write changes.svg, before.svg, after.svg, blueprint.json and agent-brief.md.
--as-builtNo pull request: draw HEAD as built, into --blueprint-out (default visualdiff-blueprint).
--no-blueprintSend no revision facts: no blueprint and no architecture gates.
--dry-runSend nothing; with --blueprint-out, also write facts.json.
--fail-on holdExit 1 when the verdict is hold, which a failed block gate causes.

What leaves the build

Structure, never source. For each source file of each revision:

FieldHolds
p, locThe path and its lines of code (not blank, not comment-only).
imp, timpModule specifiers imported for values and for types only, e.g. ../../api/usage/router.js.
rt, mntRoutes declared ([method, path]) and routers mounted under a prefix.
http, ntoHow many outbound calls the file makes, and how many carry no timeout.
hostsHost names in URL literals, e.g. api.meterly.io.
envEnvironment-variable names read, e.g. METERLY_API_KEY. Never their values.
secLine numbers where a secret-looking literal sits. Never the literal.
ifc, entry, types, testInterfaces declared, whether the file starts a process, declares only types, or is a test.
other, manifestsNotable non-source paths (Dockerfiles, CI configs, lockfiles, .env files) and dependency names from the manifests.

Here is the whole record for the Meterly client in the sample above:

{
  "p": "src/adapters/meterly/client.ts",
  "loc": 13,
  "timp": [
    "../../metering/types.js"
  ],
  "http": 1,
  "nto": 1,
  "hosts": [
    "api.meterly.io"
  ]
}

To see exactly what would be sent from your repository, without sending it, run node visualdiff.mjs --dry-run --blueprint-out visualdiff-blueprint and read visualdiff-blueprint/facts.json. Pass --no-blueprint to send none of it.

Architecture gates

The gates live in .visualdiff.json on the default branch, next to the rest of your policy. Like the rest of the policy, they are read from the base revision, so a pull request cannot loosen the rules it is judged by. Each gate is block, warn or off. A block failure holds the pull request; a warning adds a reason to the review.

{
  "architecture": {
    "gates": {
      "wrongWay": "block",
      "newCycle": "block",
      "newBoundary": "warn",
      "gradeDrop": 8,
      "minGrade": "B"
    },
    "layers": [
      { "path": "src/clients/**", "layer": "infrastructure" },
      { "path": "src/jobs/**", "layer": "app" }
    ],
    "allow": [["infrastructure", "shared"]]
  }
}
GateDefaultFails when
wrongWaywarnA new import runs against the layer order, e.g. an adapter importing a route. Type-only imports and the composition root’s injected types do not count.
newCyclewarnTwo or more packages now import each other.
layerSkipoffA handler reaches the database or a client directly, past the business layer.
newBoundaryoffThe code reaches an outside system it did not reach before.
gradeDrop10The overall design score drops by at least this many points. Always blocks; 0 turns it off.
minGradeDThe grade after the change is below this. Always blocks.

layers places paths the directory names do not explain; the first match wins. allow accepts an upward import between two layers that your team has decided is fine. Layers, top to bottom: app, wiring, interface, ui, business, crosscutting, infrastructure, shared, config.

Artifacts

With --blueprint-out, the build writes a directory you can upload with its other artifacts:

FileFor
changes.svgThe pull request painted over the design as it will be: rooms added, changed and removed, imports added and cut, findings introduced and resolved.
before.svg, after.svgEither side, plain. after.svg is the as-built record on a main-branch build.
blueprint.jsonBoth design models, the delta, the gates and the verdict, for your own tooling.
agent-brief.mdThe layers, the import direction and what to fix, written for a coding agent’s next turn.
facts.jsonWith --dry-run: exactly what would leave the build, to read before you trust it.

With a key and --store, the review comment also carries the drawing, served from the stored report.

CI recipes

The pull-request job below reviews the change, fails on a block gate and keeps the blueprint. It needs the full history so the merge base is reachable.

.github/workflows/visualdiff.yml
name: visualdiff
on: pull_request
permissions:
  contents: read
  pull-requests: write
jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o "$RUNNER_TEMP/visualdiff.mjs"
      - run: node "$RUNNER_TEMP/visualdiff.mjs" --comment --store --fail-on hold --blueprint-out visualdiff-blueprint
        env:
          VISUALDIFF_KEY: ${{ secrets.VISUALDIFF_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: blueprint
          path: visualdiff-blueprint/

Add a main-branch job to publish the as-built record of every merge. This is the drawing that planning starts from.

.github/workflows/blueprint.yml
name: blueprint
on:
  push:
    branches: [main]
jobs:
  as-built:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o "$RUNNER_TEMP/visualdiff.mjs"
      - run: node "$RUNNER_TEMP/visualdiff.mjs" --as-built
      - uses: actions/upload-artifact@v4
        with:
          name: blueprint-${{ github.sha }}
          path: visualdiff-blueprint/
.gitlab-ci.yml
visualdiff:
  image: node:22
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    GIT_DEPTH: "0"
  script:
    - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o /tmp/visualdiff.mjs
    - node /tmp/visualdiff.mjs --store --comment --fail-on hold --blueprint-out visualdiff-blueprint
  artifacts:
    when: always
    paths: [visualdiff-blueprint/]
bitbucket-pipelines.yml
pipelines:
  pull-requests:
    '**':
      - step:
          name: visualdiff
          image: node:22
          clone:
            depth: full
          script:
            - git fetch origin "$BITBUCKET_PR_DESTINATION_BRANCH"
            - curl -fsSL https://visualdiff.ai/cli/visualdiff.mjs -o /tmp/visualdiff.mjs
            - node /tmp/visualdiff.mjs --store --comment --fail-on hold --blueprint-out visualdiff-blueprint
          artifacts:
            - visualdiff-blueprint/**

Other CI systems follow the same pattern: check out with full history, run the CLI with --base origin/<target>, and keep the output directory. The other hosts are covered in the docs.

Reading the drawing

  • Decks are layers, read top to bottom in the order a request travels. Rooms are packages, sized by lines of code, with the routes they serve.
  • Lines between decks are imports, coloured by the layer they leave. Imports that skip several decks ride the bus in the right gutter. Imports inside a deck are counted rather than drawn, and type-only imports are left out.
  • The dashed red line in the left gutter is an import that runs against the layer order.
  • The violet line follows one request from its route through the packages it touches to the outside system it reaches. For a pull request, it prefers a route through what changed.
  • Dots are findings, coloured by the worst one. A ring marks a finding the change introduced, and a check marks one it resolved.
  • Beyond the boundary lists the datastores, SaaS, services and hard-coded hosts the code reaches. Datastores are found by their drivers, services by their configuration, and hosts by URL literals.
  • On Changes, green rooms are new, amber rooms changed, and dashed rooms were removed.

What it grades

Each of nine categories starts at 100 and loses points for each finding, weighted by severity. The overall grade is their mean: A from 90, B from 75, C from 60, D from 40.

CategoryChecks
ArchitectureImports that run against the layer order, package cycles, handlers that skip the business layer.
DependenciesImports the manifest does not declare, and dev dependencies used at runtime.
ConfigurationHard-coded hosts, and environment variables read outside the configuration module.
SecuritySecret-looking literals (by line, never the value), tracked .env files, containers that run as root.
ResilienceOutbound calls without a timeout.
ObservabilityNo health endpoint; no error tracking or tracing.
TestingThe ratio of test code to source, and packages no test imports.
BuildNo CI pipeline in the repository; no lockfile.
MaintainabilityModules large enough to be doing several jobs.

API

MethodPathWhat it does
POST/api/v1/analyzeWith blueprint: { head, base }, the report carries the blueprint and the gates join the verdict. blueprintSvg: true also returns the drawings.
POST/api/v1/blueprintDraw a revision from its facts, with no pull request and nothing stored. Takes head, optional base, policy and format: json | svg.
GET/api/v1/reports/:id/blueprintA stored report’s drawing as SVG (or format=json). Takes side=changes | before | after and the share token t.

Limits

  • Imports are read with patterns, not a compiler: an import built at run time, or a framework that wires modules by convention, is not seen.
  • Packages are directories two levels under the source root. A monorepo with several services draws best one service at a time (run the CLI from that service’s directory).
  • Layers come from directory names (api, routes, services, domain, repositories, adapters, lib…) and what a package touches. Where your names differ, map them with architecture.layers.
  • Up to 8,000 source files per revision are read; files over 400 KB are skipped.