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.3.0
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.3.0
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.
Demo: a Twitter-like social network
A large workspace to explore in the interactive viewer: a microblogging platform with timelines, search, direct messages, ads, trust & safety and a data platform — 6 people, 9 external systems, 54 containers in 13 groups, two containers broken down into components, ~170 relationships, a production deployment and three dynamic scenarios.
The model is an educated approximation built from public engineering write-ups, not an accurate description of any real company’s systems.
What to look at
| View | Shows |
|---|---|
containers | Everything at once — the stress test |
platform-core | The read and write paths without ads, data platform, safety and operations |
post-tweet, read-home-timeline | Dynamic views: the write path with fanout, and the ranked read path |
home-timeline, fanout | Component views inside the two busiest services |
auto-focus-event-bus | Every producer and consumer of the Kafka bus (auto focus … { direction in }) |
auto-perspective-security, auto-perspective-performance | Elements carrying a perspective note |
auto-slice-…publish…subscribe | Only the asynchronous, event-driven relationships |
auto-layer-timelines, auto-layer-trust-safety | One group and its neighbours |
production-deployment | Containers placed on data-centre and cloud nodes |
Click an element to select it — the URL updates, so a selection is a link. Press space to jump to a view and ? for shortcuts.
The demo is the static viewer that export-viewer
produces, so the server-only tools (universe graph, clusters, review, diff,
print document) are not part of it. Run the workspace locally to get them:
structurizrx serve site/demo/twitter.dsl --open
Source
site/demo/twitter.dsl
// A large, illustrative model of a Twitter-like social network, used as the
// interactive demo in the docs site (see site/build.sh). It is an educated
// approximation built from public engineering write-ups, not an accurate
// description of any real company's systems.
workspace "Twitter-like Social Network" "A large demo workspace: a microblogging platform with timelines, search, ads, trust & safety and data infrastructure." {
!identifiers flat
model {
// ------------------------------------------------------------------
// People
// ------------------------------------------------------------------
user = person "User" "Reads timelines, posts tweets, follows accounts, sends DMs."
advertiser = person "Advertiser" "Buys promoted tweets and audience targeting."
developer = person "Third-party Developer" "Builds apps and bots on the public API."
moderator = person "Trust & Safety Agent" "Reviews reported content and enforces policy."
sre = person "Site Reliability Engineer" "Operates the platform and responds to incidents."
analyst = person "Data Scientist" "Runs experiments and analyses product metrics."
// ------------------------------------------------------------------
// External systems
// ------------------------------------------------------------------
apns = softwareSystem "Apple Push Notification service" "Delivers push notifications to iOS devices." { tags "External" }
fcm = softwareSystem "Firebase Cloud Messaging" "Delivers push notifications to Android devices." { tags "External" }
smsGateway = softwareSystem "SMS Gateway" "Sends SMS for login codes and alerts." { tags "External" }
emailProvider = softwareSystem "Email Provider" "Sends transactional and digest email." { tags "External" }
paymentProcessor = softwareSystem "Payment Processor" "Charges advertisers and subscribers." { tags "External" }
cdnProvider = softwareSystem "CDN Provider" "Edge caching for images and video." { tags "External" }
appStores = softwareSystem "App Stores" "Distribute the mobile apps and in-app subscriptions." { tags "External" }
lawEnforcement = softwareSystem "Law Enforcement Portal" "Receives legal requests and emergency disclosures." { tags "External" }
oauthProviders = softwareSystem "Identity Providers" "Sign in with Apple / Google." { tags "External" }
// ------------------------------------------------------------------
// The platform
// ------------------------------------------------------------------
twitter = softwareSystem "Social Network Platform" "Lets people publish short posts and follow each other." {
group "Clients" {
webApp = container "Web App" "Single-page web client." "TypeScript, React" {
tags "Browser"
properties { owner "client-team" }
}
iosApp = container "iOS App" "Native iPhone and iPad client." "Swift" {
tags "Mobile"
properties { owner "client-team" }
}
androidApp = container "Android App" "Native Android client." "Kotlin" {
tags "Mobile"
properties { owner "client-team" }
}
}
group "Edge" {
edgeLb = container "Edge Load Balancer" "Terminates TLS, rate-limits, routes by path." "Envoy" {
tags "Infrastructure"
perspective "security" "WAF rules, per-IP and per-token rate limits"
properties { owner "traffic-team" }
}
graphqlGateway = container "GraphQL Gateway" "Aggregates backend services into the first-party client API." "Scala, Finagle" {
properties { owner "api-platform-team" }
port gql "First-party GraphQL API" { protocol "HTTPS/GraphQL" direction in }
}
publicApi = container "Public API" "Versioned REST API for third-party developers." "Scala, Finagle" {
properties { owner "api-platform-team" }
perspective "security" "OAuth 2.0 scopes, app-level quotas"
port rest "v2 REST API" { protocol "HTTPS/JSON" direction in }
}
streamingApi = container "Streaming API" "Long-lived filtered streams of public tweets." "Java" {
properties { owner "api-platform-team" }
}
mediaUpload = container "Media Upload Service" "Chunked upload endpoint for images and video." "Go" {
properties { owner "media-team" }
}
}
group "Identity" {
authService = container "Auth Service" "Sessions, OAuth tokens, 2FA, login challenges." "Scala" {
properties { owner "identity-team" }
perspective "security" "Tokens signed with rotating keys; 2FA enforced for verified orgs"
}
userService = container "User Service" "Profiles, settings, verification state." "Scala" {
properties { owner "identity-team" }
}
accountDb = container "Account Store" "Users, credentials, devices." "MySQL (sharded)" {
tags "Database"
properties { owner "identity-team" }
}
}
group "Social Graph" {
graphService = container "Social Graph Service" "Follows, blocks, mutes, lists." "Scala" {
properties { owner "graph-team" }
perspective "performance" "p99 < 10ms for follow checks"
}
graphStore = container "Graph Store" "Adjacency lists, sharded by user id." "FlockDB-style on MySQL" {
tags "Database"
properties { owner "graph-team" }
}
whoToFollow = container "Who-to-Follow" "Account recommendations from graph walks." "Scala, GraphJet" {
properties { owner "recs-team" }
}
}
group "Tweets" {
tweetService = container "Tweet Service" "Create, read and delete tweets; enforces limits." "Scala" {
properties { owner "tweet-team" }
port write "Tweet write path" { protocol "Thrift" direction in }
port read "Tweet hydration" { protocol "Thrift" direction in }
port events "Tweet events" { protocol "Kafka" direction out description "TweetCreated / TweetDeleted" }
}
tweetStore = container "Tweet Store" "Durable tweet storage, time-ordered ids." "Manhattan (distributed KV)" {
tags "Database"
properties { owner "tweet-team" }
}
engagementService = container "Engagement Service" "Likes, retweets, replies, bookmarks and their counts." "Scala" {
properties { owner "tweet-team" }
}
countersStore = container "Counter Store" "Approximate engagement counters." "Redis Cluster" {
tags "Database"
properties { owner "tweet-team" }
}
idGenerator = container "ID Generator" "Roughly time-ordered 64-bit ids." "Snowflake" {
properties { owner "tweet-team" }
}
}
group "Timelines" {
fanoutService = container "Fanout Service" "Pushes new tweet ids into followers' home timelines." "Scala" {
properties { owner "timeline-team" }
perspective "performance" "Skips fanout for accounts with > 1M followers (pulled at read time)"
fanoutConsumer = component "Tweet Event Consumer" "Consumes TweetCreated events." "Kafka Streams"
followerLookup = component "Follower Lookup" "Pages through a tweeter's followers." "Scala"
celebrityPolicy = component "Celebrity Policy" "Decides push vs. pull for high-follower accounts." "Scala"
timelineWriter = component "Timeline Writer" "Appends tweet ids to cached timelines in batches." "Scala"
fanoutConsumer -> celebrityPolicy "asks push or pull"
fanoutConsumer -> followerLookup "requests followers"
followerLookup -> timelineWriter "streams follower batches to"
}
timelineService = container "Home Timeline Service" "Assembles the ranked home timeline." "Scala" {
properties { owner "timeline-team" }
perspective "performance" "p99 < 200ms end to end"
timelineApi = component "Timeline API" "Serves home timeline requests." "Finagle Thrift"
candidateSource = component "Candidate Sources" "In-network from cache, out-of-network from recs." "Scala"
heavyRanker = component "Heavy Ranker" "Scores candidates with the ranking model." "Scala, TensorFlow Serving client"
filters = component "Visibility Filters" "Drops blocked, muted, and policy-violating tweets." "Scala"
mixer = component "Timeline Mixer" "Interleaves ads, recommendations and tweets." "Scala"
hydrator = component "Hydrator" "Fetches tweet bodies, authors and counts." "Scala"
timelineApi -> candidateSource "gets candidates from"
candidateSource -> heavyRanker "sends candidates to"
heavyRanker -> filters "passes ranked list to"
filters -> mixer "passes filtered list to"
mixer -> hydrator "hydrates page with"
}
timelineCache = container "Timeline Cache" "Per-user lists of recent tweet ids." "Redis Cluster" {
tags "Database"
properties { owner "timeline-team" }
}
userTimelineService = container "Profile Timeline Service" "Tweets and replies by a single account." "Scala" {
properties { owner "timeline-team" }
}
}
group "Discovery" {
searchIngester = container "Search Ingester" "Tokenises tweets and feeds realtime indexes." "Java" {
properties { owner "search-team" }
}
searchIndex = container "Search Index" "Realtime and archive inverted indexes." "Earlybird (Lucene)" {
tags "Database"
properties { owner "search-team" }
}
searchService = container "Search Service" "Query parsing, blending and ranking." "Java" {
properties { owner "search-team" }
}
trendsService = container "Trends Service" "Detects trending topics per region." "Scala, Heron" {
properties { owner "search-team" }
}
recsService = container "Recommendation Service" "Out-of-network tweets and topics." "Scala" {
properties { owner "recs-team" }
}
mlServing = container "Model Serving" "Online inference for ranking and safety models." "TensorFlow Serving" {
properties { owner "ml-platform-team" }
}
featureStore = container "Feature Store" "Online user and tweet features." "Manhattan" {
tags "Database"
properties { owner "ml-platform-team" }
}
}
group "Messaging & Notifications" {
dmService = container "Direct Messages Service" "1:1 and group conversations." "Scala" {
properties { owner "messaging-team" }
perspective "security" "Optional end-to-end encryption for 1:1 conversations"
}
dmStore = container "DM Store" "Conversation and message storage." "Manhattan" {
tags "Database"
properties { owner "messaging-team" }
}
notificationService = container "Notification Service" "Decides what to notify, when, and on which channel." "Scala" {
properties { owner "notifications-team" }
}
pushGateway = container "Push Gateway" "Delivers mobile push to APNs and FCM." "Go" {
properties { owner "notifications-team" }
}
emailService = container "Email Service" "Templated email and digests." "Python" {
properties { owner "notifications-team" }
}
}
group "Media" {
mediaService = container "Media Service" "Transcodes video, resizes images, extracts thumbnails." "Go, FFmpeg" {
properties { owner "media-team" }
}
blobStore = container "Blob Store" "Original and derived media files." "Object storage" {
tags "Database"
properties { owner "media-team" }
}
}
group "Ads" {
adsManager = container "Ads Manager" "Campaign management UI for advertisers." "TypeScript, React" {
tags "Browser"
properties { owner "ads-team" }
}
adsApi = container "Ads API" "Campaigns, budgets, creatives, targeting." "Scala" {
properties { owner "ads-team" }
}
adServer = container "Ad Server" "Real-time auction and ad selection." "Scala" {
properties { owner "ads-team" }
perspective "performance" "Auction must complete in < 30ms"
}
adsDb = container "Ads Database" "Campaigns and budgets." "MySQL" {
tags "Database"
properties { owner "ads-team" }
}
billingService = container "Billing Service" "Invoices advertisers and subscription users." "Java" {
properties { owner "revenue-team" }
perspective "security" "PCI scope limited to the payment processor's tokenised cards"
}
}
group "Trust & Safety" {
safetyService = container "Safety Service" "Classifies tweets and accounts for spam and abuse." "Scala" {
properties { owner "trust-safety-team" }
}
reportService = container "Report Service" "Intake for user reports and appeals." "Scala" {
properties { owner "trust-safety-team" }
}
moderationConsole = container "Moderation Console" "Review queues and enforcement actions." "TypeScript, React" {
tags "Browser"
properties { owner "trust-safety-team" }
}
legalRequests = container "Legal Request Tool" "Tracks and fulfils lawful data requests." "Python" {
properties { owner "legal-eng-team" }
}
}
group "Data Platform" {
eventBus = container "Event Bus" "Durable, partitioned log of every platform event." "Kafka" {
tags "Queue"
properties { owner "data-platform-team" }
}
streamProcessing = container "Stream Processing" "Realtime aggregations and counters." "Heron / Flink" {
properties { owner "data-platform-team" }
}
dataLake = container "Data Lake" "Historical events and snapshots." "HDFS / Parquet" {
tags "Database"
properties { owner "data-platform-team" }
}
batchCompute = container "Batch Compute" "Offline jobs, model training, reporting." "Spark, Scalding" {
properties { owner "data-platform-team" }
}
experimentation = container "Experimentation Service" "Feature flags and A/B test assignment." "Scala" {
properties { owner "data-platform-team" }
}
analyticsUi = container "Analytics Notebooks" "Ad-hoc queries and dashboards." "Jupyter, Presto" {
tags "Browser"
properties { owner "data-platform-team" }
}
}
group "Operations" {
observability = container "Observability Stack" "Metrics, traces and logs for every service." "Prometheus, Zipkin, ELK" {
tags "Infrastructure"
properties { owner "sre-team" }
}
configService = container "Config & Service Discovery" "Dynamic config and service registry." "ZooKeeper" {
tags "Infrastructure"
properties { owner "sre-team" }
}
}
}
// ------------------------------------------------------------------
// System level, for the landscape and context views. StructurizrX
// does not derive implied relationships from the container-level
// ones below, so these are stated once here.
// ------------------------------------------------------------------
user -> twitter "Reads, posts and messages using"
advertiser -> twitter "Buys promoted tweets on"
developer -> twitter "Builds apps against"
moderator -> twitter "Enforces policy using"
sre -> twitter "Operates"
analyst -> twitter "Analyses and experiments on"
twitter -> apns "Delivers iOS push via"
twitter -> fcm "Delivers Android push via"
twitter -> smsGateway "Sends login codes via"
twitter -> emailProvider "Sends email via"
twitter -> paymentProcessor "Charges cards via"
twitter -> cdnProvider "Serves media through"
twitter -> appStores "Verifies in-app purchases with"
twitter -> lawEnforcement "Discloses data to"
twitter -> oauthProviders "Federates sign-in with"
// ------------------------------------------------------------------
// People -> platform
// ------------------------------------------------------------------
user -> webApp "Reads and posts using"
user -> iosApp "Reads and posts using"
user -> androidApp "Reads and posts using"
advertiser -> adsManager "Manages campaigns in"
developer -> publicApi.rest "Builds apps against"
developer -> streamingApi "Consumes filtered streams from"
moderator -> moderationConsole "Reviews reports in"
sre -> observability "Monitors the platform with"
sre -> configService "Rolls out config changes with"
analyst -> analyticsUi "Analyses metrics in"
analyst -> experimentation "Configures experiments in"
// Clients -> edge
webApp -> edgeLb "Sends requests to" "HTTPS"
iosApp -> edgeLb "Sends requests to" "HTTPS"
androidApp -> edgeLb "Sends requests to" "HTTPS"
adsManager -> edgeLb "Sends requests to" "HTTPS"
moderationConsole -> edgeLb "Sends requests to" "HTTPS"
webApp -> cdnProvider "Loads images and video from" "HTTPS"
iosApp -> cdnProvider "Loads images and video from" "HTTPS"
androidApp -> cdnProvider "Loads images and video from" "HTTPS"
iosApp -> mediaUpload "Uploads media to" "HTTPS"
androidApp -> mediaUpload "Uploads media to" "HTTPS"
webApp -> mediaUpload "Uploads media to" "HTTPS"
iosApp -> appStores "Purchases subscriptions through"
androidApp -> appStores "Purchases subscriptions through"
// Edge
edgeLb -> graphqlGateway.gql "Routes first-party traffic to" "HTTP/2"
edgeLb -> publicApi.rest "Routes third-party traffic to" "HTTP/2"
edgeLb -> authService "Validates sessions with" "Thrift"
graphqlGateway -> authService "Authorises requests with" "Thrift"
publicApi -> authService "Checks OAuth tokens with" "Thrift"
graphqlGateway -> tweetService.write "Posts tweets via" "Thrift"
graphqlGateway -> tweetService.read "Hydrates tweets via" "Thrift"
graphqlGateway -> timelineService "Fetches home timeline from" "Thrift"
graphqlGateway -> userTimelineService "Fetches profile timelines from" "Thrift"
graphqlGateway -> userService "Reads and updates profiles with" "Thrift"
graphqlGateway -> graphService "Follows and blocks via" "Thrift"
graphqlGateway -> engagementService "Likes and retweets via" "Thrift"
graphqlGateway -> searchService "Searches via" "Thrift"
graphqlGateway -> trendsService "Reads trends from" "Thrift"
graphqlGateway -> dmService "Sends and reads messages via" "Thrift"
graphqlGateway -> notificationService "Reads notification tab from" "Thrift"
graphqlGateway -> whoToFollow "Gets account suggestions from" "Thrift"
graphqlGateway -> reportService "Files reports with" "Thrift"
graphqlGateway -> adsApi "Proxies Ads Manager calls to" "Thrift"
graphqlGateway -> experimentation "Gets feature flags from" "Thrift"
publicApi -> tweetService.write "Posts tweets via" "Thrift"
publicApi -> tweetService.read "Hydrates tweets via" "Thrift"
publicApi -> userService "Reads users from" "Thrift"
publicApi -> graphService "Reads follows from" "Thrift"
publicApi -> searchService "Searches via" "Thrift"
streamingApi -> eventBus "Filters the public tweet stream from" "Kafka" { kind subscribe }
mediaUpload -> blobStore "Stores originals in" "S3 API"
mediaUpload -> eventBus "Publishes MediaUploaded to" "Kafka" { kind publish }
// Identity
authService -> accountDb "Reads credentials and sessions from" "SQL"
authService -> smsGateway "Sends login codes via" "HTTPS"
authService -> oauthProviders "Federates sign-in with" "OIDC"
userService -> accountDb "Reads and writes profiles in" "SQL"
userService -> eventBus "Publishes UserUpdated to" "Kafka" { kind publish }
// Social graph
graphService -> graphStore "Reads and writes edges in" "SQL"
graphService -> eventBus "Publishes FollowCreated to" "Kafka" { kind publish }
whoToFollow -> graphService "Walks the graph via" "Thrift"
whoToFollow -> featureStore "Reads user features from" "Thrift"
whoToFollow -> mlServing "Scores candidates with" "gRPC"
// Tweets
tweetService -> idGenerator "Allocates tweet ids from" "Thrift"
tweetService -> tweetStore "Persists tweets in" "Thrift"
tweetService -> safetyService "Checks new tweets with" "Thrift"
tweetService -> mediaService "Attaches media via" "Thrift"
tweetService.events -> eventBus "Publishes TweetCreated to" "Kafka" { kind publish }
tweetService -> userService "Reads author state from" "Thrift"
engagementService -> countersStore "Increments counters in" "Redis protocol"
engagementService -> tweetService.read "Validates tweets via" "Thrift"
engagementService -> eventBus "Publishes engagement events to" "Kafka" { kind publish }
// Timelines
fanoutConsumer -> eventBus "Consumes TweetCreated from" "Kafka" { kind subscribe }
followerLookup -> graphService "Pages followers from" "Thrift"
timelineWriter -> timelineCache "Appends tweet ids to" "Redis protocol"
celebrityPolicy -> userService "Reads follower counts from" "Thrift"
candidateSource -> timelineCache "Reads in-network ids from" "Redis protocol"
candidateSource -> recsService "Gets out-of-network candidates from" "Thrift"
heavyRanker -> mlServing "Scores candidates with" "gRPC"
heavyRanker -> featureStore "Reads features from" "Thrift"
filters -> graphService "Reads blocks and mutes from" "Thrift"
filters -> safetyService "Reads visibility labels from" "Thrift"
mixer -> adServer "Requests promoted tweets from" "Thrift"
hydrator -> tweetService.read "Hydrates tweets via" "Thrift"
hydrator -> engagementService "Reads counts from" "Thrift"
hydrator -> userService "Reads authors from" "Thrift"
timelineService -> experimentation "Reads ranking experiment buckets from" "Thrift"
userTimelineService -> tweetStore "Scans an author's tweets in" "Thrift"
userTimelineService -> tweetService.read "Hydrates tweets via" "Thrift"
// Discovery
searchIngester -> eventBus "Consumes tweet and engagement events from" "Kafka" { kind subscribe }
searchIngester -> searchIndex "Indexes tweets into" "Thrift"
searchService -> searchIndex "Queries" "Thrift"
searchService -> tweetService.read "Hydrates results via" "Thrift"
searchService -> mlServing "Ranks results with" "gRPC"
trendsService -> eventBus "Counts hashtags and entities from" "Kafka" { kind subscribe }
trendsService -> safetyService "Filters unsafe trends with" "Thrift"
recsService -> graphService "Finds second-degree engagement via" "Thrift"
recsService -> featureStore "Reads embeddings from" "Thrift"
recsService -> mlServing "Scores candidates with" "gRPC"
mlServing -> featureStore "Reads online features from" "Thrift"
// Messaging and notifications
dmService -> dmStore "Stores conversations in" "Thrift"
dmService -> graphService "Checks who can message whom with" "Thrift"
dmService -> safetyService "Scans links in messages with" "Thrift"
dmService -> eventBus "Publishes MessageSent to" "Kafka" { kind publish }
notificationService -> eventBus "Consumes follow, engagement, mention and message events from" "Kafka" { kind subscribe }
notificationService -> userService "Reads notification settings from" "Thrift"
notificationService -> mlServing "Predicts notification relevance with" "gRPC"
notificationService -> pushGateway "Sends push via" "Thrift"
notificationService -> emailService "Sends email via" "Thrift"
pushGateway -> apns "Delivers iOS push via" "HTTP/2"
pushGateway -> fcm "Delivers Android push via" "HTTPS"
emailService -> emailProvider "Sends email via" "SMTP"
// Media
mediaService -> blobStore "Reads originals and writes renditions to" "S3 API"
mediaService -> eventBus "Consumes MediaUploaded from" "Kafka" { kind subscribe }
mediaService -> safetyService "Scans media with" "Thrift"
cdnProvider -> blobStore "Pulls media from on cache miss" "HTTPS"
// Ads
adsApi -> adsDb "Reads and writes campaigns in" "SQL"
adsApi -> billingService "Sets up payment methods with" "Thrift"
adServer -> adsDb "Loads live campaigns from" "SQL"
adServer -> featureStore "Reads targeting features from" "Thrift"
adServer -> mlServing "Predicts click-through with" "gRPC"
adServer -> eventBus "Publishes impressions to" "Kafka" { kind publish }
billingService -> paymentProcessor "Charges cards via" "HTTPS"
billingService -> appStores "Verifies in-app purchases with" "HTTPS"
billingService -> streamProcessing "Reads spend aggregates from" "Thrift"
billingService -> emailService "Sends invoices via" "Thrift"
// Trust and safety
safetyService -> mlServing "Classifies content with" "gRPC"
safetyService -> eventBus "Publishes enforcement actions to" "Kafka" { kind publish }
reportService -> eventBus "Publishes ReportFiled to" "Kafka" { kind publish }
reportService -> safetyService "Prioritises reports with" "Thrift"
moderationConsole -> reportService "Works review queues in" "HTTPS"
moderationConsole -> safetyService "Applies labels and suspensions via" "HTTPS"
moderationConsole -> legalRequests "Escalates legal cases to" "HTTPS"
legalRequests -> lawEnforcement "Discloses data to" "HTTPS"
legalRequests -> dataLake "Exports account data from" "Spark"
// Data platform
streamProcessing -> eventBus "Consumes all events from" "Kafka" { kind subscribe }
streamProcessing -> countersStore "Reconciles counters in" "Redis protocol"
streamProcessing -> featureStore "Writes realtime features to" "Thrift"
eventBus -> dataLake "Archives events to" "Kafka Connect" { kind dataflow }
batchCompute -> dataLake "Reads and writes datasets in" "HDFS"
batchCompute -> featureStore "Publishes batch features to" "Thrift"
batchCompute -> mlServing "Deploys trained models to" "Model registry" { kind deploy }
analyticsUi -> dataLake "Queries" "Presto"
experimentation -> eventBus "Logs assignments to" "Kafka" { kind publish }
// Operations (every service reports telemetry; only the key ones are drawn)
graphqlGateway -> observability "Emits traces to" "Zipkin" { kind async }
timelineService -> observability "Emits metrics to" "Prometheus" { kind async }
tweetService -> observability "Emits metrics to" "Prometheus" { kind async }
graphqlGateway -> configService "Discovers backends via" "ZooKeeper"
timelineService -> configService "Discovers backends via" "ZooKeeper"
// ------------------------------------------------------------------
// Deployment
// ------------------------------------------------------------------
production = deploymentEnvironment "Production" {
deploymentNode "User device" "" "iOS / Android / Browser" {
containerInstance webApp
containerInstance iosApp
containerInstance androidApp
}
deploymentNode "Primary data centre" "" "Bare metal, Mesos / Aurora" {
lbNode = deploymentNode "Edge tier" "" "Linux" {
edgeInstance = containerInstance edgeLb
}
deploymentNode "API tier" "" "Mesos cluster" {
containerInstance graphqlGateway
containerInstance publicApi
containerInstance streamingApi
containerInstance mediaUpload
}
deploymentNode "Service tier" "" "Mesos cluster" {
containerInstance authService
containerInstance userService
containerInstance tweetService
containerInstance engagementService
containerInstance graphService
containerInstance fanoutService
containerInstance timelineService
containerInstance userTimelineService
containerInstance searchService
containerInstance dmService
containerInstance notificationService
containerInstance adServer
containerInstance safetyService
}
deploymentNode "Storage tier" "" "Dedicated hosts" {
containerInstance tweetStore
containerInstance accountDb
containerInstance graphStore
containerInstance timelineCache
containerInstance countersStore
containerInstance searchIndex
}
deploymentNode "Messaging tier" "" "Kafka brokers" {
containerInstance eventBus
}
}
deploymentNode "Cloud region" "" "Public cloud" {
deploymentNode "Analytics" "" "Managed Hadoop / Spark" {
containerInstance dataLake
containerInstance batchCompute
}
deploymentNode "Object storage" "" "S3-compatible" {
containerInstance blobStore
}
}
}
}
views {
systemLandscape "landscape" "System landscape" {
include *
autoLayout lr
}
systemContext twitter "context" "System context" {
include *
autoLayout lr
}
container twitter "containers" "All containers" {
include *
autoLayout lr
}
component fanoutService "fanout" "Fanout service components" {
include *
autoLayout lr
}
component timelineService "home-timeline" "Home timeline components" {
include *
autoLayout lr
}
// Impact analysis for the busiest services.
auto focus tweetService { depth 1 }
auto focus eventBus { direction in }
auto focus graphService { direction in }
// Cross-cutting slices.
auto perspective "security"
auto perspective "performance"
auto slice relationship.kind==publish || relationship.kind==subscribe
auto layer "Timelines"
auto layer "Trust & Safety"
container twitter "platform-core" "Core read and write paths" {
include user webApp iosApp androidApp
include edgeLb graphqlGateway publicApi mediaUpload
include authService userService accountDb
include graphService graphStore
include tweetService tweetStore engagementService countersStore idGenerator
include fanoutService timelineService timelineCache userTimelineService
include searchIngester searchIndex searchService recsService
include notificationService pushGateway dmService dmStore
include mediaService blobStore eventBus
autoLayout lr
}
dynamic twitter "post-tweet" "Posting a tweet and fanning it out to followers." {
iosApp -> edgeLb "POST /graphql CreateTweet"
edgeLb -> graphqlGateway "Routes request"
graphqlGateway -> tweetService "CreateTweet"
tweetService -> idGenerator "Allocate id"
tweetService -> safetyService "Pre-publish safety check"
tweetService -> tweetStore "Persist tweet"
tweetService -> eventBus "Publish TweetCreated"
fanoutService -> eventBus "Consume TweetCreated"
fanoutService -> graphService "Page through followers"
fanoutService -> timelineCache "Append tweet id to each follower's timeline"
notificationService -> eventBus "Consume mentions"
notificationService -> pushGateway "Notify mentioned users"
searchIngester -> eventBus "Consume TweetCreated"
searchIngester -> searchIndex "Index tweet"
autoLayout lr
}
dynamic twitter "read-home-timeline" "Loading the ranked home timeline." {
androidApp -> edgeLb "POST /graphql HomeTimeline"
edgeLb -> graphqlGateway "Routes request"
graphqlGateway -> timelineService "GetHomeTimeline"
timelineService -> timelineCache "Read in-network tweet ids"
timelineService -> recsService "Fetch out-of-network candidates"
timelineService -> mlServing "Score candidates"
timelineService -> graphService "Filter blocks and mutes"
timelineService -> adServer "Request promoted tweets"
timelineService -> tweetService "Hydrate tweets"
timelineService -> engagementService "Hydrate counts"
autoLayout lr
}
dynamic fanoutService "fanout-components" "Inside the fanout service." {
fanoutConsumer -> eventBus "Consume TweetCreated"
fanoutConsumer -> celebrityPolicy "Push or pull?"
fanoutConsumer -> followerLookup "Request followers"
followerLookup -> graphService "Page followers"
followerLookup -> timelineWriter "Stream follower batches"
timelineWriter -> timelineCache "Append tweet ids"
autoLayout lr
}
deployment twitter "Production" "production-deployment" {
include *
autoLayout lr
}
styles {
element "Person" {
shape Person
background #08427b
color #ffffff
}
element "Software System" {
background #1168bd
color #ffffff
}
element "External" {
background #8a8a8a
color #ffffff
}
element "Container" {
background #438dd5
color #ffffff
}
element "Component" {
background #85bbf0
color #000000
}
element "Database" {
shape Cylinder
}
element "Queue" {
shape Pipe
}
element "Browser" {
shape WebBrowser
}
element "Mobile" {
shape MobileDevicePortrait
}
element "Infrastructure" {
background #6c757d
}
}
}
}
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 |
lint | Model hygiene: blocking findings, warnings and dependency cycles |
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 |
locate | Find where elements, relationships and views are declared, by path or from a viewer link |
diff | Compare two versions of a workspace as models |
clusters | Community/coupling analysis: conformance findings, cycles, bridges, metrics |
graph | The whole workspace as one graph, as JSON or DOT |
fmt | Canonical DSL text: JSON→DSL, sketch→workspace, --check/--write |
add/remove/rename | Surgical, re-parse-checked edits to DSL source |
export | Workspace JSON (superset of the Structurizr JSON schema) |
export-site | Single-page SVG technical report |
export-viewer | Portable interactive viewer with bundled assets and workspace JSON |
mcp | Serve these commands as MCP tools over stdio |
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, file, line, column, message}], lint: [{code, elementId, name, message}]} |
Without --json, parse/validation errors and (with --strict) lint findings
print to stderr as file:line:col: message, one per error, 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.
Every parse error in the file is reported in one run, up to 20, instead of stopping at the first one — useful when cleaning up a file with several mistakes at once:
structurizrx validate broken.dsl
broken.dsl:3:20: unknown keyword 'persn' in model; expected one of: person, softwareSystem, group, enterprise, deploymentEnvironment, element, properties, or a relationship 'a -> b'; did you mean 'person'?
broken.dsl:6:19: unknown keyword 'containr' in softwareSystem body; expected one of: container, group, description, technology, url, tags, properties, perspective, perspectives, port, status, introduced, retired; did you mean 'container'?
broken.dsl:8:9: unknown element identifier 'customer' in relationship
broken.dsl:9:19: unknown element identifier 'ap' in relationship
✗ 4 parse errors in broken.dsl
Each --json error carries a stable code — one of unknown-identifier,
unknown-keyword, unexpected-token, unclosed-block, include, syntax,
io, load — plus file, line, column and message:
structurizrx validate broken.dsl --json
{
"errors": [
{
"code": "unknown-keyword",
"column": 20,
"file": "broken.dsl",
"line": 3,
"message": "unknown keyword 'persn' in model; expected one of: person, softwareSystem, group, enterprise, deploymentEnvironment, element, properties, or a relationship 'a -> b'; did you mean 'person'?"
},
{
"code": "unknown-identifier",
"column": 9,
"file": "broken.dsl",
"line": 8,
"message": "unknown element identifier 'customer' in relationship"
}
],
"lint": [],
"valid": false
}
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 lint
Check a workspace’s model hygiene: findings that block a build, softer warnings, and dependency cycles.
structurizrx lint <file> [--strict] [--json]
| Flag | Effect |
|---|---|
--strict | Also fail (exit 1) on warnings and cycles, not just blocking findings |
--json | Emit {passed, blocking: [...], warnings: [...]} instead of a text report |
lint exits 1 whenever there are blocking findings, and — with --strict —
also when there are warnings or cycles. Without --strict it exits 1 only
for blocking findings, matching validate --strict.
How this differs from validate --strict
validate --strict fails only on the four blocking findings below — it’s
the gate a build should never pass with an unfinished model. lint reports
the same blocking set, plus hygiene warnings (a missing description isn’t
wrong, just unfinished) and dependency cycles, which validate doesn’t
look at all. lint --strict is the stricter check: it fails on warnings and
cycles too, so it’s suited to a “is this workspace actually done” gate rather
than “is this workspace well-formed.”
Findings
Blocking (also fail validate --strict):
| Code | Meaning |
|---|---|
placeholder | Element was auto-created in sketch mode and never properly declared |
uncertain | Element is tagged Uncertain |
orphan | Element has no relationships and no children |
unbound-port | Port is declared but never referenced by a relationship |
Warnings (only fail with --strict):
| Code | Meaning |
|---|---|
missing-description | Element has no description |
missing-technology | Container or component has no technology |
not-in-any-view | Element is not shown by any view |
no-relationships | Element has no incoming or outgoing relationships and no children |
duplicate-name | Element’s name is hard to tell apart from another’s |
relationship-undescribed | One or more relationships leaving the element have no description |
cycle | A dependency cycle exists between elements (container level, and component level when the model has components) |
Generated (auto) views are materialized first, so not-in-any-view is
judged against the effective view set, not just what’s literally written in
the views block.
Example
site/examples/sketch.dsl (see Sketch mode) is
all placeholders, so it fails outright:
structurizrx lint sketch.dsl
Blocking:
[placeholder] 'customer' is a placeholder auto-created in sketch mode; declare it properly (customer)
[placeholder] 'shop' is a placeholder auto-created in sketch mode; declare it properly (shop)
[placeholder] 'billing' is a placeholder auto-created in sketch mode; declare it properly (billing)
[placeholder] 'erp' is a placeholder auto-created in sketch mode; declare it properly (erp)
Warnings:
[missing-description] 'customer' has no description (customer)
[missing-description] 'shop' has no description (shop)
[missing-description] 'billing' has no description (billing)
[relationship-undescribed] 1 of 1 relationship(s) leaving 'billing' have no description (billing)
[missing-description] 'erp' has no description (erp)
4 blocking, 5 warnings
A finished workspace prints nothing:
structurizrx lint ws.dsl
✓ no findings
structurizrx lint ws.dsl --json
{
"blocking": [],
"passed": true,
"warnings": [
{
"blocking": false,
"code": "missing-description",
"elementId": "1",
"message": "'Customer' has no description",
"name": "Customer"
}
]
}
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, png, 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 |
png | same views as svg |
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)
PNG
--format png rasterizes the same SVG the svg exporter produces, using
system fonts (via resvg) — there’s no separate PNG layout engine, so a
diagram looks identical in both formats, just as pixels instead of vectors:
structurizrx render shop.dsl --format png --output ./out
Generated views: auto-landscape, auto-context-shop, auto-container-shop, auto-focus-api, auto-lint
Written: out/auto-landscape.png
Written: out/auto-focus-api.png
Written: out/auto-lint.png
Written: out/auto-context-shop.png
Written: out/auto-container-shop.png
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.
Serving a single file opens that workspace’s overview directly; serving a directory opens the list of workspaces in it.
Finding your way
Every page has the same bar along the top: the workspace’s name, then a tab for each way of looking at it.
| Tab | What it shows |
|---|---|
| Overview | The workspace’s home: what it is, what the model holds, its health (each problem links to the elements behind it in Review), recent changes (each links to the comparison) and every view as a thumbnail |
| Diagrams | The workspace’s views, listed in the sidebar — your own views first, in C4 order, then generated ones. The button at the diagram’s top left (or [) hides the list; on a narrow window it opens over the diagram instead |
| Graph | The whole workspace as one force-directed graph |
| Clusters | Communities, cycles and coupling, compared with the structure the model declares |
| Review | The model element by element, with hygiene findings |
| Compare | What changed in the model between two git versions |
| Decisions | The workspace’s ADRs (only when it has some) |
Find (⌘K, or Ctrl+K; space on the diagrams page) searches views, elements, decisions and commands from any page. Picking an element shows it where you are — selected on the diagram, the graph or in review — or, if the current page can’t, opens the view that shows it best with it selected. The ⋯ menu holds Print all views, the canvas demo, this documentation and Dark mode, which applies to every page at once.
On the diagrams page the view’s name floats at the diagram’s top left; click it for that view’s actions (open as SVG, copy as Mermaid, print, full screen) and the display settings below. Zoom sits at the bottom left, and a dynamic view’s step buttons at the bottom centre.
Large views stay readable:
- Zoomed far out, where it would be too small to read, the text on relationships and the descriptions in boxes are hidden; hover over an element or a relationship (or select it) to see them.
- Once you zoom in past the whole diagram, a minimap appears above the zoom buttons, showing where you are; click or drag on it to move.
- z zooms to the selection (or back to the whole diagram), as does Zoom to in the element panel — close enough to read, never so close that one box fills the screen.
Routes
| Route | What it is |
|---|---|
GET / | Workspace list |
GET /workspace/{name}/overview | Workspace home: description, counts, health, recent changes, views |
GET /workspace/{name} | The diagrams (#<view>&sel=<references> picks the view and selection) |
GET /workspace/{name}/diagram/{key} | A single diagram |
GET /workspace/{name}/graph | Universe graph — the whole workspace as one force-directed graph |
GET /workspace/{name}/clusters | Cluster analysis |
GET /workspace/{name}/review | Element-by-element review |
GET /workspace/{name}/diff | Compare two git versions of the workspace |
GET /workspace/{name}/decisions | ADR list (from !adrs) |
GET /workspace/{name}/decisions/{id} | A single ADR |
GET /workspace/{name}/print | Every view as one printable document |
GET /workspace/{name}/canvas | In-browser WASM rendering demo |
GET /docs/ | This documentation site |
GET /llms.txt | The DSL cheat sheet, as plain text |
Selecting and linking
On the workspace, diagram and universe graph pages, a click selects an element or relationship; shift-click (or ⌘/ctrl-click) adds to or removes from the selection, and a click on the background — or Esc — clears it.
The selection is kept in the page URL, so the address bar always links to
exactly what is selected: #<view>&sel=<references> on the workspace page,
#sel=<references> on the others. Reloading the page, or the workspace
reloading after an edit, keeps the selection. References name things by path
(Shop/API, Shop/API->Shop/Database) rather than by id, so a link keeps
working while the DSL changes — and locate turns one into the
file and line of each selected statement. The viewer does that lookup
itself, too: the status bar (and the graph’s details panel) links each
selected item to its declaration, opening it in VS Code.
locate from the command line:
structurizrx locate shop.dsl 'http://localhost:3000/workspace/shop#containers&sel=Shop%2FAPI'
Element details
Selecting one element opens its details in a panel beside the diagram: its kind, technology and parent, its description, any review findings, what it uses and what uses it, what it contains, and the views it appears in. The same panel appears wherever you meet an element — a node in the graph, a name on the clusters page, the review walkthrough — so an element always reads the same way.
Following a link in the panel moves to that element (and selects it on the
diagram if it is drawn there); an Appears in entry opens that view with the
element selected. Open in editor jumps to its declaration in VS Code, and
Copy reference copies its path for locate, query or an agent.
Esc closes the panel.
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}/graph | the universe graph as {nodes, links} JSON |
GET /api/workspace/{name}/catalog | what Find searches: elements with paths and views, views with titles, decisions |
GET /api/workspace/{name}/revisions | the workspace file’s git history |
GET /api/workspace/{name}/diff?from=&to= | the model-level comparison of two versions |
GET /api/workspace/{name}/digest | digest |
GET /api/workspace/{name}/query?expr=... | query |
GET /api/workspace/{name}/locate?sel=... | locate --json |
Universe graph
GET /workspace/{name}/graph shows the entire workspace as one
force-directed graph, in the spirit of Obsidian’s graph view: every element,
view, ADR and documentation section is a node, and every relationship,
containment, deployment instance and view membership is a link. It is the
one page that shows how everything in a workspace hangs together, rather
than one C4 scope at a time.
| Interaction | Effect |
|---|---|
| hover | Highlights the node and its immediate neighbours, dimming the rest |
| click | Opens the details panel (kind, description, technology, tags, connections) |
| double-click | Focuses the node’s neighbourhood — a local graph, depth adjustable 1–5 |
| drag a node | Pins it where you drop it; click it without moving to release |
| drag / scroll | Pan and zoom; f zooms to fit, Esc clears focus and selection |
/ | Jumps to the search box; matches stay bright while everything else dims |
The Nodes and Links checkboxes filter what the graph contains. Views, decisions and documentation sections start switched off — they belong to the workspace, but they crowd out the model on first sight. The Forces sliders (repel, link distance, link force, centre, node size) tune the layout live.
Nodes are coloured by kind and sized by how many connections they have, so the hubs of a model are visible before you read a single label.
Diagram display
Three display settings are in the view-name menu on the diagrams page, and a fourth in the ⋯ menu. Each is remembered by your browser, and the single-diagram and print pages follow them too.
| Setting | Effect |
|---|---|
| Layout | How views with autoLayout are arranged. ELK, right-angle lines (the default) and ELK, curved lines use the Eclipse Layout Kernel, which routes relationships around boxes and keeps groups and boundaries together; dagre is the layout upstream Structurizr uses. The view’s direction and separations apply to all three. Views with stored positions are never re-arranged. |
| Elements: Filled / Outlined | Filled draws boxes in solid colour, as C4 diagrams usually are. Outlined moves each element’s colour to its border and leaves the box nearly plain — no large areas of colour, easier on the eye in big views. Works with the workspace’s own colours too. |
| Ignore DSL colours | Draws with the built-in palette instead of the colours the workspace’s styles and themes set. Shapes, icons, sizes and borders still come from the workspace. |
| Dark mode (in the ⋯ menu) | Switches every page, and the diagrams, to a dark background. |
With the built-in palette, whether because the workspace defines no styles
or because Ignore DSL colours is on, software systems other than the one a
view is about are drawn grey, as in render’s SVG output.
Printing a diagram
Print this view in the view-name menu (or p) opens a Print
this view dialog: choose A4 or A3, and landscape, portrait or the
orientation that matches the diagram’s shape. A plain Ctrl/⌘+P works
too, using the last paper size and the matching orientation.
Rather than printing the on-screen pan/zoom viewport (which would crop the diagram to whatever is visible), the current view is exported to a standalone SVG, scaled to fit one page, and captioned with the view title, description, workspace name and date. Diagrams viewed in dark mode are printed with a light background.
This prints one view. To print every view as a paginated document, use
Print all views in the app bar’s ⋯ menu (GET /workspace/{name}/print).
The paper size is set via CSS (@page { size: ... }); in browsers that
don’t support it, pick the paper size in the print dialog instead — the
diagram still fills whatever page it lands on.
Compare versions
GET /workspace/{name}/diff compares two versions of the workspace out of
git and reports what changed in the model, not in the text of the file:
elements, relationships, views and decisions that were added, removed or
modified, field by field.
Pick the two versions in the header. Besides the commits, both pickers offer Working copy — the file as it is on disk, uncommitted edits included — and the page opens on the comparison you usually want: the last commit against your working copy.
The comparison is a link. The chosen pair lives in the query string
(?from=<sha>&to=<sha|working>), so a comparison worth discussing can be
pasted into a review or an issue.
| It reports | Example |
|---|---|
| Elements | + Internet Banking/Cache (container) |
| Relationships | ~ API → Database, description: "Reads from" → "Writes to" |
| Views | which elements a view now shows, or no longer shows |
| Decisions | a new ADR, a status change, an edited body |
| Renames and moves | Internet Banking/API → Internet Banking/API Gateway (probably renamed) |
Why not git diff
git diff answers a different question well. Moving a container between two
software systems is a two-hunk textual change that says nothing about the
architecture, while adding one relationship can be a one-word edit with a
large architectural meaning. Reformatting the file, renaming a DSL
identifier or reordering declarations are large text diffs and no model
change at all — this page reports nothing for them.
Elements are matched by their path (System/Container/Component), never
by id: the DSL assigns ids in parse order, so the same container has a
different id in two revisions as soon as anything is declared before it. One
consequence is worth knowing: a rename is reported as a removal plus an
addition, because from the model’s point of view that is indistinguishable
from a replacement. Likely pairs are listed under Renames and moves — and
only when one candidate is unambiguous, so a container split into three
suggests nothing rather than the wrong thing.
Multi-file workspaces are handled: the version list covers commits that
touched any !included file, and each version’s includes are read from that
same revision, so you never see one commit’s entry file spliced with
another’s parts.
A workspace outside a repository, or one that has never been committed, says so instead of offering the comparison.
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, path}], relationships: [{id, source, destination, description?, technology?, kind?, status?, introduced?, retired?}]} instead of a text listing |
expression is parsed with allow_hyphen_values, so expressions containing
- (like ->api->) don’t need extra escaping.
Elements print with their name path; relationships print as A -> B "desc" [kind] name-path triples, [kind] only shown when the relationship sets one:
structurizrx query ws.dsl "->api->"
element 3 container "Web App" Shop/Web App
element 4 container "API" Shop/API
element 6 container "Database" Shop/Database
relationship 7 Shop/Web App -> Shop/API.Customer REST API "calls"
relationship 8 Shop/API -> Shop/Database "reads and writes" [sync]
structurizrx query ws.dsl "->api->" --json
{
"elements": [
{ "id": "3", "name": "container \"Web App\"", "path": "Shop/Web App" },
{ "id": "4", "name": "container \"API\"", "path": "Shop/API" },
{ "id": "6", "name": "container \"Database\"", "path": "Shop/Database" }
],
"relationships": [
{ "id": "7", "source": "Shop/Web App", "destination": "Shop/API.Customer REST API", "description": "calls" },
{ "id": "8", "source": "Shop/API", "destination": "Shop/Database", "description": "reads and writes", "kind": "sync" }
]
}
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 locate
Find the DSL statements that declare elements, ports, relationships, views and decisions — by name, or straight from a link copied out of the web viewer.
structurizrx locate <file> <reference-or-link>... [--json]
| Flag | Effect |
|---|---|
--json | Emit {items: [...], unresolved: [...]} instead of a text listing |
References
References name things the way the viewer shows them — by path, not by id. Ids are assigned in parse order and change whenever something is declared earlier in the file; a path does not, so a reference keeps pointing at the same element while the workspace is being edited. Paths match case-insensitively.
| Reference | Names |
|---|---|
Shop/API | An element: ancestor names, then its own, joined with / |
Production/Server/API | A deployment element, rooted at its environment (here an instance of API) |
Shop/API.http | A port |
Shop/API->Shop/DB | Every relationship between the two ends |
Shop/API->Shop/DB "reads orders" | Only the one with that description |
view:containers | A view, by key |
decision:3 | An imported architecture decision, by id |
When no relationship joins two ends directly but relationships between their children do — what a view draws as an implied relationship — the reference resolves to those relationships.
Viewer links
A link from structurizrx serve can be passed as-is. Its view (the
workspace page’s #<key>, or the diagram page’s /diagram/<key>) and every
reference in its sel= selection are located. If the link’s workspace slug
isn’t the one serve would give <file>, locate warns — the link is
probably for a different workspace.
structurizrx locate shop.dsl 'http://localhost:3000/workspace/shop#containers&sel=Shop%2FAPI,Shop%2FAPI-%3EShop%2FDatabase'
view containers
shop.dsl:12:9 (to line 14)
container shop "containers" {
container Shop/API
shop.dsl:4:13 (to line 6)
api = container "API" {
relationship Shop/API -> Shop/Database "reads orders"
shop.dsl:8:13
api -> db "reads orders"
Each location is file:line:col of the statement’s first token (an
ident = prefix included), with the line its block closes on. Files pulled in
with !include are reported as themselves, with their own line numbers.
Some things have no statement of their own, and say so:
- a relationship replicated onto deployment instances points at the relationship it copies;
- a view generated by
autois reported as generated; - a
.jsonworkspace has no source locations at all.
From a running server
structurizrx serve answers the same question at
GET /api/workspace/{name}/locate?sel=<references>, with references in the
page-hash format (each URI-encoded, joined with ,) and the --json output
shape. Each location there also carries absolutePath, which is what the
viewer’s “Open in editor” links use.
Unresolved references
A reference that matches nothing is reported on stderr with a suggestion —
the closest path, or one with the same name under a different parent (the
element was moved) — and locate exits non-zero. Everything that did
resolve is still printed.
error: Shop/APX: no element or port with path 'Shop/APX' (did you mean 'Shop/API'?)
structurizrx diff
Compare two versions of a workspace as models — elements, relationships, views and decisions added, removed or changed — not as text.
structurizrx diff <file> [--from <rev>] [--to <rev>] [--against <file>] [--json] [--fail-on-change]
| Flag | Default | Effect |
|---|---|---|
--from | HEAD | Git revision to compare from |
--to | the file on disk | Git revision to compare to |
--against | — | Compare file (on disk) against another workspace file, instead of git history |
--json | Emit {from, to, diff} instead of a text report | |
--fail-on-change | Exit 1 when anything changed (for CI gates) |
--against and --from/--to are mutually exclusive. Without --against,
file must be tracked by git; otherwise diff fails and suggests
--against.
Keyed by name, not id
Everything is matched by an element’s canonical path (ancestor names plus its own name) and a relationship’s two end paths — never by id. The DSL assigns ids in parse order, so the same element gets a different id in two revisions as soon as anything is declared earlier in the file. The consequence: a rename reads as a removal plus an addition, since from the model’s point of view that’s indistinguishable from a replacement. Likely pairs are scored (unchanged description, then parent, then technology) and only reported under “Likely renames” when one candidate wins outright — a container split into three suggests nothing rather than the wrong thing.
structurizrx diff shop-v1.dsl --against shop-v2.dsl
shop-v1.dsl -> shop-v2.dsl
elements +1 −1, relationships +1 −1, views =, decisions =
Elements:
− Shop/API (container)
+ Shop/Gateway (container)
Relationships:
− Shop/Web App -> Shop/API "calls"
+ Shop/Web App -> Shop/Gateway "calls"
Likely renames:
Shop/API -> Shop/Gateway (renamed)
!included files
Multi-file workspaces are handled: a revision’s !includes are read from
that same revision, so a comparison never splices one commit’s entry file
with another’s parts (or today’s parts from disk).
CI use
Fail a check step when a workspace changed since the last commit:
structurizrx diff ws.dsl --fail-on-change
Or capture the JSON for a bot comment:
structurizrx diff ws.dsl --from origin/main --to HEAD --json > diff.json
With no changes:
structurizrx diff ws.dsl
no changes (HEAD -> working)
structurizrx clusters
Cluster analysis at one level: detected communities and how well they agree
with the model’s declared structure, cycles, bridges, articulation points and
per-element coupling metrics. It’s the same analysis behind the viewer’s
/workspace/{name}/clusters page.
structurizrx clusters <file> [--level <lvl>] [--include <tag>]... [--exclude <tag>]... [--no-implied] [--json]
| Flag | Default | Effect |
|---|---|---|
--level | container | Project the model onto system, container or component |
--include | — | Keep only elements carrying one of these tags (repeatable) |
--exclude | — | Drop elements carrying one of these tags (repeatable) |
--no-implied | off | Don’t roll descendant relationships up onto their ancestors |
--json | Emit the full analysis as JSON instead of a text report |
Generated (auto) views are materialized first, matching render/serve.
What each section means
- Communities — groups the Louvain algorithm finds by relationship
density alone, independent of anything declared in the DSL. Each is
compared against the structure the model declares (
group, else the parent element):agreeing/total agree with '<dominant group>'shows how many members’ declared parent matches the community’s most common one. This is the read worth having — a community where few elements agree with the declared grouping is either a naming problem or an undeclared coupling. - Conformance findings — the prose version of the community/declared-structure mismatch: elements whose actual coupling disagrees with where the DSL says they belong.
- Cycles — strongly connected components with more than one member: real circular dependencies, not just a bidirectional pair.
- Bridges — edges whose removal would disconnect the graph; each one is a single point of failure in the dependency structure.
- Articulation points — nodes whose removal would disconnect the graph, the node-level equivalent of a bridge.
- Metrics table — per element:
afferent— incoming dependencies (how many things depend on it)efferent— outgoing dependencies (how many things it depends on)instability—efferent / (afferent + efferent); 0 is maximally stable (depended on, depends on nothing), 1 is maximally unstablepagerank— relative importance by the same algorithm as web search ranking, computed over the dependency graphbetweenness— how often the element sits on the shortest path between two others; high values mark structural chokepoints
Structural algorithms (components, cycles, articulation points, bridges) come
from petgraph; Louvain communities and Brandes’ betweenness are
implemented in structurizr-query directly, since petgraph has neither.
Louvain needs no random seed — it visits nodes in index order and breaks ties
towards the lowest community index — so a run is reproducible by
construction.
Example
Real output from the bundled Big Bank example at component level:
structurizrx clusters bigbank.dsl --level component
6 elements, 4 dependencies, 3 communities (modularity 0.406), 2 disconnected parts, 0 cycles, 4 bridges, 0 conformance findings
Communities:
#0: Sign In Controller, Security Component [2/2 agree with 'API Application']
#1: Accounts Summary Controller, Mainframe Banking System Facade [2/2 agree with 'API Application']
#2: Reset Password Controller, E-mail Component [2/2 agree with 'API Application']
Bridges:
Sign In Controller -> Security Component
Accounts Summary Controller -> Mainframe Banking System Facade
Reset Password Controller -> Security Component
Reset Password Controller -> E-mail Component
Articulation points:
Reset Password Controller
Security Component
name afferent efferent instability pagerank betweenness
Sign In Controller 0 1 1.000 0.1142 0.0000
Accounts Summary Controller 0 1 1.000 0.1142 0.0000
Reset Password Controller 0 2 1.000 0.1142 0.0000
Security Component 2 0 0.000 0.2715 0.0000
Mainframe Banking System Facade 1 0 0.000 0.2202 0.0000
E-mail Component 1 0 0.000 0.1656 0.0000
Filter to one part of the model with tags, and compare communities without rolling up descendant relationships:
structurizrx clusters ws.dsl --level container --include Payments --no-implied --json
structurizrx graph
Print the whole workspace as one graph: every element, view and decision a
node; every relationship, containment, deployment instance and view
membership a link. This is the data behind the viewer’s universe graph page
(/workspace/{name}/graph).
structurizrx graph <file> [--format <json|dot>]
| Flag | Default | Effect |
|---|---|---|
--format | json | json (the viewer’s feed shape, {workspaceName, nodes, links}) or dot |
Generated (auto) views are materialized first.
structurizrx graph shop.dsl --format json
{
"workspaceName": "Shop",
"nodes": [
{ "id": "e:1", "refId": "1", "name": "Customer", "kind": "person", "tags": ["Element", "Person"] },
{ "id": "e:2", "refId": "2", "name": "Shop", "kind": "softwareSystem", "tags": ["Element", "Software System"] },
{ "id": "e:3", "refId": "3", "name": "Web App", "kind": "container", "parentId": "e:2", "description": "Storefront", "technology": "TypeScript", "tags": ["Element", "Container"] }
]
}
DOT / Graphviz
structurizrx graph shop.dsl --format dot
digraph universe {
label="Shop";
node [fontname="sans-serif"];
edge [fontname="sans-serif"];
"e:1" [label="Customer", shape=ellipse];
"e:2" [label="Shop", shape=box];
"e:3" [label="Web App", shape=box];
"e:1" -> "e:3" [label="shops on", style=solid];
"e:2" -> "e:3" [style=dashed];
}
Containment edges are dashed, view/decision membership edges are dotted, and relationships are solid. Pipe straight into Graphviz:
structurizrx graph shop.dsl --format dot | dot -Tsvg > universe.svg
structurizrx fmt
Print a workspace as canonical DSL: stable ordering and layout, a .json
workspace converted to DSL, a sketch promoted
to a full workspace { model { … } views { … } }.
structurizrx fmt <file> [--output <path>] [--write] [--check] [--force]
| Flag | Effect |
|---|---|
--output, -o | Write the result to this file instead of stdout |
--write | Rewrite <file> in place |
--check | Exit 1 if the file isn’t already in canonical form (CI); no output written |
--force | With --write, overwrite even when comments or other things would be lost |
--write and --check are mutually exclusive; --force requires --write.
Promoting a sketch
A sketch (bare arrows, no workspace block) formats into a full workspace,
with placeholders declared as softwareSystems and a generated
systemLandscape view:
structurizrx fmt sketch.dsl
Input (site/examples/sketch.dsl):
customer -> shop "buys things"
shop -> billing "somehow charges" ?
billing -> erp
Output:
workspace "Sketch" {
model {
customer = softwareSystem "customer" "" "Placeholder"
shop = softwareSystem "shop" "" "Placeholder"
billing = softwareSystem "billing" "" "Placeholder"
erp = softwareSystem "erp" "" "Placeholder"
customer -> shop "buys things"
shop -> billing "somehow charges" ?
billing -> erp
}
views {
systemLandscape "sketch" {
include billing customer erp shop
}
}
}
Converting JSON to DSL
fmt accepts .json workspaces too — useful for round-tripping a workspace
exported by export, or one produced by other Structurizr tooling, back
into readable DSL:
structurizrx export shop.dsl --output shop.json
structurizrx fmt shop.json
workspace "Shop" {
model {
customer = person "Customer"
shop = softwareSystem "Shop" {
webApp = container "Web App" "Storefront" "TypeScript"
api = container "API" "Handles requests" "Rust" {
status implemented
port customerRestApi "Customer REST API" {
protocol "HTTPS/JSON"
}
}
database = container "Database" "Stores data" "PostgreSQL" "Database"
webApp -> api.customerRestApi "calls"
api -> database "reads and writes" {
kind sync
}
}
customer -> webApp "shops on"
}
...
}
--write and what it refuses to lose
fmt doesn’t preserve comments, so --write (and --check, implicitly)
refuses to overwrite a file that has any, or that uses !include,
!docs/!adrs, constants (!const/!var), or specification kind
aliases — anything the canonical rewrite would drop or normalize away:
structurizrx fmt commented.dsl --write
Error: not rewriting commented.dsl because comments are not preserved.
Use --output to write elsewhere, or --force to accept the loss.
structurizrx fmt commented.dsl --write --force
formatted commented.dsl
CI: enforce canonical form
structurizrx fmt ws.dsl --check
ws.dsl is not in canonical form (run `structurizrx fmt --write`)
Exits 0 silently when the file is already canonical.
structurizrx add / remove / rename
Surgical edits to DSL source — the three edits that are easy to get wrong by
hand: putting a statement inside the right block, deleting a declaration
together with what refers to it, and renaming an identifier everywhere,
including across !included files. Comments and formatting outside the
touched lines are kept.
Every edit is written, then the workspace is re-parsed; if the result doesn’t parse, the original files are restored and the parse errors are reported instead — a workspace never goes from valid to broken through these commands.
Targets are addressed by DSL identifier (api) or by name path
(Shop/API, Shop/Web App->Shop/API), same as locate.
add
structurizrx add <file> <statement> [--in <ref>] [--json]
| Flag | Default | Effect |
|---|---|---|
--in | model | Block to add into: model, views, or an element reference |
--json | Emit {added: {file, line}} instead of text |
statement is one DSL line — an element, a relationship, a view, or a body
line like technology "Rust" inside an existing element.
structurizrx add ws.dsl 'db = container "Database" "Stores orders" "Postgres"' --in shop
added at ws.dsl:6
structurizrx add ws.dsl 'api -> db "reads and writes"' --in model
added at ws.dsl:10
structurizrx add ws.dsl 'cache = container "Cache" "Redis"' --in shop --json
{"added":{"file":"ws.dsl","line":7}}
remove
structurizrx remove <file> <reference> [--cascade] [--json]
| Flag | Effect |
|---|---|
--cascade | Also remove every relationship touching the element or anything inside it |
--json | Emit {removed: [{what, file, line, endLine}, ...]} instead of text |
Without --cascade, removing something still referenced by a relationship
fails and nothing is written:
structurizrx remove ws.dsl gateway
Error: the edit would leave the workspace unparseable, so nothing was changed:
ws.dsl:8:19: unknown element identifier 'gateway' in relationship
(use --cascade to also remove the relationships that refer to it)
With --cascade, the element’s own relationships are removed alongside it,
across whichever file each one is declared in:
structurizrx remove ws.dsl gateway --cascade
removed container gateway (parts/extra.dsl:2)
removed relationship Shop/Web App -> Shop/API "calls" (ws.dsl:8)
structurizrx remove ws.dsl cache --json
{"removed":[{"endLine":7,"file":"ws.dsl","line":7,"what":"container cache"}]}
rename
structurizrx rename <file> <identifier> <new-identifier> [--json]
Rewrites the identifier everywhere it’s used as a token — declarations,
relationship endpoints, port references (api.rest) — across every
!included file. Quoted element names are untouched: renaming the
identifier api to gateway doesn’t change container "API" to
container "Gateway".
structurizrx rename ws.dsl api gateway
renamed api -> gateway (2 occurrences in 2 files)
ws.dsl: 1
parts/extra.dsl: 1
structurizrx rename ws.dsl db database --json
{"renamed":{"files":[{"file":"ws.dsl","replacements":2}],"from":"db","to":"database"}}
Multi-file example
A two-file workspace, entry file plus an !included part:
ws.dsl:
workspace "Shop" {
model {
customer = person "Customer"
shop = softwareSystem "Shop" {
!include parts/extra.dsl
}
customer -> webapp "shops on"
webapp -> api "calls"
}
views {
systemContext shop "context" {
include *
autoLayout
}
}
}
parts/extra.dsl:
webapp = container "Web App" "Storefront" "TypeScript"
api = container "API" "Handles orders" "Rust"
structurizrx rename ws.dsl api gateway rewrites the identifier in both
files — the declaration in parts/extra.dsl and the relationship endpoint in
ws.dsl — while leaving container "API"’s quoted name exactly as it was:
webapp = container "Web App" "Storefront" "TypeScript"
gateway = container "API" "Handles orders" "Rust"
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).
Static report
Export a portable, server-free single-page technical report with a top-level index and all rendered SVG diagrams:
structurizrx export-site ws.dsl --output ./site
Open site/index.html in any browser. Generated views are materialized, just
as they are for render and serve.
Static viewer
Export the interactive viewer as a portable application with the workspace JSON and every required web asset (including images and JointJS):
structurizrx export-viewer ws.dsl --output ./viewer
Serve viewer/ with any static file server and open index.html.
The exported viewer has no server behind it, so the pages and buttons that
need one — universe graph, clusters, review, diff, print document, SVG
download, copy as Mermaid, open-in-editor links and live reload — are left
out. The docs site’s demo is an export-viewer output.
structurizrx mcp
Serve structurizrx’s read and edit commands as Model Context
Protocol tools over stdio, for agent hosts
(Claude Desktop, Claude Code, Cursor, Codex, …) that would otherwise have to
shell out to the CLI and parse its output.
structurizrx mcp
It’s a hand-rolled JSON-RPC 2.0 server, newline-delimited JSON on
stdin/stdout: nothing but protocol messages goes to stdout, diagnostics go to
stderr. initialize and tools/list work like any MCP server:
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n' | structurizrx mcp
Tools
| Tool | Does |
|---|---|
validate | Parse and validate a workspace; parse errors with file/line/column, model validation errors, lint findings. strict also treats lint findings as failing |
digest | Compact plain-text model summary, sized for an LLM’s context — use to get oriented before querying or editing |
query | Run a selector expression and return matching elements/relationships as structured data |
locate | Find where elements, ports, relationships, views or decisions are declared, by name path or viewer link — the bridge from query/digest results to an editable source line |
lint | Model hygiene: blocking findings (the same gate validate --strict fails on) plus softer warnings |
diff | Compare two versions of a workspace as models, keyed by name so renames are detected; git revisions by default, or against another file |
render | Render a workspace’s diagrams to SVG or PNG; all views by default, or one view |
docs | The DSL cheat sheet — keywords, syntax, extensions — for writing or editing DSL from scratch |
add | Add one DSL statement inside a block, keeping comments and formatting; writes nothing if the result wouldn’t parse |
remove | Delete an element/relationship/port’s declaring statement; cascade also removes relationships that refer to it |
rename | Rename a DSL identifier everywhere, across !included files, without touching quoted element names |
format | Canonical DSL text: JSON converted to DSL, a sketch promoted to a full workspace; read-only, reports what would be lost |
Every tool takes file (absolute, or resolved against the server’s working
directory — see below). Run tools/list for the exact input schema of each,
including the free-text selector/reference grammar query and locate take.
file is resolved against the server’s cwd
file arguments are not resolved against the client’s workspace or the
conversation’s notion of “current file” — they’re resolved against the
directory structurizrx mcp itself was launched from. An agent host that
runs the server from an unpredictable directory (or the user’s home
directory) can silently miss the intended file. Passing an absolute path
is always safest.
Configuration
Claude Code
claude mcp add structurizrx -- structurizrx mcp
Claude Desktop
Add to claude_desktop_config.json:
{
"mcpServers": {
"structurizrx": {
"command": "structurizrx",
"args": ["mcp"]
}
}
}
Cursor
Add to .cursor/mcp.json:
{
"mcpServers": {
"structurizrx": {
"command": "structurizrx",
"args": ["mcp"]
}
}
}
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.