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/andpackaging/chocolatey/and are regenerated (version + checksum) by the release workflow on each tag. Availability viawinget install/choco installadditionally requires a one-time submission to each catalog (a PR tomicrosoft/winget-pkgs, andchoco pushto 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.
| Platform | Download |
|---|---|
| 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.
| Command | What it does |
|---|---|
validate | Parse + validate; --strict also fails on lint findings; --json emits structured output |
render | Export diagrams (materializes generated views first) |
serve | Live-reloading web viewer with a JSON API |
digest | Compact plain-text model + view summary, sized for LLM context |
query | Run a selector expression against a workspace |
export | Workspace JSON (superset of the Structurizr JSON schema) |
docs | Print 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
--versionprints 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 (
.jsonvs anything else, treated as DSL).
structurizrx validate
Parse and validate a .dsl or .json workspace file.
structurizrx validate <file> [--strict] [--json]
| Flag | Effect |
|---|---|
--strict | Also fail (non-zero exit) on lint findings — placeholders, uncertain (?) items, orphan elements, unbound ports |
--json | Emit 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>]
| Flag | Default | Effect |
|---|---|---|
--format | plantuml | One 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:
| Format | Renders |
|---|---|
svg | system landscape, system context, container, component |
mermaid | system landscape, system context, container, component |
dot/graphviz | system 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/Flag | Default | Effect |
|---|---|---|
path | . | A .dsl/.json file, or a directory containing one or more workspaces |
--port, -p | 3000 | TCP port to listen on |
--open | off | Open 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
| Route | What it is |
|---|---|
GET / | Workspace list |
GET /workspace/{name} | Workspace overview |
GET /workspace/{name}/diagram/{key} | A single diagram |
GET /workspace/{name}/decisions | ADR list (from !adrs) |
GET /workspace/{name}/decisions/{id} | A single ADR |
GET /workspace/{name}/canvas | In-browser WASM rendering demo |
GET /docs/ | This documentation site |
GET /llms.txt | The 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:
| Route | Equivalent to |
|---|---|
GET /api/workspaces | workspace 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}/svg | render --format svg, one diagram |
GET /api/workspace/{name}/diagram/{key}/mermaid | render --format mermaid, one diagram |
GET /api/workspace/{name}/digest | digest |
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]
| Flag | Effect |
|---|---|
--json | Emit {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>]
| Flag | Default | Effect |
|---|---|---|
--output, -o | workspace.json | Output 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:
- Core structure —
workspace/model/views, the element hierarchy, groups - Sketch mode — files with no
workspaceblock - Element extras —
status,introduced/retired,perspective,port,tags/technology/url - Relationships and ports —
kind, named relationships,?uncertainty, port-attached relationships - Workspace-level blocks —
milestones,perspectives,specification(kind aliases),styles - Views — hand-authored views, selectors, and the
autogenerator family - Documentation and decisions —
!adrs/!decisions,!include - Multi-file workspaces — splitting large models
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 landscapesoftwareSystem— the top-level unit of the C4 modelcontainer— inside asoftwareSystem; an application, service, database, etc.component— inside acontainer; 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:
| Kind | Use for |
|---|---|
sync | Request/response, blocking call |
async | Fire-and-forget, non-blocking call |
publish | Emits to a topic/queue |
subscribe | Consumes from a topic/queue |
dataflow | Data movement without request/response semantics |
dependency | Build-time dependency, not a runtime call |
deploy | Deployment 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 blocks — milestones,
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 indocs/SPEC.md§6.3) parses successfully but currently emits nothing —generate_viewsaccepts the syntax and prints a note that it was skipped, rather than producing a view. Likewise thecollapsemodifier 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 —
rendersilently 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: 0001 → 1); 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.