Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

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

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

Why a master model

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

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

Progressive fidelity

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

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

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

Where to go next

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

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

Install

Homebrew (macOS & Linux)

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

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

Windows

Scoop

Scoop installs from the project’s bucket:

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

scoop update structurizrx picks up new releases automatically.

winget

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

winget install Pomali.StructurizrX

Chocolatey

Once the package is published to the Chocolatey Community Repository:

choco install structurizrx

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

Linux packages (apt / dnf)

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

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

VERSION=0.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.

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

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

Linux / macOS

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

Windows

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

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

Build from source

Requires a Rust toolchain.

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

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

Verify the install

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

Next: the Quickstart.

Quickstart

The fastest path: a sketch

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

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

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

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

structurizrx serve sketch.dsl --open

Edit the file and save; the browser reloads automatically.

A full workspace

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

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

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

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

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

Where to go next

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

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.

Open the interactive demo →

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

ViewShows
containersEverything at once — the stress test
platform-coreThe read and write paths without ads, data platform, safety and operations
post-tweet, read-home-timelineDynamic views: the write path with fanout, and the ranked read path
home-timeline, fanoutComponent views inside the two busiest services
auto-focus-event-busEvery producer and consumer of the Kafka bus (auto focus … { direction in })
auto-perspective-security, auto-perspective-performanceElements carrying a perspective note
auto-slice-…publish…subscribeOnly the asynchronous, event-driven relationships
auto-layer-timelines, auto-layer-trust-safetyOne group and its neighbours
production-deploymentContainers 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.

CommandWhat it does
validateParse + validate; --strict also fails on lint findings; --json emits structured output
lintModel hygiene: blocking findings, warnings and dependency cycles
renderExport diagrams (materializes generated views first)
serveLive-reloading web viewer with a JSON API
digestCompact plain-text model + view summary, sized for LLM context
queryRun a selector expression against a workspace
locateFind where elements, relationships and views are declared, by path or from a viewer link
diffCompare two versions of a workspace as models
clustersCommunity/coupling analysis: conformance findings, cycles, bridges, metrics
graphThe whole workspace as one graph, as JSON or DOT
fmtCanonical DSL text: JSON→DSL, sketch→workspace, --check/--write
add/remove/renameSurgical, re-parse-checked edits to DSL source
exportWorkspace JSON (superset of the Structurizr JSON schema)
export-siteSingle-page SVG technical report
export-viewerPortable interactive viewer with bundled assets and workspace JSON
mcpServe these commands as MCP tools over stdio
docsPrint the DSL cheat sheet

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

Global behavior

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

structurizrx validate

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

structurizrx validate <file> [--strict] [--json]
FlagEffect
--strictAlso fail (non-zero exit) on lint findings — placeholders, uncertain (?) items, orphan elements, unbound ports
--jsonEmit machine-readable JSON instead of text: {valid, errors: [{code, 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]
FlagEffect
--strictAlso fail (exit 1) on warnings and cycles, not just blocking findings
--jsonEmit {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):

CodeMeaning
placeholderElement was auto-created in sketch mode and never properly declared
uncertainElement is tagged Uncertain
orphanElement has no relationships and no children
unbound-portPort is declared but never referenced by a relationship

Warnings (only fail with --strict):

CodeMeaning
missing-descriptionElement has no description
missing-technologyContainer or component has no technology
not-in-any-viewElement is not shown by any view
no-relationshipsElement has no incoming or outgoing relationships and no children
duplicate-nameElement’s name is hard to tell apart from another’s
relationship-undescribedOne or more relationships leaving the element have no description
cycleA 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>]
FlagDefaultEffect
--formatplantumlOne 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:

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

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/FlagDefaultEffect
path.A .dsl/.json file, or a directory containing one or more workspaces
--port, -p3000TCP port to listen on
--openoffOpen the browser automatically after starting

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

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.

TabWhat it shows
OverviewThe 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
DiagramsThe 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
GraphThe whole workspace as one force-directed graph
ClustersCommunities, cycles and coupling, compared with the structure the model declares
ReviewThe model element by element, with hygiene findings
CompareWhat changed in the model between two git versions
DecisionsThe 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

RouteWhat it is
GET /Workspace list
GET /workspace/{name}/overviewWorkspace 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}/graphUniverse graph — the whole workspace as one force-directed graph
GET /workspace/{name}/clustersCluster analysis
GET /workspace/{name}/reviewElement-by-element review
GET /workspace/{name}/diffCompare two git versions of the workspace
GET /workspace/{name}/decisionsADR list (from !adrs)
GET /workspace/{name}/decisions/{id}A single ADR
GET /workspace/{name}/printEvery view as one printable document
GET /workspace/{name}/canvasIn-browser WASM rendering demo
GET /docs/This documentation site
GET /llms.txtThe 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:

RouteEquivalent to
GET /api/workspacesworkspace list with counts
GET /api/workspace/{name}the full workspace JSON (export)
GET /api/workspace/{name}/decisions[/{id}]ADR data
GET /api/workspace/{name}/diagram/{key}/svgrender --format svg, one diagram
GET /api/workspace/{name}/diagram/{key}/mermaidrender --format mermaid, one diagram
GET /api/workspace/{name}/graphthe universe graph as {nodes, links} JSON
GET /api/workspace/{name}/catalogwhat Find searches: elements with paths and views, views with titles, decisions
GET /api/workspace/{name}/revisionsthe workspace file’s git history
GET /api/workspace/{name}/diff?from=&to=the model-level comparison of two versions
GET /api/workspace/{name}/digestdigest
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.

InteractionEffect
hoverHighlights the node and its immediate neighbours, dimming the rest
clickOpens the details panel (kind, description, technology, tags, connections)
double-clickFocuses the node’s neighbourhood — a local graph, depth adjustable 1–5
drag a nodePins it where you drop it; click it without moving to release
drag / scrollPan 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.

SettingEffect
LayoutHow 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 / OutlinedFilled 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 coloursDraws 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 reportsExample
Elements+ Internet Banking/Cache (container)
Relationships~ API → Database, description: "Reads from" → "Writes to"
Viewswhich elements a view now shows, or no longer shows
Decisionsa new ADR, a status change, an edited body
Renames and movesInternet 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]
FlagEffect
--jsonEmit {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]
FlagEffect
--jsonEmit {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.

ReferenceNames
Shop/APIAn element: ancestor names, then its own, joined with /
Production/Server/APIA deployment element, rooted at its environment (here an instance of API)
Shop/API.httpA port
Shop/API->Shop/DBEvery relationship between the two ends
Shop/API->Shop/DB "reads orders"Only the one with that description
view:containersA view, by key
decision:3An 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.

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 auto is reported as generated;
  • a .json workspace 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]
FlagDefaultEffect
--fromHEADGit revision to compare from
--tothe file on diskGit revision to compare to
--against—Compare file (on disk) against another workspace file, instead of git history
--jsonEmit {from, to, diff} instead of a text report
--fail-on-changeExit 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]
FlagDefaultEffect
--levelcontainerProject 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-impliedoffDon’t roll descendant relationships up onto their ancestors
--jsonEmit 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 unstable
    • pagerank — relative importance by the same algorithm as web search ranking, computed over the dependency graph
    • betweenness — 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>]
FlagDefaultEffect
--formatjsonjson (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]
FlagEffect
--output, -oWrite the result to this file instead of stdout
--writeRewrite <file> in place
--checkExit 1 if the file isn’t already in canonical form (CI); no output written
--forceWith --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]
FlagDefaultEffect
--inmodelBlock to add into: model, views, or an element reference
--jsonEmit {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]
FlagEffect
--cascadeAlso remove every relationship touching the element or anything inside it
--jsonEmit {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>]
FlagDefaultEffect
--output, -oworkspace.jsonOutput file path

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

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

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

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

ToolDoes
validateParse and validate a workspace; parse errors with file/line/column, model validation errors, lint findings. strict also treats lint findings as failing
digestCompact plain-text model summary, sized for an LLM’s context — use to get oriented before querying or editing
queryRun a selector expression and return matching elements/relationships as structured data
locateFind 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
lintModel hygiene: blocking findings (the same gate validate --strict fails on) plus softer warnings
diffCompare two versions of a workspace as models, keyed by name so renames are detected; git revisions by default, or against another file
renderRender a workspace’s diagrams to SVG or PNG; all views by default, or one view
docsThe DSL cheat sheet — keywords, syntax, extensions — for writing or editing DSL from scratch
addAdd one DSL statement inside a block, keeping comments and formatting; writes nothing if the result wouldn’t parse
removeDelete an element/relationship/port’s declaring statement; cascade also removes relationships that refer to it
renameRename a DSL identifier everywhere, across !included files, without touching quoted element names
formatCanonical 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:

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

Core structure

A full workspace has three top-level blocks:

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

The element hierarchy

Standard C4 elements, unchanged from upstream Structurizr:

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

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

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

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

Groups

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

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

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

Enterprise and deployment nodes

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

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

Comments

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

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

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

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

Sketch mode

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

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

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

? — marking uncertainty

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

shop -> billing "somehow charges" ?

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

Opting a full workspace into sketch leniency

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

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

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

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

Element extras

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

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

status — confidence in the design

billing = softwareSystem "Billing" { status idea }

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

introduced / retired — lifecycle

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

legacyCrm = softwareSystem "Legacy CRM" { retired billingSplit }

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

perspective — quality-attribute notes

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

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

port — named interaction points

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

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

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

technology, description, tags, url

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

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

Relationships and ports

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

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

Port-attached relationships

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

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

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

kind — connector semantics

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

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

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

Naming a relationship

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

? — uncertain relationships

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

Next: Workspace-level 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 in docs/SPEC.md §6.3) parses successfully but currently emits nothing — generate_views accepts the syntax and prints a note that it was skipped, rather than producing a view. Likewise the collapse modifier for folding n-ary connectors isn’t implemented yet. Check the repository README’s Status section for the current gap list before relying on either.

Dynamic and deployment views

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

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

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

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

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

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

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

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

Documentation and decisions

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

!adrs / !decisions — architecture decision records

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

!adrs <path> (alias !decisions) points at a directory of Markdown files in AdrTools/MADR format: filenames start with a numeric ID (0001-use-postgres.md), the ID becomes the decision’s id (leading zeros stripped: 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.