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:Deploymentconstrains its source toc4: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 — seetodo/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:
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:constrainsad:decisionStatewithsh:inover the five individuals, so mintingad:UnderReviewwould turn correct input into a validation failure. The token is kept ondct:typeand 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:StructurizrElementLabelShapetargetsstructurizr:DeploymentNodeand reachesstructurizr:InfrastructureNodeonly through the subclass axiom that ontology declares.arch-decision— it declaresad:Decision rdfs:subClassOf arch:Elementand types the fivead:DecisionStateindividuals, so without it an ADR from!adrsis unreachable from anything targetingarch:Elementand 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.