Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

StructurizrX is a Rust toolchain for describing software architecture as a plain-text model and generating diagrams from it — a re-implementation of Structurizr and the C4 model, evolving into an LLM-native architecture description system: one master model, views as queries over it, and tool feedback precise enough that both humans and LLM agents can self-correct from error messages alone.

structurizrx validate ws.dsl
parse error at line 5, column 17: unknown element identifier 'shoop'
in relationship (did you mean 'shop'?)

Why a master model

Diagrams rot because they state each fact once per diagram. If api calls billing and that fact lives only inside a hand-drawn box-and-arrow picture, nothing stops a second diagram from drifting out of sync the moment the relationship changes. StructurizrX keeps the model as the single source of truth and treats views as selections over it — stored as queries (auto focus api, auto slice element.status==idea), generated deterministically, never hand-maintained.

Mermaid and PlantUML remain excellent outputs — a generated view exported to Mermaid renders natively in GitHub READMEs and PRs — but neither is a model language: there’s no way to state a fact once and derive many views from it. StructurizrX’s DSL is the model layer; Mermaid/PlantUML/DOT/SVG are projections of it.

Progressive fidelity

The same format holds a napkin sketch (“there’s a shop, it talks to billing somehow”) and a detailed spec (typed ports, connector semantics, quality-attribute annotations, milestones). Start with arrows:

customer -> shop "buys things"
shop -> billing "somehow charges" ?

and grow into a full model with containers, ports, relationship kinds, and generated views, all under the same identifiers — a placeholder becomes a softwareSystem, later grows containers, without ever renaming anything downstream.

Where to go next

  • Install and Quickstart to get running.
  • The CLI reference for every subcommand.
  • The Language reference for the full DSL — both the parts inherited from upstream Structurizr and StructurizrX’s own extensions (ports, relationship kinds, status, milestones, generated views).

The full extended design, including the reasoning behind each extension, lives in docs/SPEC.md in the repository.

Install

Homebrew (macOS & Linux)

Works on macOS (Apple Silicon and Intel) and Linux (x86-64 and arm64):

brew tap pomali/structurizrx https://github.com/pomali/structurizrx
brew install pomali/structurizrx/structurizrx

Windows

Scoop

Scoop installs from the project’s bucket:

scoop bucket add structurizrx https://github.com/pomali/structurizrx
scoop install structurizrx

scoop update structurizrx picks up new releases automatically.

winget

Once the package is published to the Windows Package Manager Community Repository:

winget install Pomali.StructurizrX

Chocolatey

Once the package is published to the Chocolatey Community Repository:

choco install structurizrx

Maintainer note: the winget and Chocolatey manifests live under packaging/winget/ and packaging/chocolatey/ and are regenerated (version + checksum) by the release workflow on each tag. Availability via winget install / choco install additionally requires a one-time submission to each catalog (a PR to microsoft/winget-pkgs, and choco push to the community feed), which are external, moderated publishing steps.

Linux packages (apt / dnf)

Every release also publishes .deb and .rpm packages for x86-64 and arm64. These are standalone package files (not a hosted repository), so download the one matching your distribution and CPU, then install it with your package manager:

Debian / Ubuntu (.deb) — amd64 or arm64:

VERSION=0.1.1
curl -LO https://github.com/pomali/structurizrx/releases/download/v${VERSION}/structurizrx_${VERSION}_amd64.deb
sudo apt install ./structurizrx_${VERSION}_amd64.deb

Fedora / RHEL / openSUSE (.rpm) — x86_64 or aarch64:

VERSION=0.1.1
curl -LO https://github.com/pomali/structurizrx/releases/download/v${VERSION}/structurizrx-${VERSION}-1.x86_64.rpm
sudo dnf install ./structurizrx-${VERSION}-1.x86_64.rpm

Because these aren’t served from an apt/dnf repository, apt/dnf won’t auto-update them — grab the newer package on each release (or use Homebrew, which does track updates).

Prebuilt binaries

Every release ships a self-contained structurizrx binary for the platforms below. Download the archive for your platform, extract it, and put the binary on your PATH.

PlatformDownload
macOS (Apple Silicon)structurizrx-aarch64-apple-darwin.tar.gz
macOS (Intel)structurizrx-x86_64-apple-darwin.tar.gz
Linux (x86-64, glibc)structurizrx-x86_64-unknown-linux-gnu.tar.gz
Linux (x86-64, static/musl)structurizrx-x86_64-unknown-linux-musl.tar.gz
Linux (arm64)structurizrx-aarch64-unknown-linux-gnu.tar.gz
Windows (x86-64)structurizrx-x86_64-pc-windows-msvc.zip

The static musl build has no shared-library dependencies and runs on any Linux distribution (including Alpine and minimal containers).

Linux / macOS

# pick the archive matching your platform from the table above
curl -LO https://github.com/pomali/structurizrx/releases/latest/download/structurizrx-x86_64-unknown-linux-gnu.tar.gz
tar xzf structurizrx-x86_64-unknown-linux-gnu.tar.gz
sudo install structurizrx /usr/local/bin/    # or move it anywhere on your PATH

Windows

Download structurizrx-x86_64-pc-windows-msvc.zip from the latest release, extract structurizrx.exe, and place it in a directory on your PATH (for example, run in PowerShell):

Expand-Archive structurizrx-x86_64-pc-windows-msvc.zip -DestinationPath .
# then move structurizrx.exe somewhere on your PATH

Build from source

Requires a Rust toolchain.

git clone https://github.com/pomali/structurizrx
cd structurizrx/rust
cargo build --release -p structurizr-cli    # binary: target/release/structurizrx

All Rust code lives under rust/ as a Cargo workspace; cargo commands should be run from there. See the repository’s CLAUDE.md for the full crate breakdown if you’re contributing to StructurizrX itself.

Verify the install

structurizrx --version
structurizrx docs   # prints the DSL cheat sheet

Next: the Quickstart.

Quickstart

The fastest path: a sketch

A sketch is a file with no workspace block — just arrows. Unknown names become placeholder software systems automatically; ? marks a relationship you’re not sure about yet:

customer -> shop "buys things"
shop -> billing "somehow charges" ?
billing -> erp

This is already a complete, valid model — it parses, validates, and renders a single landscape view with everything in it:

Save it as sketch.dsl and view it live in the browser:

structurizrx serve sketch.dsl --open

Edit the file and save; the browser reloads automatically.

A full workspace

A full workspace uses the Structurizr DSL (StructurizrX reads standard upstream DSL) plus StructurizrX’s own extensions for ports, relationship kinds, status, milestones, and generated views:

workspace "Shop" {
    model {
        customer = person "Customer"
        shop = softwareSystem "Shop" {
            web = container "Web App" "Storefront" "TypeScript"
            api = container "API" "Handles requests" "Rust" {
                status implemented
                port rest "Customer REST API" { protocol "HTTPS/JSON" }
            }
            db = container "Database" "Stores data" "PostgreSQL" { tags "Database" }
            web -> api.rest "calls"
            api -> db "reads and writes" { kind sync }
        }
        customer -> web "shops on"
    }
    views {
        auto                     // generated default view set
        auto focus api           // neighborhood view around one element
        auto lint                // placeholders, orphans, unbound ports
    }
}
structurizrx validate ws.dsl --strict    # errors + lint findings (add --json for tooling)
structurizrx render ws.dsl --format svg --output ./out
structurizrx serve ws.dsl --port 3000 --open

render --format svg produces one file per view. The zero-config auto default alone gives a landscape view plus a context view per system and a container view per non-empty system:

auto focus api adds a neighborhood view centered on a single element — here, everything one hop from api in both directions:

validate is strict by default: unknown identifiers and misplaced or misspelled keywords fail with the offending file and line (include-aware), the accepted keywords for that context, and a “did you mean” suggestion. Forward references are legal; !sketch opts a full workspace into the same leniency sketch files get.

Where to go next

  • The full CLI reference for every subcommand and flag.
  • The Language reference for the complete DSL — ports, relationship kinds, milestones, generated views, and everything else used above.

CLI reference

The structurizrx binary (package structurizr-cli) accepts both .dsl and .json workspace files for every subcommand that takes a file argument.

CommandWhat it does
validateParse + validate; --strict also fails on lint findings; --json emits structured output
renderExport diagrams (materializes generated views first)
serveLive-reloading web viewer with a JSON API
digestCompact plain-text model + view summary, sized for LLM context
queryRun a selector expression against a workspace
exportWorkspace JSON (superset of the Structurizr JSON schema)
docsPrint the DSL cheat sheet

There’s also structurizrx lsp, which runs the DSL language server over stdio for editor integration (see the VS Code extension under editors/vscode in the repository) — it has no user-facing flags and isn’t covered further here.

Global behavior

  • --version prints the binary version; --help (or a subcommand’s --help) prints usage.
  • File loading picks the DSL parser or the JSON deserializer based on the file extension (.json vs anything else, treated as DSL).

structurizrx validate

Parse and validate a .dsl or .json workspace file.

structurizrx validate <file> [--strict] [--json]
FlagEffect
--strictAlso fail (non-zero exit) on lint findings — placeholders, uncertain (?) items, orphan elements, unbound ports
--jsonEmit machine-readable JSON instead of text: {valid, errors: [{code, message}], lint: [{code, elementId, name, message}]}

Without --json, parse/validation errors and (with --strict) lint findings print to stderr with stable error codes, and the process exits non-zero on failure. On success it prints ✓ Workspace '<name>' is valid.

Errors are strict by default even without --strict: an unknown element identifier, or a misplaced/misspelled keyword, is always a hard parse error — with the offending file and line (include-aware across !included files), the accepted keywords for that context, and a “did you mean” suggestion. --strict only adds the lint pass (things that parse fine but indicate an unfinished model) to what fails the command.

structurizrx validate ws.dsl --strict --json
{
  "valid": true,
  "errors": [],
  "lint": [
    { "code": "unbound-port", "elementId": "api.rest", "name": "rest", "message": "port 'rest' is never connected" }
  ]
}

structurizrx render

Export diagrams from a workspace file to disk.

structurizrx render <file> [--format <fmt>] [--output <dir>]
FlagDefaultEffect
--formatplantumlOne of svg, mermaid, plantuml, dot/graphviz
--output, -o.Output directory (created if missing)

render first materializes generated (auto) views — the same step serve performs — so auto, auto focus, auto lint, etc. in the views block are expanded before export. Each diagram is written as <output>/<view-key>.<extension>.

Not every exporter supports every view type; unsupported views are skipped with a warning rather than silently vanishing:

FormatRenders
svgsystem landscape, system context, container, component
mermaidsystem landscape, system context, container, component
dot/graphvizsystem landscape, system context
plantuml (default)system landscape, system context, container
structurizrx render ws.dsl --format svg --output ./out
Generated views: systemlandscape, systemcontext-shop, container-shop
Written: ./out/systemlandscape.svg
Written: ./out/systemcontext-shop.svg
Written: ./out/container-shop.svg
Warning: 1 view(s) skipped (svg exporter does not support: 1 dynamic)

structurizrx serve

Serve a workspace, or a directory of workspaces, in a local web browser with live reload.

structurizrx serve [path] [--port <n>] [--open]
Argument/FlagDefaultEffect
path.A .dsl/.json file, or a directory containing one or more workspaces
--port, -p3000TCP port to listen on
--openoffOpen the browser automatically after starting

Like render, serve materializes generated (auto) views before rendering. Editing and saving a watched file reloads the browser via a WebSocket automatically — no manual refresh.

Routes

RouteWhat it is
GET /Workspace list
GET /workspace/{name}Workspace overview
GET /workspace/{name}/diagram/{key}A single diagram
GET /workspace/{name}/decisionsADR list (from !adrs)
GET /workspace/{name}/decisions/{id}A single ADR
GET /workspace/{name}/canvasIn-browser WASM rendering demo
GET /docs/This documentation site
GET /llms.txtThe DSL cheat sheet, as plain text

JSON API

The same server exposes a JSON API mirroring the CLI, useful for agents working against a live server:

RouteEquivalent to
GET /api/workspacesworkspace list with counts
GET /api/workspace/{name}the full workspace JSON (export)
GET /api/workspace/{name}/decisions[/{id}]ADR data
GET /api/workspace/{name}/diagram/{key}/svgrender --format svg, one diagram
GET /api/workspace/{name}/diagram/{key}/mermaidrender --format mermaid, one diagram
GET /api/workspace/{name}/digestdigest
GET /api/workspace/{name}/query?expr=...query

structurizrx digest

Print a compact plain-text summary of the model and view set, sized to paste into an LLM’s context window.

structurizrx digest <file>

Generated (auto) views are materialized first, so the digest reflects the effective view set — the same thing render/serve would produce, not just what’s literally written in the views block.

The digest lists elements one per line with qualified ids, relationship triples with their kind, ports, perspectives, and milestones. Target size for an enterprise-sized model is a few KB — small enough that an agent can hold the whole architecture in context alongside its actual task, without a separate retrieval step.

structurizrx digest ws.dsl
workspace: Shop

person Customer
system Shop
  container Shop/Web App [TypeScript]
  container Shop/API [status:implemented] ports: Customer REST API(HTTPS/JSON)
  container Shop/Database

rel Customer -> Shop/Web App "shops on"
rel Shop/Web App -> Shop/API.Customer REST API "calls"
rel Shop/API -> Shop/Database "reads and writes" [sync]

view auto-landscape landscape (2 elements, 0 rels)
view auto-context-shop systemContext of Shop (1 elements, 0 rels)
view auto-container-shop container of Shop (4 elements, 3 rels)

Markers like [status:implemented] and [sync] (relationship kind) only appear when the model sets them; milestones and perspectives, when present, are summarized in the header alongside workspace:/description:.

structurizrx query

Run a selector expression against a workspace and print the matching elements/relationships.

structurizrx query <file> <expression> [--json]
FlagEffect
--jsonEmit {elements: [{id, name}], relationships: [id]} instead of a text listing

expression is parsed with allow_hyphen_values, so expressions containing - (like ->api->) don’t need extra escaping.

structurizrx query ws.dsl "element.tag==Database"
element  6  container "Database"
structurizrx query ws.dsl "->api->" --json
{
  "elements": [
    { "id": "3", "name": "container \"Web App\"" },
    { "id": "4", "name": "container \"API\"" },
    { "id": "6", "name": "container \"Database\"" }
  ],
  "relationships": ["7", "8"]
}

A bad expression exits non-zero with the engine’s error text, which names the valid selector paths — the same feedback loop validate gives for parse errors. See the language reference for the full selector grammar (element.status==idea, relationship.kind==async, a && b, !a, neighborhood syntax ->x->, and so on).

structurizrx export

Export a workspace to JSON.

structurizrx export <file> [--output <path>]
FlagDefaultEffect
--output, -oworkspace.jsonOutput file path

The output is StructurizrX’s model JSON — a superset shape of the upstream Structurizr JSON schema: every new field (ports, kind, status, milestones, perspectives on relationships/ports) is optional and omitted when unset, so tooling that only understands the upstream schema still gets a workspace it can read; it just won’t see the extensions.

structurizrx export ws.dsl --output ws.json
Exported workspace to ws.json

export does not materialize generated (auto) views the way render/serve/digest do — it exports the workspace’s model and views exactly as authored (or as separately generated and re-imported).

structurizrx docs

Print the DSL extension cheat sheet (llms.txt) to stdout.

structurizrx docs

This is the same one-page reference also served as plain text by structurizrx serve at /llms.txt, and rendered as this documentation site’s Language reference. It’s designed to fit in an LLM agent’s context in one shot: the entire extension surface (ports, kind, status, sketch mode, milestones, generators, selectors) on one page — if an extension doesn’t fit, the extension is considered too big.

Language reference

StructurizrX reads standard Structurizr DSL (workspace/model/views, person, softwareSystem, container, component, group, deployment, styles, !docs, !adrs, !include) plus a set of extensions designed to fit the same lexical style — braces, ident = type "name" declarations — so an LLM’s priors about Structurizr DSL carry over.

Upstream interop is not a design goal: StructurizrX can always read upstream DSL (the parser is tested against the upstream fixture corpus), but its own extensions aren’t constrained to also be valid upstream DSL. Where upstream’s design is awkward (comma-joined tag strings, positional quoted arguments), StructurizrX extensions use explicit keywords instead.

This reference is organized as:

For the condensed, single-page version of everything below (the format StructurizrX ships to LLM agents), see structurizrx docs or GET /llms.txt on a running structurizrx serve instance. The full design rationale behind each extension lives in docs/SPEC.md.

Core structure

A full workspace has three top-level blocks:

workspace "Name" "Optional description" {
    model {
        // people, systems, containers, components, relationships
    }
    views {
        // hand-authored views, or `auto` generators
    }
}

The element hierarchy

Standard C4 elements, unchanged from upstream Structurizr:

model {
    customer = person "Customer" "A person who buys things"

    shop = softwareSystem "Shop" "Sells things online" {
        web = container "Web App" "Storefront" "TypeScript"
        api = container "API" "Handles requests" "Rust" {
            controller = component "OrderController" "Handles order requests"
        }
    }

    customer -> shop "Uses"
    web -> api "Calls"
}
  • person — a human user, inside or outside the system landscape
  • softwareSystem — the top-level unit of the C4 model
  • container — inside a softwareSystem; an application, service, database, etc.
  • component — inside a container; a logical grouping of code

Every element declaration follows identifier = kind "Name" ["Description"] ["Technology"] { ... } (the trailing positional arguments and the body are both optional). The identifier is how everything else — relationships, views, ports — refers to this element, and it’s stable across refinement: a placeholder can later grow into a full softwareSystem with containers without any downstream reference changing.

Groups

group clusters elements (for layout and for selectors like element.layer==<group>) without changing the model hierarchy:

softwareSystem "Shop" {
    group "Core" {
        web = container "Web App"
        api = container "API"
    }
    group "Data" {
        db = container "Database"
    }
}

Full runnable example, including an auto layer view per group: layers.dsl.

Enterprise and deployment nodes

enterprise "Name" { ... } scopes a block of softwareSystem/person declarations as internal to the named enterprise (vs. external actors declared outside it) — used by system landscape views to distinguish “inside our organization” from third parties.

deploymentEnvironment "Name" { deploymentNode "Name" { containerInstance api; infrastructureNode "Load Balancer"; } } describes what runs where, for deployment views — unchanged from upstream Structurizr. Full runnable example: deployment.dsl (see Views for what does and doesn’t render today).

Comments

// and # start a line comment, and /* … */ spans multiple lines. Unlike upstream Structurizr — where ///# are comments only when they are the first non-whitespace on a line — StructurizrX also accepts them inline, after other tokens:

autolayout lr        # inline note, ignored to end of line
background #1168bd    // the hex color is kept; this trailing comment is not

To keep hex colors and variable interpolation unambiguous, an inline # starts a comment only when it is followed by whitespace or the end of the line. #1168bd (a color value) and #{VAR} (interpolation) are therefore never mistaken for comments. An inline // always starts a comment; unquoted urls such as https://example.com/theme.json keep their // because it is part of the word, not a standalone token.

Next: Sketch mode — the zero-ceremony way to start a model with no workspace block at all.

Sketch mode

A file with no workspace block is a sketch: bare statements are implicitly wrapped in workspace { model { ... } }, and any identifier that’s used but never declared is auto-created as a placeholder software system (tagged Placeholder).

customer -> shop "buys things"
shop -> billing "somehow charges" ?
billing -> erp

This is a complete, valid model — it parses, validates, and renders a single landscape view with everything in it. Placeholders participate in all standard views like any other software system; there’s nothing special you need to do to “finish” one later — just declare it properly (shop = softwareSystem "Shop" { ... }) under the same identifier and it picks up wherever the placeholder left off.

? — marking uncertainty

A trailing ? on a relationship marks it explicitly uncertain, distinct from merely undetailed:

shop -> billing "somehow charges" ?

? is meaningful, not just decorative: structurizrx validate --strict and auto lint both surface ?-marked items as findings, so an agent (or a human) can grep a large model for “things I said I wasn’t sure about.”

Opting a full workspace into sketch leniency

Inside a full workspace { ... } block, strictness is the default: an undeclared identifier is a parse error, same as any other unknown-keyword error. Add !sketch at the top of the file to opt that workspace into the same auto-vivification bare sketch files get:

!sketch
workspace "Shop" {
    model {
        customer -> shop "buys things"   // shop auto-created, no error
    }
}

Dotted identifiers (port references like api.rest) must always resolve to a declared element, even in sketch mode — auto-vivification only applies to bare identifiers.

Next: Element extras — status, lifecycle, ports, and the other optional annotations available on any element.

Element extras

Any element body (person, softwareSystem, container, component) can carry these, all optional:

api = container "API" "Order API" "Rust" {
    status implemented                       // idea|draft|specified|implemented|deprecated
    introduced billingSplit                  // milestone name (see workspace-level blocks)
    retired target
    perspective "security" "rate-limited"    // quality-attribute note
    port rest "Customer REST API" {          // named interaction point
        protocol "HTTPS/JSON"
        direction in                         // in|out|inout (default)
    }
    technology "Rust"
    description "..."
    tags "Core"
    url "https://internal-wiki/order-api"
}

status — confidence in the design

billing = softwareSystem "Billing" { status idea }

Orthogonal to time: a status idea element introduced at a five-year milestone is a vague long-term intention; a status specified one is a committed roadmap item. Selectors filter on it (element.status==idea, see Views), and styles can theme it (e.g. dashed borders for ideas).

introduced / retired — lifecycle

Reference a milestone name, never a raw date — when a plan slips, the date changes in exactly one place and every introduced/retired stays correct:

legacyCrm = softwareSystem "Legacy CRM" { retired billingSplit }

An element exists at milestone M iff introduced ≤ M < retired. Unmarked elements exist at all times. See Views for auto asof and auto delta, which render the model at or between milestones.

perspective — quality-attribute notes

perspective "performance" "p99 < 50ms, 2k rps"

A name plus a free-text note. Perspectives are declarable on elements, relationships, and ports. auto perspective "security" (see Views) renders every item carrying a given perspective plus enough structural context to make sense of it.

port — named interaction points

Answers a question plain relationships can’t: what does this element offer or require, independent of who’s currently connected, and which of many inbound arrows go through the same contract.

port events "Order events" {
    protocol "Kafka"
    direction out          // in|out|inout, default inout
    description "Public, versioned event stream"
}

Relationships attach to a port with dot syntax (web -> api.rest "calls"); attaching to the element directly stays legal — that’s the low-fidelity form. Declared-but-never-connected ports are visible and lintable (auto lint flags unbound ports) — an unconsumed interface is information, not an error.

technology, description, tags, url

Standard Structurizr fields, unchanged: technology (free text, also settable as the trailing positional argument on the declaration line), description, tags (comma-joined string, used by selectors and styles), and url (a link shown in the web viewer).

Next: Relationships and ports for the same set of extras on relationships, plus kind and uncertain (?) relationships.

Relationships and ports

A plain relationship is unchanged from upstream: source -> destination "description" "technology". StructurizrX adds kind, ports, naming, and uncertainty.

web -> api.rest "calls"                    // attach to a port via dot syntax
orderFlow = api -> billing "OrderPlaced" {  // named; body is optional
    kind async        // sync|async|publish|subscribe|dataflow|dependency|deploy
    status specified
    introduced billingSplit
    perspective "reliability" "at-least-once"
    tags "Critical"
    technology "Kafka"
    properties { owner "team-x" }
}
a -> b "maybe" ?                            // uncertain relationship

Port-attached relationships

source.port -> destination or source -> destination.port connects to a named port instead of the element as a whole:

web -> api.rest "calls"
api.events -> billing.orders "OrderPlaced" { kind async }

Attaching directly to the element (no .port) stays legal — that’s the lower-fidelity form, and both can coexist in the same model.

kind — connector semantics

A closed vocabulary, richer than free-text technology plus a binary interaction style:

KindUse for
syncRequest/response, blocking call
asyncFire-and-forget, non-blocking call
publishEmits to a topic/queue
subscribeConsumes from a topic/queue
dataflowData movement without request/response semantics
dependencyBuild-time dependency, not a runtime call
deployDeployment relationship

dependency matters beyond labeling: it lets one master model serve both a runtime view and a build-time/dependency view — filter views by kind instead of maintaining two separate models. Selectors (relationship.kind==async) and auto focus ... { splitBy kind } both key off it.

Naming a relationship

orderFlow = api -> billing "OrderPlaced" { ... } gives the relationship an identifier, so other constructs — dynamic views, docs, perspectives — can reference it directly instead of by description text matching.

? — uncertain relationships

A trailing ? marks a relationship as explicitly uncertain (see Sketch mode) — surfaced by auto lint and validate --strict, kept distinct from a relationship that’s simply undetailed.

Next: Workspace-level blocksmilestones, perspectives, specification, and styles.

Workspace-level blocks

These sit directly inside workspace { ... }, alongside model and views.

milestones — named points in time

milestones {
    mvp          "2026-08"
    billingSplit "2026-12"  "Billing extracted from the monolith"
    target       "2031"     "Target architecture"
}

Ordered by declaration (not by date); dates are optional labels. An implicit now milestone precedes all declared ones. Elements and relationships reference milestones by name via introduced/retired (see Element extras) — never a raw date, so a slipped plan only needs updating in one place. Views can render auto asof <milestone> or auto delta <m1> <m2>.

perspectives — the quality-attribute registry

perspectives {
    security "STRIDE-reviewed boundaries"
    performance
}

An optional registry of perspective names (a description is optional). This lets auto perspective * enumerate every registered perspective and lets validation catch typos in perspective "..." annotations elsewhere in the model — registration itself is optional; unregistered perspective names still work, just without typo-checking.

specification — kind aliases

Domain vocabulary without a new element type in the model. A specification block maps an alias to a base C4 kind plus default tags/technology:

specification {
    kind queue container { tags "Queue,Connector" technology "Kafka" }
    kind lambda container { tags "Serverless" technology "AWS Lambda" }
}
model {
    shop = softwareSystem "Shop" {
        orders = queue "Order queue"    // stored as a plain container
    }                                   // tagged Queue,Connector
}

orders is a completely ordinary container underneath — renderers and upstream-compatible JSON see nothing new — but selectors can match the alias directly (element.kind==queue). Container/component-level aliases (like queue above, aliasing container) can only be used nested inside a softwareSystem/container body, matching where a plain container /component declaration would go; person/softwareSystem-level aliases are used at the top of model, same as a plain person/softwareSystem would be.

styles (inside views)

Tag-based visual overrides, unchanged from upstream Structurizr — every exporter (SVG, PlantUML, Mermaid, DOT) respects these:

views {
    auto
    styles {
        element "Queue" {
            background "#ff0000"
            shape hexagon
        }
        relationship "Critical" {
            thickness 4
            color "#ff0000"
            dashed true
        }
    }
}

element "<tag>" { ... } accepts shape, background, color/colour, stroke, fontSize, border, opacity, width, height. relationship "<tag>" { ... } accepts thickness, color/colour, fontSize, lineStyle, routing, opacity, dashed, position. Both key off the element/relationship’s tags — any element or relationship carrying the named tag picks up the style.

Next: Views — hand-authored views, selectors, and the auto generator family.

Views

The master model is authored; views are declared as selections over it, and the common ones need zero declaration at all — an empty or absent views block gets the zero-config default set.

The induced-subgraph rule

A view is fundamentally a set of elements, however that set is produced. Given the set, every model relationship whose two endpoints are both in the set is included automatically, and ancestors of included elements are pulled in as boundary boxes for rendering context. This is why a hand-written include a b c never has to enumerate relationships — the system derives the edges.

Selectors

One expression language, used by include/exclude, generator arguments, and structurizrx query:

element.tag==Database
element.kind==container
element.status==idea
element.layer==domain           // layer = group name, or the `layer` property
element.parent==shop            // direct children
element.technology==Kafka
element.property.owner==checkout-team
relationship.kind==async
relationship.tag==critical
->api->                         // neighborhood: api + direct neighbors
a && b, a || b, !a              // boolean combinators

Two properties get special tooling awareness beyond the generic element.property.<name> lookup: owner (default rollup partition, a digest column, and an optional unowned-element lint) and layer (layer views, layer-order lint). They’re still stored as ordinary properties.

Selectors filter; they can’t compute anything requiring a graph walk — that’s what generators are for.

The auto generator family

Each generator answers a specific stakeholder question. All of them can appear any number of times in a views block, and generated views get deterministic keys (auto-focus-api, auto-context-shop) so links and stored layout survive regeneration.

views {
    auto           // zoom ladder: landscape + context per system + container
                   // view per non-empty system + component view per
                   // non-empty container. Zero-config default when the
                   // `views` block is absent or just says `auto`.
}

“What breaks if I change X? What does X need?” — reachability:

auto focus api {
    depth 2                // default 1; unset = 1
    direction in           // in = impact analysis (who depends on me)
                           // out = dependency analysis (what do I need)
                           // both (default when direction is omitted)
    splitBy kind           // one *separate view* per relationship kind present
}

Without splitBy, focus emits a single combined view.

“How are X and Y connected at all?” — path enumeration:

auto paths web db          // all simple paths web → db

“Where does concern C live?” — cross-cutting slices:

auto perspective "security"     // everything carrying that perspective
auto layer "domain"
auto slice relationship.kind==dataflow
auto slice element.status==idea

layer groups elements for this kind of slicing; see layers.dsl for a worked example that groups a system into "Core" and "Data" and generates one auto layer view per group.

“What’s unfinished or inconsistent?” — model hygiene:

auto lint       // placeholder elements, ?-marked items, unbound ports,
                // orphan elements

“What changes between now and milestone M?” — temporal, keyed off milestones:

auto asof billingSplit          // model state at that milestone;
                                // `asof now` filters out everything future
auto delta now billingSplit     // union of both states — a migration/diff view

Not yet materialized: auto rollup (the partition/Conway-view generator described in docs/SPEC.md §6.3) parses successfully but currently emits nothing — generate_views accepts the syntax and prints a note that it was skipped, rather than producing a view. Likewise the collapse modifier for folding n-ary connectors isn’t implemented yet. Check the repository README’s Status section for the current gap list before relying on either.

Dynamic and deployment views

Dynamic views (ordered interaction scenarios) and deployment views exist and parse using standard upstream Structurizr syntax, unchanged.

A dynamic view numbers a sequence of source -> destination "description" steps in the order they’re declared, for answering “what happens, in what order, when this use case runs”:

dynamic shop "checkout" "Placing an order" {
    customer -> web "Clicks 'Buy now'"
    web -> api "POST /orders"
    api -> db "Inserts order row"
    autoLayout
}

A deployment view selects from a deploymentEnvironment — the infrastructure a system runs on (deploymentNode, containerInstance, infrastructureNode) — rather than from the model’s structure:

production = deploymentEnvironment "Production" {
    deploymentNode "Amazon Web Services" {
        deploymentNode "EC2 instance" {
            apiInstance = containerInstance api
        }
    }
}
views {
    deployment shop "Production" { include * }
}

Full runnable examples: checkout-flow.dsl and deployment.dsl.

Not yet rendered: both view types validate and digest correctly, but none of the four exporters (SVG, Mermaid, PlantUML, DOT) draws them yet — render silently skips them with a warning today. The container view below is the same underlying model, shown via a view type that does render, as a stand-in until dynamic/deployment rendering lands.

Next: Documentation and decisions for !adrs/!include.

Documentation and decisions

The division of labor: the DSL holds structure (elements, ports, connections); prose lives in markdown, attached via directives. StructurizrX never tries to express paragraphs in the DSL itself.

!adrs / !decisions — architecture decision records

workspace "Shop" {
    !adrs decisions
    model { ... }
}

!adrs <path> (alias !decisions) points at a directory of Markdown files in AdrTools/MADR format: filenames start with a numeric ID (0001-use-postgres.md), the ID becomes the decision’s id (leading zeros stripped: 00011); the first line # 1. Use PostgreSQL supplies the title; a line Date: 2026-07-04 supplies the date; a ## Status section supplies the status (Proposed/Accepted/Superseded/etc.). Files are read in filename-sorted order.

Decisions are served by structurizrx serve at /workspace/{name}/decisions (list) and /workspace/{name}/decisions/{id} (single ADR, rendered from Markdown to HTML), and over the JSON API at /api/workspace/{name}/decisions[/{id}].

Full runnable example: decisions.dsl plus its decisions/ directory of two MADR-format records.

Next: Multi-file workspaces — splitting a large model across files with !include.

Multi-file workspaces

!include <path> splits a large model across files, resolved relative to the including file’s own directory:

// root.dsl
workspace "Enterprise" {
    model {
        !include shop.dsl
        !include billing.dsl
    }
    views { auto }
}
// shop.dsl
shop = softwareSystem "Shop" {
    web = container "Web App"
}

Recommended layout is one file per bounded context or subsystem, plus a root workspace file that just wires them together with !include and declares cross-subsystem relationships. This is also the natural editing unit for an LLM agent: it can load and edit one subsystem file without pulling the whole enterprise model into context, then re-validate just its change.

Parse errors inside an included file report that file’s own path and line number, not the root file’s — so “line 5 of shop.dsl” points exactly where an agent needs to look, even several !include levels deep.

Full runnable example: a root workspace catalog.dsl that !includes two subsystem files from a catalog/ subdirectory and wires a cross-subsystem relationship between them. Note that identifiers declared inside an included file (ordersApi, db, …) are flat, top-level names once included — not accessed as orders.api — so give elements you need to reference from outside their own file a globally unique identifier.