Skip to content

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:

  1. Semantic graph — elements, relationships, and views typed against both the notation ontology and the arch: foundational ontology
  2. Views graph — visual scaffolding (archvis:ArchNode, archvis:Link, geometry) for diagram rendering by rdf2docs
  3. Provenance graph — conversion metadata (timestamp, source file, tool name)
  4. Folder structure — arch:Folder hierarchy for navigation in rdf2docs

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:

  1. a qualifiedPredicates: entry in the --type-mapping, if the author gave one
  2. the qualified form your notation's ontology publishes, if it publishes one — c4:qualifiedUses, bs:qualifiedOwnedBy, am:qualifiedServes
  3. 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:

{base-iri}{notation}/{model-id}/{segment}/{local-id}
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. id attribute, 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:

  1. The view IRI has a arch:Diagram
  2. Each node has archvis:view <viewIri> and archvis:bounds-x/y/w/h
  3. Each link has archvis:view <viewIri>, archvis:source, archvis:target
  4. 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:Diagram on the element IRI
  • Point all archvis:view triples 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:Element on all elements
  • [ ] Emits arch:QualifiedRelationship on all relationships
  • [ ] Emits arch:ModelConcept on all elements and relationships
  • [ ] Emits arch:source / arch:target on all relationships
  • [ ] Emits a qualified predicate from source element to relationship on all relationships, ungated, falling back to arch:hasQualifiedRelationship
  • [ ] Emits rdf:reifies with a createTripleTerm object wherever it emits a direct triple
  • [ ] Emits no arch:unqualifiedForm on instance data — it is schema-level only
  • [ ] Emits arch:Diagram on all views
  • [ ] Emits skos:prefLabel for names, language-tagged via labelLiteral()
  • [ ] Emits skos:notation for IDs
  • [ ] Emits dct:isPartOf on all elements/relationships/views
  • [ ] Emits arch:Folder hierarchy via emitStandardFolders()
  • [ ] Emits archvis:ArchNode with archvis:view and bounds
  • [ ] Emits archvis:Link with archvis:view, source, target
  • [ ] Emits archvis:points as a proper RDF list for bendpoints
  • [ ] Emits provenance via emitProvenance()
  • [ ] Supports --type-mapping YAML 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 (override arch: namespace)
  • [ ] --ns-core-vis (override archvis: 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.feature scenario for the new notation, using the shared steps every relationship is reachable from its source and every 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