Skip to content

Structurizr / C4 Converter

Converts Structurizr workspace JSON to RDF aligned to the C4 model ontology.

Ontology

Every document this converter emits into or validates against. Each link is the published page, which serves human-readable documentation and negotiates to Turtle.

Document Namespace Prefix Covers
C4 model ontology https://meta.linked.archi/c4/onto# c4 The abstract model — people, systems, containers, components
C4 viewpoints https://meta.linked.archi/c4/viewpoints# c4vp Which C4 level a view sits at
C4 metamodel manifest https://meta.linked.archi/c4/metamodel# — The arch:modelConformsToMetamodel target. Not abbreviated in output
Structurizr extension https://meta.linked.archi/c4/structurizr# structurizr Deployment, which C4 deliberately says nothing about
Architecture decisions https://meta.linked.archi/arch-decision# ad ADRs from !adrs, and their five states
Linked.Archi core https://meta.linked.archi/core# arch arch:Element, arch:Model, relationship endpoints, folders
Core visual https://meta.linked.archi/core-vis# archvis View nodes and links, in the views graph

And the shape documents, which constrain that output rather than appearing in it:

Document Namespace Referred to as Covers
C4 shapes https://meta.linked.archi/c4/shapes# c4sh: C4's constraints, including the naming rule
Structurizr shapes https://meta.linked.archi/c4/structurizr-shapes# structsh: Constraints on the deployment classes
Decision shapes https://meta.linked.archi/arch-decision-shapes# adsh: ADR constraints. Opt-in — see Validate
Core shapes https://meta.linked.archi/core-shapes# — The relationship-endpoint contract

Each shape document binds its own namespace to : rather than declaring a short prefix, so c4sh:, structsh: and adsh: are this project's shorthand for reading them here, not prefixes you will find in the published Turtle.

archvis here, arch-vis upstream

The converters register the core-vis namespace under the prefix archvis, while the published document declares it as arch-vis. The IRIs are identical and nothing is ambiguous, but a prefixed name copied from the published page into a query against this output — or the reverse — will not resolve until the prefix is declared to match.

C4 is notation-independent and silent about tooling, so the extension carries what Structurizr adds. The C4 ontology's own description names that document and says why the split exists.

Where no architecture ontology has a term, the output uses standard external vocabularies rather than minting one:

Prefix Namespace Used for
schema https://schema.org/ url, perspectives, !docs prose, tags, folder listings
dct http://purl.org/dc/terms/ dct:identifier, dct:type, dct:date, folder membership
skos http://www.w3.org/2004/02/skos/core# Labels, notations, descriptions
prov http://www.w3.org/ns/prov# The run, and each lifted ADR's source
owl http://www.w3.org/2002/07/owl# owl:sameAs, from a property keyed @id

Output is laid out in four named graphs — semantic, model, views, provenance — as it is for every converter here. See output serialization and the architecture overview for the graph and folder structure.

Mapping reference

The converter reads workspace JSON, so the JSON column is what it actually matches on. The DSL column is the authoring keyword that produces it — read the table left to right to see what survives a .dsl → JSON → RDF round trip. See how to get a workspace JSON.

Anything marked not read is present in the file and contributes no triples. Nothing in that column is silently lossy where a graph would notice: every case that would be — softwareSystemInstance, a custom element with no mapped class, an ADR status outside the published set, an embedded documentation image, and an unmintable property key — is reported once per run.

Elements

DSL Workspace JSON RDF type
person model.people[] c4:Person
softwareSystem model.softwareSystems[] c4:SoftwareSystem
container …softwareSystems[].containers[] c4:Container
component …containers[].components[] c4:Component
deploymentNode model.deploymentNodes[], .children[] structurizr:DeploymentNode
infrastructureNode ….infrastructureNodes[] structurizr:InfrastructureNode
element (custom element) model.customElements[] no notation class — see Custom elements

Every element also gets arch:Element, arch:ModelConcept and dct:isPartOf the Elements folder. --type-mapping overrides any row's type, keyed on the lowercased name (person, software_system, container, component, deployment_node, infrastructure_node, custom).

Containment

Four different nestings, three different predicates. C4 names its own two; the deployment tree falls back to the generic core one, because the extension declares no parent/child property and c4:hasContainer would claim the child is a c4:Container.

DSL nesting JSON RDF (parent → child)
container in softwareSystem containers[] c4:hasContainer
component in container components[] c4:hasComponent
deploymentNode in deploymentNode children[] arch:hasPart
infrastructureNode in deploymentNode infrastructureNodes[] arch:hasPart
group group not read — a rendering boundary, not a model fact

dct:isPartOf is deliberately not used for nesting: it is already spoken for by folder and model membership, so a consumer could not tell "sits in the Elements folder" from "is nested inside this node".

Relationships

Every relationship is an arch:QualifiedRelationship and an arch:ModelConcept with arch:source and arch:target, and is pointed at from its source element by a qualified predicate — the way in, since arch:source only points out. Both qualified forms are published alongside their unqualified ones with arch:unqualifiedForm.

DSL JSON RDF type Qualified predicate Direct triple (--emit-direct-rel-triples)
a -> b …relationships[] c4:Using {src} c4:qualifiedUses {rel} {src} c4:uses {tgt}
containerInstance containerInstances[] structurizr:Deployment {container} structurizr:qualifiedDeployedOn {rel} {container} structurizr:deployedOn {node}
softwareSystemInstance, instanceOf on a system softwareSystemInstances[] not emitted, reported — —
-/> (remove relationship) — resolved by the DSL parser before export — —

The qualified predicate is always emitted; the direct triple needs the flag, and carries an rdf:reifies bridge back to the relationship resource so the shortcut and the resource are one statement rather than two unrelated ones.

--type-mapping keys these on using and deployment. A qualifiedPredicates: entry overrides the published predicate, which is the route a house ontology takes.

A container instance is a relationship, not an element. The extension publishes no ContainerInstance class; it publishes structurizr:Deployment, and structsh:DeploymentShape constrains it to run from a c4:Container to a structurizr:DeploymentNode. So the instance's own id becomes the relationship, its containerId the arch:source, and the node it sits inside the arch:target — and its own tags and properties land on that relationship:

<…/relationship/23> a structurizr:Deployment, arch:QualifiedRelationship ;
    arch:source <…/element/3> ;          # the container
    arch:target <…/element/21> ;         # the node it runs on
    dct:identifier "acmeCloud.euWest1.oppMfeInstance" .

<…/element/3> structurizr:qualifiedDeployedOn <…/relationship/23> .

Two consequences:

  • Software system instances are not emitted. structurizr:Deployment constrains its source to c4:Container, so emitting one would make correct input produce a graph that fails its own validation. Each is reported rather than dropped silently. The fix is upstream — see todo/UPSTREAM-ISSUES-meta.md.
  • A relationship drawn between two instances lands on the containers. Structurizr draws deployment-view relationships between instances, and an instance is a relationship here, so its endpoints are redirected onto the containers they instantiate. Which instance is lost, what talks to what is kept. Two instances of one container talking to the same peer therefore arrive as two relationships with identical endpoints under distinct ids — redundant, not wrong.

Custom elements

The DSL's element keyword models the things a real landscape contains that C4 has no level for — a managed cloud service, a regulatory obligation, a partner data feed. Structurizr defines it as an element that sits outside the C4 model, so no c4: class is right for it and the deployment extension does not reach it either.

It is emitted with the core classes only:

<…/element/30> a arch:Element, arch:ModelConcept ;
    skos:prefLabel "Amazon S3"@en ;
    skos:notation "30" ;
    dct:type "Infrastructure" ;          # the metadata token, verbatim
    dct:identifier "objectStore" .

No class is invented. c4: and structurizr: are published documents this converter reads and does not own, so a c4:CustomElement would look like part of C4 while nothing declared its meaning and no shape could constrain it. Unlike the LeanIX converter — which falls back to the published lmm:FactSheet root for a fact sheet type its ontology does not declare — there is no root class here to fall back to: C4 declares no c4:Element, and c4sh:C4ElementLabelShape enumerates the four level classes rather than targeting arch:Element, deliberately.

An unmapped custom element is validated by nothing

Because no shape targets arch:Element, nothing checks that a custom element has a name — where an unnamed container is reported. That is the honest residue of a construct the vocabulary does not reach, which is why the run reports it rather than passing it over. Filed upstream as issue 8 in todo/UPSTREAM-ISSUES-meta.md.

metadata is the type token. element "Amazon S3" "Infrastructure" puts Infrastructure there, and Structurizr renders it where a C4 element shows [Container: Spring Boot] — it is the only statement of kind in the file, so it is kept on dct:type whether or not a class was found. Read only for custom elements, so a metadata key on some other element cannot silently become a type.

--type-mapping is how you give them a class, keyed on your own token or on custom for all of them at once. A token entry wins over the blanket one:

elements:
  Infrastructure: https://vocab.example.org/arch#Infrastructure
  Compliance:     https://vocab.example.org/arch#Obligation
  custom:         https://vocab.example.org/arch#External   # anything not named above

A mapped class is added alongside the core ones, and dct:type still carries the token — the class is your reading of it, the token is what the workspace says.

Two things that need no special handling. Relationships work in both directions: c4:Using constrains its endpoints to arch:Element, and c4sh:UsingShape enforces exactly that, so an edge between a container and a custom element is valid as it stands. And a custom view is read, because it is the only place a custom element can be drawn; it claims no viewpoint, since the published catalogue declares seven and none of them is this.

Perspectives

A perspectives block is a named, valued judgement an author attached to an element or a relationship — the one place a security or performance assessment gets written down:

container "Payment API" {
    perspectives {
        "Security" "Handles cardholder data end to end" "High"
    }
}

All three parts survive, as the property-value pair the perspective structurally is:

<…/element/2> schema:additionalProperty [
    a schema:PropertyValue ;
    schema:propertyID "structurizr.perspective" ;
    schema:name "Security" ;
    schema:value "High" ;
    schema:description "Handles cardholder data end to end."
] .

Not arch:Perspective. Core does publish that class, but it means a stakeholder perspective — strategic, operations, physical — and its two properties have domain arch:Stakeholder and arch:Viewpoint. Nothing relates an arch:Element to one and nothing carries a value for one, so typing a Structurizr perspective as arch:Perspective would claim membership of a class whose published examples are a different kind of thing, and still leave the value homeless.

schema:propertyID is what separates these from workspace properties, which share schema:additionalProperty — correctly, since schema.org defines it as "an additional characteristic of the entity" and both are that. Select perspectives with:

?element schema:additionalProperty [ schema:propertyID "structurizr.perspective" ;
                                     schema:name ?perspective ; schema:value ?assessment ] .

Documentation and decisions

!docs and !adrs are where a workspace keeps the reasoning a diagram cannot carry. Both are emitted; between them they were the largest thing still being dropped.

Documentation sections become addressable schema:CreativeWork resources the subject is schema:subjectOf — schema.org's "a CreativeWork about this Thing":

<…/element/2> schema:subjectOf <…/documentation/2/1> .

<…/documentation/2/1> a schema:CreativeWork ;
    schema:text "## Sun Deals\n\nHow the platform hangs together." ;
    schema:encodingFormat "text/markdown" ;
    schema:position 1 ;
    schema:name "01-overview.md" .

Its own resource rather than a literal on the element, because there can be several, they are ordered, and each has its own media type. Not skos:definition, which already carries the element's one-line description — two values under one predicate with nothing to tell them apart is a collision, and a page of Markdown is not a definition. Workspace-level !docs attaches to the model, under …/documentation/workspace/<n>.

ADRs need no schema.org at all, because this project already publishes a decision ontology:

<…/decision/1> a ad:Decision, arch:Element, arch:ModelConcept ;
    skos:prefLabel "Use a micro-frontend for the offer UI"@en ;
    skos:notation "1" ;
    ad:decisionState ad:Superseded ;
    ad:supersededBy <…/decision/3> ;
    ad:relatedConcept <…/element/2> ;
    dct:date "2026-03-04T00:00Z"^^xsd:dateTime ;
    dct:type "Superseded" ;
    schema:text "## Context\n\nThe offer UI is owned by two teams." ;
    schema:encodingFormat "text/markdown" .

Structurizr's five ADR statuses are exactly the five published ad:DecisionState individuals — ad:Proposed, ad:Accepted, ad:Superseded, ad:Deprecated, ad:Rejected — so the mapping is a transcription, matched case-insensitively. ad:relatedConcept is what makes "which decisions affect this container" answerable; a workspace-level ADR has no element to name and carries none.

Decisions get their own …/decision/<id> segment. ADR ids are numbered independently of element ids, so sharing element/ would let ADR 3 and container 3 collide.

This is the first converter to emit ad: subjects

Elsewhere the converters only ever link to a decision record, because ADRs are authored outside the diagrams. A Structurizr workspace carries them inline, so a converted one holds the records themselves. arch-decision is therefore in this converter's default ontologies — ad:Decision rdfs:subClassOf arch:Element has to resolve — while its shapes stay opt-in: validate --shapes arch-decision-shapes.

The ADR body becomes the rationale document the decision shapes ask for:

<…/decision/1> ad:justificationDocument <…/decision/1/rationale> .
<…/decision/1/rationale> a schema:CreativeWork ;
    schema:text "## Context\n\nThe offer UI is owned by two teams." ;
    schema:encodingFormat "text/markdown" .

adsh:AcceptedDecisionRationaleShape requires an accepted decision to record why the choice was made. A prose ADR is that record, and ad:justificationDocument ranges over schema:CreativeWork — the same form used for !docs, and the only one of the two rationale properties with somewhere to put the media type. ad:justification takes a bare xsd:string.

Each lifted ADR also records where it came from, in the provenance graph:

# …/graph/provenance
<…/decision/1>
    prov:wasDerivedFrom      <{base}provenance/source/group-models/8c44d1a4e9f0/workspace-json> ;
    prov:qualifiedDerivation <{base}provenance/derivation/group-models/8c44d1a4e9f0/workspace-json/9c2f1e04> .

<{base}provenance/derivation/group-models/8c44d1a4e9f0/workspace-json/9c2f1e04>
    a                prov:Derivation ;
    prov:entity      <{base}provenance/source/group-models/8c44d1a4e9f0/workspace-json> ;
    prov:hadActivity <{base}provenance/run/9c2f1e04> .

A decision record is an artifact in its own right, so its origin is a fact about it and not only about the model — it is what tells a consumer that the options are prose in a Markdown body somewhere, rather than that no options were recorded.

The qualified form names the run the derivation went through, which the plain predicate cannot and PROV does not license inferring. One prov:Derivation serves every decision lifted from that workspace in that run. Emitted only when the run describes its provenance, since that is what mints the entity being pointed at — see ADR 0008 for the rest of the provenance graph — and deliberately not in the semantic graph: a lift marker belongs in provenance (DD-15), and adsh:AcceptedDecisionSelectionShape relies on a validator over the semantic graph not seeing it, so conformance never depends on how a consumer assembled the graph.

The target is the workspace file. Structurizr's Decision carries an id, a title, a date, a status, links and a body but no filename — unlike a documentation Section — so the path of the individual .md an ADR was read from is not in the export, and deriving one from the id would be a guess about a directory layout the converter never saw.

Two deliberate limits:

  • An unrecognised status gets no state. adsh: constrains ad:decisionState with sh:in over the five individuals, so minting ad:UnderReview would turn correct input into a validation failure. The token is kept on dct:type and the run reports it.
  • Embedded images are not emitted. Structurizr stores an image's bytes base64-encoded in the workspace, so publishing one would put a whole file in a single literal — megabytes per screenshot, not queryable and not dereferenceable. The prose around them is emitted in full, and the run says how many were skipped.

The supersede links deserve a note on how they are read. Structurizr writes the relationship as free text in the link description, so Superseded by and Supersedes are matched to ad:supersededBy and ad:replaces; anything else falls to ad:relatedDecision. Both specific properties are rdfs:subPropertyOf ad:relatedDecision, so the general fact holds either way and the match only ever adds precision. ad:supersededBy's own scope note asks for the replacement to be a separate assertion rather than left inside the status line, which is what makes reading the description worth doing.

Attributes

DSL JSON RDF Controlled by
name name skos:prefLabel --emit-skos-labels, --label-language
(export id) id skos:notation --emit-skos-notation
description on an element description skos:definition —
description on a relationship description skos:prefLabel — a relationship's description is its label --emit-skos-labels
technology technology c4:technology —
tags, tag tags (comma-separated) schema:keywords, author's tags only — see Tags —
properties properties depends on the key — see Properties --ns-vocab, --ns-global-id, --type-mapping
element metadata metadata dct:type — see Custom elements --type-mapping
url url schema:url (xsd:anyURI) —
perspectives perspectives[] a schema:PropertyValue each — see Perspectives —
!docs documentation.sections[] a schema:CreativeWork each — see Documentation and decisions —
!adrs documentation.decisions[] ad:Decision — see Documentation and decisions —

Deployment attributes

DSL JSON RDF
deploymentEnvironment <name> environment, written on every node in the tree structurizr:environment (xsd:string)
instances instances structurizr:instances (xsd:integer)
deploymentGroup, deploymentGroups deploymentGroups[] on an instance not read
healthCheck healthChecks[] on an instance not read

deploymentEnvironment is not an element. Structurizr's export flattens the grouping into a string on each node, so there is no environment-level subject to mint and none is invented. Query "everything in Production" by filtering structurizr:environment.

instances accepts a range in the DSL (1..N, 0..1), and structurizr:instances is xsd:integer upstream. A value that is not an integer is left out rather than emitted as a literal the shapes would reject.

Views

Every view is an arch:View and an arch:Diagram, dct:isPartOf the model and the Views folder. Under --include-views its members go to the views named graph.

DSL JSON arch:viewConformsToViewpoint
systemLandscape views.systemLandscapeViews[] c4vp:SystemLandscape
systemContext views.systemContextViews[] c4vp:SystemContext
container views.containerViews[] c4vp:ContainerDiagram
component views.componentViews[] c4vp:ComponentDiagram
deployment views.deploymentViews[] c4vp:C4Deployment
dynamic views.dynamicViews[] c4vp:DynamicDiagram
filtered views.filteredViews[] none — a filtered view is a view of another view, so it has no kind of its own to claim
custom views.customViews[] none — a custom view is not a C4 level, so the catalogue has nothing for it
image views.imageViews[] not read

c4vp:C4Deployment breaks the naming pattern because the published local name does: C4's own name for the level is "deployment", not "deployment diagram".

DSL JSON RDF
key key skos:notation, and the view's IRI segment
title title, falling back to description skos:prefLabel
include (elements) elements[].id archvis:ArchNode — archvis:view, archvis:archElement
include (relationships) relationships[].id archvis:Link — archvis:archRelationship, archvis:source, archvis:target
autoLayout, animation, styles, theme, themes, terminology automaticLayout, animations, views.configuration not read

No geometry. Structurizr auto-layouts, so there is no authored position to carry and no no-geometry profile difference for this converter.

Workspace level

DSL JSON RDF
workspace root object arch:Model, arch:modelConformsToMetamodel <https://meta.linked.archi/c4/metamodel#C4Model>
— file name the source entity's schema:name and the PROV run, in the provenance graph
workspace <name> <description> name, description not read — parsed into the intermediate model and emitted nowhere. The model's --model-id names it instead
!docs on the workspace documentation.sections[] at the root schema:subjectOf a schema:CreativeWork, under …/documentation/workspace/<n>
!adrs on the workspace documentation.decisions[] at the root ad:Decision, with no ad:relatedConcept — there is no element to name
configuration, users, scope, visibility views.configuration not read — access control and rendering, not architecture

The model carries no label

A workspace name reaches StructurizrModel and stops there, so arch:Model has a type and a metamodel link and nothing else. The schema:name on the model folder is the --model-id, not the workspace name. Worth knowing if you expected to read a workspace title out of the graph.

Tags

Structurizr writes tags as one comma-separated string with its own structural entries first, so "Element,Container,Database" is two automatic tags and one the author wrote. Only the author's reach the graph, as schema:keywords literals. The structural set is Element, Person, Software System, Container, Component, Deployment Node, Infrastructure Node, Relationship, Container Instance, Software System Instance.

Properties

A Structurizr properties map is an arbitrary Map<String, String> on any element, relationship or view, and it is where organisations put the facts a cross-model graph joins on. Every key reaches the graph; where it lands depends on how it is written and which options are set:

Key Emitted as Requires
structurizr.dsl.identifier dct:identifier, and optionally the IRI segment —
@id owl:sameAs <base + value> --ns-global-id
@type an extra rdf:type —
prefix:local <namespace><local> prefix under namespaces: in --type-mapping
:local vocab:local --ns-vocab
anything else vocab:key --ns-vocab
anything else schema:additionalProperty blank node —
"properties" : {
  "structurizr.dsl.identifier" : "sundeals",
  "architect" : "Lead Architect",
  "leanIXUrl" : "https://acme.leanix.net/Acme/factsheet/Application/00000000-…",
  "@id" : "APP-0001"
}

With --ns-vocab https://vocab.example.org/arch# and --ns-global-id https://graph.example.org/id/:

<…/element/2> dct:identifier "sundeals" ;
    vocab:architect "Lead Architect" ;
    vocab:leanIXUrl "https://acme.leanix.net/Acme/factsheet/Application/00000000-…" ;
    owl:sameAs <https://graph.example.org/id/APP-0001> .

Nothing is dropped. Without --ns-vocab a plain key still arrives, as a schema:additionalProperty / schema:PropertyValue pair carrying the key and value as literals — queryable, but not as a predicate. The run says which keys ended up there and which option promotes them, once per reason rather than once per element.

Keys that cannot be a local name in an IRI — "Business Owner", anything with a dot or a slash — always take the pair route, since no namespace can reach them.

The dispatch matches the ArchiMate converter's, so an organisation converting both notations has one set of conventions. The one difference: a plain key becomes vocab:key here, where ArchiMate requires :key. An Archi property key is free text typed into a dialogue; a Structurizr key comes from DSL source and is already identifier-shaped.

Usage

# TriG output (named graphs preserved)
java -jar structurizr2linkedarchi.jar convert \
  workspace.json \
  --base-iri https://example.org/la/ \
  --model-id my-system \
  --format TRIG \
  -o out.trig

# Turtle output (flat)
java -jar structurizr2linkedarchi.jar convert \
  workspace.json \
  --base-iri https://example.org/la/ \
  --model-id my-system \
  --format TURTLE \
  -o out.ttl

# With direct relationship triples
java -jar structurizr2linkedarchi.jar convert \
  workspace.json \
  --base-iri https://example.org/la/ \
  --model-id my-system \
  --format TRIG \
  --emit-direct-rel-triples \
  -o out.trig

CLI options

Option Default Description
<inputs> required Structurizr workspace JSON file(s)
-o, --output required Output artifact, path[:FORMAT[:PROFILE]]. Repeatable — several artifacts are written from one conversion, which is faster than re-running the converter and is what makes them share one prov:generatedAtTime. PROFILE is full (default), no-geometry, no-views or no-diagrams; see output serialization
--format inferred Default format for any --output that names none: TRIG / TURTLE / JSONLD / RDFXML / NTRIPLES / NQUADS. A file extension such as ttl is also understood
--base-iri required Base IRI for minting resource IRIs
--model-id filename Model identifier for a single input; giving it with several inputs is refused. There is no diagram index here — one workspace is one whole model — so this or the filename is the only source, and neither is validated. Pass a slug explicitly for anything published; see where a model ID comes from
--type-mapping none YAML type overrides
--include-views true Emit views graph
--emit-direct-rel-triples false Emit {src} c4:uses {tgt} shortcuts, each bridged back to its relationship with rdf:reifies. The qualified predicate is emitted either way
--emit-skos-labels true Emit skos:prefLabel
--label-language en BCP-47 tag for skos:prefLabel literals
--emit-skos-notation true Emit skos:notation
--ns-vocab none Base IRI for properties keys with no published term. Without it they still arrive, as schema:additionalProperty pairs rather than predicates
--ns-global-id none Base IRI for the cross-model graph, resolving a property keyed @id into owl:sameAs
--iri-from-dsl-identifier false Mint element IRIs from structurizr.dsl.identifier. Stable across re-exports, but moves every affected IRI — an identity change, so re-convert rather than flipping it on a published graph

Input format

Structurizr workspace JSON (exported from Structurizr CLI, Structurizr Lite, or the cloud service):

{
  "name": "My System",
  "documentation": {
    "sections": [{ "content": "# Overview", "format": "Markdown", "order": "1" }],
    "decisions": [{ "id": "1", "title": "Use PostgreSQL", "status": "Accepted",
                    "date": "2026-03-04T00:00:00Z", "content": "## Context…" }]
  },
  "model": {
    "people": [{ "id": "1", "name": "User", ... }],
    "softwareSystems": [{
      "id": "2", "name": "Platform",
      "url": "https://wiki.example.org/systems/platform",
      "properties": { "architect": "Lead Architect" },
      "perspectives": [{ "name": "Security", "description": "PCI scope", "value": "High" }],
      "containers": [{
        "id": "3", "name": "API",
        "technology": "Spring Boot",
        "components": [{ "id": "4", "name": "Auth Controller" }]
      }]
    }],
    "deploymentNodes": [{
      "id": "20", "name": "AWS", "environment": "Production",
      "children": [{
        "id": "21", "name": "eu-west-1", "instances": "3",
        "infrastructureNodes": [{ "id": "22", "name": "Edge Router" }],
        "containerInstances": [{ "id": "23", "containerId": "3" }]
      }]
    }],
    "customElements": [
      { "id": "30", "name": "Amazon S3", "metadata": "Infrastructure" }
    ]
  },
  "views": {
    "systemContextViews": [...],
    "containerViews": [...],
    "componentViews": [...],
    "deploymentViews": [...],
    "customViews": [...]
  }
}

The deployment tree is walked in full — children, infrastructureNodes and containerInstances at every depth.

Output example

@prefix c4: <https://meta.linked.archi/c4/onto#> .
@prefix arch: <https://meta.linked.archi/core#> .

<.../graph/semantic> {
    <.../element/1> a c4:Person, arch:Element, arch:ModelConcept ;
        arch:inModel <.../structurizr/platform> ;
        skos:prefLabel "User"@en ;
        skos:notation "1" .

    <.../element/2> a c4:SoftwareSystem, arch:Element, arch:ModelConcept ;
        arch:inModel <.../structurizr/platform> ;
        skos:prefLabel "Platform"@en ;
        c4:hasContainer <.../element/3> .

    <.../element/3> a c4:Container, arch:Element, arch:ModelConcept ;
        arch:inModel <.../structurizr/platform> ;
        skos:prefLabel "API"@en ;
        c4:technology "Spring Boot" ;
        c4:hasComponent <.../element/4> .

    <.../element/4> a c4:Component, arch:Element, arch:ModelConcept ;
        arch:inModel <.../structurizr/platform> ;
        skos:prefLabel "Auth Controller"@en .

    <.../relationship/10> a c4:Using, arch:QualifiedRelationship, arch:ModelConcept ;
        arch:inModel <.../structurizr/platform> ;
        arch:source <.../element/1> ;
        arch:target <.../element/2> ;
        skos:prefLabel "Uses"@en ;
        c4:technology "HTTPS" .
}

# The curated model, and the folders holding the ids above.
<.../graph/model> {
    <.../structurizr/platform> a arch:Model ;
        arch:modelConformsToMetamodel <https://meta.linked.archi/c4/metamodel#C4> .
    <.../element/1> dct:isPartOf <.../structurizr/platform/folder/Elements> .
}

Architecture

Structurizr workspace JSON
  → StructurizrParser (Gson)
  → StructurizrModel (C4Element, C4Relationship, C4View)
  → LinkedArchiEmitter (extends BaseLinkedArchiEmitter)
  → TriG / Turtle output

StructurizrVocabulary holds both published namespaces (c4: and structurizr:) and the rules for the property keys that have no published term.

How to get a workspace JSON

# From Structurizr CLI (DSL → JSON)
structurizr-cli export -workspace workspace.dsl -format json -output .

# From Structurizr Lite
# → workspace.json is auto-generated in the data directory

# From Structurizr Cloud API
curl -H "X-Authorization: ..." https://api.structurizr.com/workspace/{id} > workspace.json

Validate

Runs SHACL validation on converted output. Shapes and ontologies are fetched from meta.linked.archi at runtime.

Default shapes are c4-shapes, structurizr-shapes and core-shapes. This converter types its output in C4, so C4's shapes are the ones that apply, including its naming rule (c4sh:C4ElementLabelShape). structurizr-shapes covers what C4 itself does not define — structurizr:Deployment and structurizr:DeploymentNode — and is published under c4/structurizr-shapes, since the shapes belong to the C4 namespace family; only the short name says Structurizr. Core covers the relationship-endpoint contract.

The c4, structurizr, arch-decision and core ontologies are loaded for rdfs:subClassOf reasoning. Two of them matter for specific reasons:

  • structurizr — structsh:StructurizrElementLabelShape targets structurizr:DeploymentNode and reaches structurizr:InfrastructureNode only through the subclass axiom that ontology declares.
  • arch-decision — it declares ad:Decision rdfs:subClassOf arch:Element and types the five ad:DecisionState individuals, so without it an ADR from !adrs is unreachable from anything targeting arch:Element and its state is an untyped IRI.

Switch both naming rules off for a run with --without-shape labels.

java -jar structurizr2linkedarchi.jar validate -i out.trig

# A workspace that carries ADRs: add the decision shapes, which are opt-in everywhere
java -jar structurizr2linkedarchi.jar validate -i out.trig --shapes arch-decision-shapes

# Validate against your own shapes instead
java -jar structurizr2linkedarchi.jar validate -i out.trig --shapes ./shapes/c4-rules.ttl

# Offline, reusing documents fetched by an earlier run
java -jar structurizr2linkedarchi.jar validate -i out.trig --asset-dir .assets --offline

arch-decision-shapes is deliberately not in the default set: most workspaces hold no decision records, and downloading constraints that target classes nothing instantiates validates nothing. Add it for a workspace whose !adrs block is populated — output from this converter conforms to the whole set, verified against arch-decision-shapes 0.2.0.

Exits 0 when the data conforms, 1 when violations are found, 2 on error.

See Validation for the shared engine, all options, coverage reporting and report format.