Adding a New Converter¶
This guide explains how to build a new Linked.Archi converter — a tool that reads a source modelling notation and produces an RDF dataset aligned to the Linked.Archi foundational ontology.
What a converter does¶
A converter takes a source model file and produces a TriG RDF dataset with:
- Semantic graph — elements, relationships, and views typed against both
the notation ontology and the
arch:foundational ontology - Views graph — visual scaffolding (
archvis:ArchNode,archvis:Link, geometry) for diagram rendering byrdf2docs - Provenance graph — conversion metadata (timestamp, source file, tool name)
- Folder structure —
arch:Folderhierarchy for navigation inrdf2docs
The output must be consumable by rdf2docs without any notation-specific code in
the generator. This is the key design constraint: the generator is model-agnostic.
Project setup¶
1. Create the module¶
converter-{notation}/
├── build.gradle.kts
└── src/
├── main/kotlin/archi/linked/converter/{notation}/
│ ├── Main.kt
│ ├── ConvertCommand.kt
│ ├── {Notation}Parser.kt
│ ├── {Notation}Model.kt
│ └── LinkedArchiEmitter.kt
└── test/kotlin/archi/linked/converter/{notation}/
└── {Notation}ConverterTest.kt
2. Add to settings.gradle.kts¶
include(
"core",
"svg-core",
"converter-archimate",
"converter-bpmn",
"converter-plantuml",
"converter-{notation}" // ← add this
)
3. build.gradle.kts¶
plugins {
kotlin("jvm")
application
id("com.gradleup.shadow")
}
kotlin { jvmToolchain(25) }
application {
mainClass.set("archi.linked.converter.{notation}.MainKt")
}
dependencies {
implementation(project(":core"))
implementation("info.picocli:picocli:${property("picocliVersion")}")
// Add notation-specific dependencies here (e.g. XML parser, format library)
runtimeOnly("ch.qos.logback:logback-classic:${property("logbackVersion")}")
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter:${property("junitVersion")}")
}
tasks.test { useJUnitPlatform() }
tasks.shadowJar {
archiveBaseName.set("{notation}2linkedarchi")
archiveClassifier.set("")
archiveVersion.set("")
mergeServiceFiles()
}
tasks.build { dependsOn(tasks.shadowJar) }
Foundational ontology contract¶
Every converter MUST emit these types and predicates. They are the interface between converters and consumers.
Required namespaces¶
| Prefix | IRI | Purpose |
|---|---|---|
arch: |
https://meta.linked.archi/core# |
Foundational types and predicates |
archvis: |
https://meta.linked.archi/core-vis# |
View/diagram ontology |
skos: |
http://www.w3.org/2004/02/skos/core# |
Labels and notations |
dct: |
http://purl.org/dc/terms/ |
isPartOf, created, source |
schema: |
https://schema.org/ |
Folder membership |
rdf: |
http://www.w3.org/1999/02/22-rdf-syntax-ns# |
Types, lists |
Required triples per concept¶
Element:
<element/{id}>
a arch:Element, <domain-type> ;
skos:notation "{id}" ;
skos:prefLabel "{name}"@en ;
dct:isPartOf <{notation}/{model-id}/folder/Elements> .
Relationship:
<relationship/{id}>
a arch:QualifiedRelationship, <domain-type> ;
arch:source <element/{source-id}> ;
arch:target <element/{target-id}> ;
skos:notation "{id}" ;
dct:isPartOf <folder/relationships> .
# Required. The way *in* to the relationship — see below.
<element/{source-id}> <qualified-predicate> <relationship/{id}> .
The relationship must be reachable from its source¶
arch:source and arch:target point out of a relationship. A resource with only those is
reachable from nowhere: answering "which relationships leave this element" means scanning every
arch:source in the graph, and everything the resource carries — its class, its labels, its
lifecycle, its provenance — is only as reachable as the resource is.
So a qualified predicate from the source element to the relationship is not optional, and is not gated behind a flag. Resolve it in this order:
- a
qualifiedPredicates:entry in the--type-mapping, if the author gave one - the qualified form your notation's ontology publishes, if it publishes one —
c4:qualifiedUses,bs:qualifiedOwnedBy,am:qualifiedServes arch:hasQualifiedRelationship, which core declares as the fallback for exactly this case
Step 3 means there is never a reason to emit nothing. Note its declared domain is
arch:ModelConcept, so gate it on whatever flag decides that the source is typed as one.
Never invent a qualified predicate. If the ontology publishes none, use step 3 rather than
minting a plausible name in a namespace you do not own. And if a --type-mapping has retyped the
relationship's class, do not fall back to step 2: the published predicate declares rdfs:range
against the class the retyping replaced, and because rdfs:range is an entailment a reasoner would
put that class straight back. Go to step 3 and warn.
The direct triple, and its bridge¶
--emit-direct-rel-triples adds the shortcut {src} {pred} {tgt}. When you emit it, also emit the
RDF 1.2 bridge back to the resource, or the shortcut and the resource are two unrelated statements
and nothing at instance level says which predicate the resource stands for:
mb.add(srcIri, pred, tgtIri)
mb.add(relIri, vocab.rdfReifies, vf.createTripleTerm(srcIri, pred, tgtIri))
vf.createTripleTerm matters: hand-building an IRI for this produces RDF4J's opaque
urn:rdf4j:triple:… form, which serialises without complaint and no other tool can read.
The schema-level counterpart is arch:unqualifiedForm, declared on the QualifiedRelationship
subclass in your notation's ontology. It is schema-level only — core says so explicitly — so
never write it onto instance data. rdf:reifies is the instance-level answer.
Both invariants are asserted for every converter by qualified-relations.feature; see
Testing.
View (diagram):
<view/{id}>
a arch:Diagram, <notation-view-type> ;
skos:notation "{id}" ;
skos:prefLabel "{name}"@en ;
dct:isPartOf <folder/views> .
View node:
<view/{view-id}/node/{node-id}>
a archvis:ArchNode ;
archvis:view <view/{view-id}> ;
archvis:archElement <element/{elem-id}> ;
archvis:bounds-x {x} ; archvis:bounds-y {y} ;
archvis:bounds-w {w} ; archvis:bounds-h {h} .
View link:
<view/{view-id}/link/{link-id}>
a archvis:Link ;
archvis:view <view/{view-id}> ;
archvis:archRelationship <relationship/{rel-id}> ;
archvis:source <view/{view-id}/node/{src-node-id}> ;
archvis:target <view/{view-id}/node/{tgt-node-id}> ;
archvis:points ( [ archvis:point-x {x} ; archvis:point-y {y} ] ... ) .
Folder:
<folder/{path}>
a arch:Folder ;
schema:name "{display-name}" ;
dct:isPartOf <folder/{parent-path}> ;
schema:itemListElement
[ a schema:ListItem ; schema:position 1 ; schema:item <element/{id}> ] .
IRI minting strategy¶
All IRIs follow the pattern:
| Segment | Used for |
|---|---|
element/ |
Semantic elements |
relationship/ |
Semantic relationships |
view/ |
Views / diagrams |
view/{view-id}/node/ |
View nodes |
view/{view-id}/link/ |
View links |
graph/semantic |
Named graph for facts lifted from an input |
graph/model |
Named graph for the curated model: arch:Model, conformance, folders, ordering, membership |
graph/views |
Named graph for view triples |
graph/provenance |
Named graph for provenance triples |
IRI stability rules¶
- Use the source tool's stable identifier (e.g.
idattribute, GUID) - Never use display names or labels in IRIs — they change
- For anonymous elements (no id), derive from parent:
{parentIri}/{localName} - Add a counter suffix for duplicates:
{parentIri}/{localName}_2 - Last resort only:
{base}#_gen_{uuid}— avoid this
Using core.IriMinting¶
val mint = IriMinting(vf, baseIri, "mynotation", modelId)
mint.modelIri() // {base}mynotation/{modelId}
mint.semanticGraphIri() // {base}mynotation/{modelId}/graph/semantic
mint.modelGraphIri() // {base}mynotation/{modelId}/graph/model
mint.viewsGraphIri() // {base}mynotation/{modelId}/graph/views
mint.provenanceGraphIri() // {base}mynotation/{modelId}/graph/provenance
mint.elementIri("elem-1") // {base}mynotation/{modelId}/element/elem-1
mint.relationshipIri("rel-1") // {base}mynotation/{modelId}/relationship/rel-1
mint.viewIri("view-1") // {base}mynotation/{modelId}/view/view-1
mint.viewNodeIri("view-1", "n-1") // {base}mynotation/{modelId}/view/view-1/node/n-1
Code structure¶
Parser¶
Reads the source format and produces an intermediate model (plain Kotlin data classes, not RDF):
data class MyElement(val id: String, val name: String, val type: String)
data class MyRelationship(val id: String, val sourceId: String, val targetId: String, val type: String)
data class MyView(val id: String, val title: String?, val nodeIds: List<String>, val linkIds: List<String>)
data class MyModel(val elements: List<MyElement>, val relationships: List<MyRelationship>, val views: List<MyView>, val sourceFile: String)
class MyNotationParser {
fun parse(file: File, modelId: String): MyModel { /* ... */ }
}
Emitter (extends BaseLinkedArchiEmitter)¶
class LinkedArchiEmitter : BaseLinkedArchiEmitter("mynotation") {
fun emit(model: MyModel, opts: MyOptions): Model {
resetState()
val mint = IriMinting(vf, opts.baseIri, notationSlug, opts.modelId)
val vocab = LinkedArchiVocab(vf, opts.nsCore, opts.nsCoreVis)
val modelIri = mint.modelIri()
// Facts lifted from the input …
val gSemantic = mint.semanticGraphIri()
// … and the model the conversion curated around them. Keeping the two apart is what lets a
// lifted fact be attributed to the file it came from. See §4.
val gModel = mint.modelGraphIri()
val gViews = mint.viewsGraphIri()
val gProv = mint.provenanceGraphIri()
val mb = ModelBuilder()
registerNamespaces(mb, coreOpts)
mb.setNamespace("myns", opts.nsNotation)
// ── Model ──
mb.namedGraph(gModel).subject(modelIri)
.add(RDF.TYPE, vocab.model)
.add(vocab.modelConformsToMetamodel, vf.createIRI(Conformance.Metamodels.MY_NOTATION))
// ── Folders ── curation, so the model graph
val folders = emitStandardFolders(mb, gModel, vocab, mint, opts.modelId)
// ── Elements ──
for (elem in model.elements) {
val iri = mint.elementIri(elem.id)
val typeIri = vf.createIRI(resolveType(elem.type, opts))
mb.namedGraph(gSemantic).subject(iri)
.add(RDF.TYPE, vocab.element)
.add(RDF.TYPE, vocab.modelConcept)
// Membership as a statement, in the concept's own graph. Without it the model is
// reassemblable only from the graph boundary, which Turtle does not have.
.add(vocab.inModel, modelIri)
.add(RDF.TYPE, typeIri)
if (opts.emitSkosNotation) mb.namedGraph(gSemantic).subject(iri)
.add(SKOS.NOTATION, vf.createLiteral(elem.id))
if (opts.emitSkosLabels) mb.namedGraph(gSemantic).subject(iri)
.add(SKOS.PREF_LABEL, labelLiteral(elem.name, opts.labelLanguage))
// Both directions of the folder containment, in the graph that defines the folders. Do not
// add a dct:isPartOf beside the triples above: that puts one relation in two graphs.
emitFolderMember(mb, gModel, vocab, folders.elements, iri)
}
// ── Relationships ──
for (rel in model.relationships) {
val iri = mint.relationshipIri(rel.id)
val srcIri = mint.elementIri(rel.sourceId)
val tgtIri = mint.elementIri(rel.targetId)
mb.namedGraph(gSemantic).subject(iri)
.add(RDF.TYPE, vocab.qualifiedRelationship)
.add(RDF.TYPE, vocab.modelConcept)
.add(vocab.inModel, modelIri)
.add(vocab.source, srcIri)
.add(vocab.target, tgtIri)
// The way in. Ungated: the triples above point only outward, so without this the
// relationship resource is reachable from nowhere. `terms.qualifiedUses` stands for
// whatever qualified form your ontology publishes; drop to the core fallback where it
// publishes none, rather than inventing a name.
val qualPred = opts.typeMapping.qualifiedPredicateIri(rel.type)?.let { vf.createIRI(it) }
?: terms.qualifiedUses
?: vocab.hasQualifiedRelationship
mb.namedGraph(gSemantic).subject(srcIri).add(qualPred, iri)
// The shortcut, and the RDF 1.2 bridge that ties it back to the resource.
if (opts.emitDirectRelTriples) {
val pred = opts.typeMapping.predicateIri(rel.type)?.let { vf.createIRI(it) } ?: terms.uses
mb.namedGraph(gSemantic).subject(srcIri).add(pred, tgtIri)
mb.namedGraph(gSemantic).subject(iri)
.add(vocab.rdfReifies, vf.createTripleTerm(srcIri, pred, tgtIri))
}
emitFolderMember(mb, gModel, vocab, folders.relationships, iri)
}
// ── Views ──
if (opts.includeViews) {
for (view in model.views) {
val viewIri = mint.viewIri(view.id)
// A view is a model concept, so the view resource is semantic; only its geometry is not.
mb.namedGraph(gSemantic).subject(viewIri)
.add(RDF.TYPE, vocab.diagram)
.add(RDF.TYPE, vocab.modelConcept)
.add(vocab.inModel, modelIri)
emitFolderMember(mb, gModel, vocab, folders.views, viewIri)
// View nodes
for (nodeId in view.nodeIds) {
val nodeIri = mint.viewNodeIri(view.id, nodeId)
mb.namedGraph(gViews).subject(nodeIri)
.add(RDF.TYPE, vocab.archNode)
.add(vocab.archVisView, viewIri)
.add(vocab.archElement, mint.elementIri(nodeId))
}
}
}
// ── Provenance ──
// The graph lists describe each graph this call wrote as a prov:Bundle, saying what generated
// it and from what. `curatedGraphs` is derived from the diagram index, `liftedGraphs` from the
// inputs this call describes.
emitProvenance(
mb, gProv, opts.baseIri, modelIri, vocab, opts.inputFile,
runProvenance = opts.runProvenance,
liftedGraphs = listOf(gSemantic, gViews),
curatedGraphs = listOf(gModel),
)
return mb.build()
}
}
ConvertCommand¶
@Command(name = "convert", description = ["Convert {notation} to Linked.Archi RDF."])
class ConvertCommand : Runnable {
@Parameters(arity = "1..*") lateinit var inputs: List<File>
@Option(names = ["-o", "--output"], required = true) lateinit var output: File
@Option(names = ["--base-iri"], required = true) lateinit var baseIri: String
@Option(names = ["--model-id"]) var modelId: String? = null
@Option(names = ["--format"], required = true) lateinit var format: String
@Option(names = ["--type-mapping"]) var typeMappingFile: File? = null
override fun run() {
val parser = MyNotationParser()
val emitter = LinkedArchiEmitter()
val typeMapping = TypeMapping.loadOrEmpty(typeMappingFile)
val fmt = OutputFormat.fromString(format) ?: error("Unknown format: $format")
val merged = LinkedHashModel()
for (input in inputs) {
val mid = modelId ?: input.nameWithoutExtension
val model = parser.parse(input, mid)
val opts = MyOptions(input, baseIri, mid, typeMapping)
val rdf = emitter.emit(model, opts)
rdf.namespaces.forEach { merged.setNamespace(it) }
merged.addAll(rdf)
}
RdfIo.write(merged, output, fmt)
}
}
View rendering contract¶
For rdf2docs to render an SVG diagram from the views graph:
- The view IRI has
a arch:Diagram - Each node has
archvis:view <viewIri>andarchvis:bounds-x/y/w/h - Each link has
archvis:view <viewIri>,archvis:source,archvis:target - Bendpoints are a proper RDF list on
archvis:points:
// Build RDF list tail-first
var tail: Value = RDF.NIL
for (point in points.reversed()) {
val cell = vf.createBNode()
val pointNode = vf.createBNode()
mb.namedGraph(gViews).subject(pointNode)
.add(vocab.pointX, vf.createLiteral(point.x))
.add(vocab.pointY, vf.createLiteral(point.y))
mb.namedGraph(gViews).subject(cell)
.add(RDF.FIRST, pointNode)
.add(RDF.REST, tail)
tail = cell
}
mb.namedGraph(gViews).subject(linkIri).add(vocab.points, tail)
When the element IS the view¶
Some notations (BPMN Process, UML Package) are both semantic elements and
diagram containers. In this case:
- Use the element IRI as the view IRI (do not mint a separate
view/IRI) - Emit
arch:Diagramon the element IRI - Point all
archvis:viewtriples at the element IRI
Folder structure¶
The folder structure drives the rdf2docs sidebar.
Minimum required folders¶
| Folder | Contents |
|---|---|
{model-id} |
Root folder (model name) |
{model-id}/Elements |
All elements |
{model-id}/Relationships |
All relationships |
{model-id}/Views |
All views / diagrams |
Use BaseLinkedArchiEmitter.emitStandardFolders() to create these automatically.
Ordered membership¶
Every element/relationship must have dct:isPartOf pointing to its folder.
Use emitFolderMember() to add the ordered schema:itemListElement entry.
Type mapping support¶
Every converter should support --type-mapping for domain ontology overrides:
// Resolution priority:
// 1. Explicit YAML entry (user override)
// 2. Built-in defaults (e.g. UmlTypeDefaults for PlantUML)
// 3. Fallback: {notationNamespace}{CapitalizedTypeName}
val typeIri = opts.typeMapping.elementClassIriOrNull(sourceType)
?: builtInDefaults.resolve(sourceType, fallbackNs)
Look a mapping up by the name the source uses first, then by any canonical name you resolved from
it, and apply the same rule to elements:, relationships:, predicates: and
qualifiedPredicates:. Resolving them differently is a quiet failure: LeanIX matched
relationships: on both and its two predicate sections on only the first, so a mapping keyed by the
canonical name retyped an edge while its predicate entries were ignored.
A relationships: entry replaces a class rather than adding to it, which invalidates any
predicate declared against the class it replaced — see
Type mapping.
Implementation checklist¶
Parser¶
- [ ] Reads source format (XML, JSON, YAML, etc.)
- [ ] Extracts elements with stable IDs, types, names
- [ ] Extracts relationships with source/target IDs
- [ ] Extracts views with node/link geometry (if notation has diagrams)
- [ ] Handles anonymous elements (no ID) with parent-derived IRIs
Emitter¶
- [ ] Extends
BaseLinkedArchiEmitter - [ ] Emits
arch:Elementon all elements - [ ] Emits
arch:QualifiedRelationshipon all relationships - [ ] Emits
arch:ModelConcepton all elements and relationships - [ ] Emits
arch:source/arch:targeton all relationships - [ ] Emits a qualified predicate from source element to relationship on all relationships,
ungated, falling back to
arch:hasQualifiedRelationship - [ ] Emits
rdf:reifieswith acreateTripleTermobject wherever it emits a direct triple - [ ] Emits no
arch:unqualifiedFormon instance data — it is schema-level only - [ ] Emits
arch:Diagramon all views - [ ] Emits
skos:prefLabelfor names, language-tagged vialabelLiteral() - [ ] Emits
skos:notationfor IDs - [ ] Emits
dct:isPartOfon all elements/relationships/views - [ ] Emits
arch:Folderhierarchy viaemitStandardFolders() - [ ] Emits
archvis:ArchNodewitharchvis:viewand bounds - [ ] Emits
archvis:Linkwitharchvis:view,source,target - [ ] Emits
archvis:pointsas a proper RDF list for bendpoints - [ ] Emits provenance via
emitProvenance() - [ ] Supports
--type-mappingYAML overrides
CLI¶
- [ ]
--base-iri(shared graph root) - [ ]
-o / --output(output file path) - [ ]
--format(TRIG / TURTLE / JSONLD / RDFXML / NTRIPLES / NQUADS) - [ ]
--model-id(stable model identifier) - [ ]
--ns-core(overridearch:namespace) - [ ]
--ns-core-vis(overridearchvis:namespace) - [ ]
--type-mapping(domain type YAML) - [ ]
--include-views(enable view graph emission) - [ ]
--emit-skos-labels/--emit-skos-notation - [ ]
--emit-direct-rel-triples(shortcut triples)
Testing¶
- [ ] Parser unit test (element/relationship extraction from sample)
- [ ] Emitter unit test (correct RDF triples for known input)
- [ ] A
qualified-relations.featurescenario for the new notation, using the shared stepsevery relationship is reachable from its sourceandevery direct triple is bridged to its relationship - [ ]
ConversionVerifier.verify()passes on output - [ ] No
_gen_IRIs in output - [ ] Entity counts match input
Testing¶
@Test
fun `output passes all verification checks`() {
val model = parser.parse(sampleFile, "test")
val rdf = emitter.emit(model, opts)
val result = ConversionVerifier.verify(rdf)
assertTrue(result.passed, "Issues: ${result.issues}")
}
@Test
fun `entity counts match input`() {
val model = parser.parse(sampleFile, "test")
val rdf = emitter.emit(model, opts)
val counts = ConversionVerifier.countEntities(rdf, nsCore, nsCoreVis)
assertEquals(model.elements.size, counts.elements)
assertEquals(model.relationships.size, counts.relationships)
}
Design decisions¶
Why arch:Diagram not arch:View?¶
arch:Diagram is a subtype of arch:View and implies visual rendering with
geometry. Using arch:Diagram lets rdf2docs trigger SVG rendering for any
resource typed as arch:Diagram regardless of notation.
Why dual typing?¶
- Notation type (
bpmn:UserTask): notation-specific queries - Foundational type (
arch:Element): cross-notation queries
A SPARQL query SELECT ?e WHERE { ?e a arch:Element } returns elements from ALL
notations without knowing anything about BPMN, ArchiMate, or UML.
Why RDF lists for bendpoints?¶
The SPARQL property path archvis:points/rdf:rest*/rdf:first only works with
proper RDF lists. Build them tail-first as shown above.
Reference implementations¶
| Complexity | Converter | Look at |
|---|---|---|
| Simple | PlantUML | LinkedArchiEmitter.kt — extends BaseLinkedArchiEmitter, uses UmlTypeDefaults |
| Medium | ArchiMate | ExchangeParser.kt + LinkedArchiEmitter.kt — StAX, folder tree, properties |
| Complex | BPMN | BpmnXmlToRdfConverter.kt + MetaModel.kt — CMOF-driven, IRI remapping, DI geometry |