Ontology-Driven Code Generation

Kastor Gen supports generating domain interfaces and wrapper implementations directly from SHACL shapes and JSON-LD context files. This approach eliminates the need for manual interface definitions and ensures consistency between your ontology and code.

Overview

Instead of manually writing domain interfaces like this:

@Rdf(iri = "http://www.w3.org/ns/dcat#Catalog")
interface Catalog {
    @Rdf(iri = "http://purl.org/dc/terms/title")
    val title: String
    
    @Rdf(iri = "http://purl.org/dc/terms/description")
    val description: String
    
    @Rdf(iri = "http://www.w3.org/ns/dcat#dataset")
    val dataset: List<Dataset>
}

You can generate them automatically from SHACL shapes and JSON-LD context files.

SHACL Shapes

SHACL (Shapes Constraint Language) defines the structure and constraints for your RDF data. Here’s an example for DCAT:

@prefix dcat: <http://www.w3.org/ns/dcat#> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

<http://example.org/shapes/Catalog>
    a sh:NodeShape ;
    sh:targetClass dcat:Catalog ;
    sh:property [
        sh:path dcterms:title ;
        sh:name "title" ;
        sh:description "A name given to the catalog." ;
        sh:datatype xsd:string ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
    ] ;
    sh:property [
        sh:path dcterms:description ;
        sh:name "description" ;
        sh:description "A free-text account of the catalog." ;
        sh:datatype xsd:string ;
        sh:minCount 0 ;
        sh:maxCount 1 ;
    ] ;
    sh:property [
        sh:path dcat:dataset ;
        sh:name "dataset" ;
        sh:description "A collection of data that is listed in the catalog." ;
        sh:class dcat:Dataset ;
        sh:minCount 0 ;
    ] .

JSON-LD Context

JSON-LD context provides type mappings and property definitions:

{
  "@context": {
    "dcat": "http://www.w3.org/ns/dcat#",
    "dcterms": "http://purl.org/dc/terms/",
    
    "Catalog": "dcat:Catalog",
    "Dataset": "dcat:Dataset",
    
    "title": {
      "@id": "dcterms:title",
      "@type": "xsd:string"
    },
    "description": {
      "@id": "dcterms:description",
      "@type": "xsd:string"
    },
    "dataset": {
      "@id": "dcat:dataset",
      "@type": "@id"
    }
  }
}

What a context may contain

The context file is read as a JSON-LD 1.1 context definition:

  • a context object, or an array of context objects (applied in order; null resets);
  • @base and @vocab; @version, @language, @direction, @protected and @propagate are accepted and have no effect on generation;
  • keyword aliases - "id": "@id", "type": "@type", "type": {"@id": "@type", "@container": "@set"} - are recorded as aliases (JsonLdContext.keywordAliases); they are neither types nor properties;
  • simple terms ("Person": "foaf:Person"), prefixes (a simple term whose IRI ends with # or /, or an expanded definition with "@prefix": true), and expanded term definitions with @id (optional: a term without one is resolved against @vocab, a compact IRI against its prefix), @reverse, @type (@id, @vocab, @json, @none or a datatype IRI) and @container (a keyword, or an array such as ["@set", "@language"]);
  • a scoped @context in a term definition, resolved against the context that declares it (JsonLdProperty.scopedContext);
  • terms may refer to terms and prefixes defined further down in the same context; "term": null removes a term.

Rejected, with a message that names the term: remote contexts (a context given as a URL) and @import - the processor does not download anything, put the term definitions in the context file -, unknown keywords in a term definition, @reverse together with @id, values of the wrong JSON type, compact IRIs with an undeclared prefix, cyclic definitions.

Code Generation

1. Create a generator class

Create a class annotated with @Rdf, pointing at SHACL (required) and JSON-LD context (recommended) under src/main/resources:

package com.example.mydomain.generated

import com.geoknoesis.kastor.gen.annotations.Rdf

@Rdf(
    shacl = "ontologies/my-ontology.shacl.ttl",
    context = "ontologies/my-ontology.context.jsonld",
    packageName = "com.example.mydomain.generated",
    generateInterfaces = true,
    generateWrappers = true,
)
class OntologyGenerator

You can instead put the same @Rdf(shacl = …, context = …, …) on a @file:Rdf annotation at the top of a Kotlin file if you prefer file-level configuration.

2. Generated Interfaces

The processor generates pure domain interfaces:

// GENERATED FILE - DO NOT EDIT
// Generated from SHACL shape: http://example.org/shapes/Catalog
package com.example.mydomain.generated

import com.geoknoesis.kastor.gen.annotations.Rdf

/**
 * Domain interface for http://www.w3.org/ns/dcat#Catalog
 * Generated from SHACL shape: http://example.org/shapes/Catalog
 */
@Rdf(iri = "http://www.w3.org/ns/dcat#Catalog")
interface Catalog {
    /**
     * A name given to the catalog.
     * Path: http://purl.org/dc/terms/title
     */
    @Rdf(iri = "http://purl.org/dc/terms/title")
    val title: String          // sh:minCount 1, sh:maxCount 1 -> non-null

    /**
     * A free-text account of the catalog.
     * Path: http://purl.org/dc/terms/description
     */
    @Rdf(iri = "http://purl.org/dc/terms/description")
    val description: String?   // sh:maxCount 1 without sh:minCount -> nullable

    /**
     * A collection of data that is listed in the catalog.
     * Path: http://www.w3.org/ns/dcat#dataset
     */
    @Rdf(iri = "http://www.w3.org/ns/dcat#dataset")
    val dataset: List<Dataset>
}

3. Generated Wrappers

The processor also generates RDF-backed wrapper implementations (simplified; the real output also contains a generated validate() when validationMode = EMBEDDED, and a writeToGraph helper):

// GENERATED FILE - DO NOT EDIT
// Generated from SHACL shape: http://example.org/shapes/Catalog
package com.example.mydomain.generated

import com.geoknoesis.kastor.gen.runtime.*
import com.geoknoesis.kastor.rdf.*

/**
 * RDF-backed wrapper for Catalog
 * Generated from SHACL shape: http://example.org/shapes/Catalog
 */
internal class CatalogWrapper private constructor(
  input: RdfHandle,
) : Catalog, RdfBacked {

  override val rdf: RdfHandle by lazy(LazyThreadSafetyMode.PUBLICATION) {
    if (input is DefaultRdfHandle) input.withKnownPredicates(KNOWN) else input
  }

  /**
   * A name given to the catalog.
   * Path: http://purl.org/dc/terms/title
   */
  override val title: String by lazy {
    KastorGraphOps.getRequiredLiteralValue(rdf.graph, rdf.node, Iri("http://purl.org/dc/terms/title")).lexical
  }

  /**
   * A free-text account of the catalog.
   * Path: http://purl.org/dc/terms/description
   */
  override val description: String? by lazy {
    KastorGraphOps.getLiteralValues(rdf.graph, rdf.node, Iri("http://purl.org/dc/terms/description"))
      .map { it.lexical }.firstOrNull()
  }

  /**
   * A collection of data that is listed in the catalog.
   * Path: http://www.w3.org/ns/dcat#dataset
   */
  override val dataset: List<Dataset> by lazy {
    KastorGraphOps.getObjectValues(rdf.graph, rdf.node, Iri("http://www.w3.org/ns/dcat#dataset")) { child ->
      OntoMapper.materialize(RdfRef(child, rdf.graph), Dataset::class.java)
    }
  }

  companion object {
    private val KNOWN: Set<Iri> = setOf(
      Iri("http://purl.org/dc/terms/description"),
      Iri("http://purl.org/dc/terms/title"),
      Iri("http://www.w3.org/ns/dcat#dataset"),
    )

    init {
      OntoMapper.register(Catalog::class.java) { handle -> CatalogWrapper(handle) }
    }
  }
}

Reading values

The same rules apply to every generated reader: SHACL live wrappers, SHACL data-class factories, and wrappers generated for hand-written @Rdf interfaces.

  • Missing required values always throw. Reading a required member without a value throws a MaterializationException with the message Required value missing for <member> <path>, followed by of shape <shapeIri> for SHACL-generated types. A member is required when it is:
    • a single-valued member with sh:minCount ≥ 1;
    • a list member with sh:minCount ≥ 1 that has no values (data-class factories used to return an empty list here);
    • a non-null member of a hand-written @Rdf interface.

    There is no lenient option for this. Missing values are never replaced by invented defaults ("", 0, false): an optional (nullable) member reads null, and an optional list reads empty.

  • Values that fail to decode follow MaterializationPolicy.illTypedValues. A value whose lexical form is not valid for the member’s type (e.g. "abc"^^xsd:integer read as a number), or whose datatype is not of the member’s XSD family ("42"^^xsd:string read as a number):
    • THROW (default): reading throws a MaterializationException naming the value, its datatype, the member and the expected type;
    • SKIP: the value is left out and a warning is logged.

    This covers list, nullable and non-null members alike, including read-only members and mutable getters of hand-written @Rdf wrappers (which used to skip such values silently). See the runtime reference.

  • Values of an unexpected term kind follow the same policy. A literal where an IRI, blank node or object is expected, or an IRI or blank node where a literal is expected, goes through MaterializationPolicy.unexpectedTerm: THROW (default) raises a MaterializationException naming the value, the member and the expected kind; SKIP leaves it out and logs a warning. Values are never dropped silently.
  • The rules combine under SKIP. If every value of a required member was skipped, the member counts as missing and reading it throws.

MaterializationPolicy.withIllTypedValues(handling) { … } overrides the handling for the current thread only; it does not propagate to other threads or coroutines (see the runtime reference).

Generated validation

With validationMode = EMBEDDED the wrapper’s validate() checks, for every path of the shape (inherited paths included): sh:minCount/sh:maxCount; sh:datatype (a literal of exactly that datatype with a well-formed lexical form); sh:nodeKind; sh:class (an rdf:type equal to the class or a transitive rdfs:subClassOf of it); sh:pattern; string lengths; sh:in; and the numeric bounds sh:minInclusive/sh:maxInclusive/sh:minExclusive/sh:maxExclusive, compared using both operands’ numeric datatypes, with a value that is not a well-formed numeric literal reported as a violation. Each sh:pattern regex is compiled lazily on first use, so an unusual pattern can never break class initialisation.

  • Constraints are a conjunction. When a shape restates a path that it also inherits (or declares two property shapes for one path), every constraint of every declaration applies: every typed bound is checked, every sh:pattern must match (each with its own sh:flags), every sh:hasValue must be among the values, and the sh:nodeKinds are intersected (sh:BlankNodeOrIRI and sh:IRI leave sh:IRI). Node kinds with nothing in common (sh:IRI and sh:Literal) reject every value, and generation logs a warning naming the shape and the path. Nothing is silently dropped.
  • sh:pattern means the same as in the Kastor SHACL validator. Patterns are XPath / XML Schema regular expressions, and generated code runs them with ShaclPattern (kastor-gen-runtime): the validator’s own translation, on its linear-time engine (RE2/J), so no value can make a pattern backtrack (see the SHACL validation guide). A match is charged to a budget of ShaclPattern.MAX_STEPS (200,000,000) steps, each character read costing the size of the compiled pattern, as in the validator; past it (.{0,1000}x on a value of a million characters) the check throws ShaclPatternBudgetException instead of accepting or rejecting the value: bound long values with sh:maxLength. $ matches at the very end only, \d is any Unicode digit, \w is [_\p{L}\p{M}\p{N}\p{S}], [a-z-[aeiou]] is a class subtraction, the i flag leaves \p{Lu} case-sensitive. A leading (?i), (?s), (?m) or (?x) is read as sh:flags; POSIX classes inside a class ([[:alpha:]_]) and script names (\p{IsLatin}) are understood. What the engine refuses is rejected when the code is generated, with an error that names the shape, the path and the pattern: back-references, lookarounds, atomic groups, possessive quantifiers, word boundaries (\b), repetition counts above 1000, && and a nested [ inside a class, inline flag groups that are not leading (a(?i)b, (?i:...)), unknown sh:flags. The Kastor SHACL validator refuses the same shapes.
  • sh:hasValue is checked when an instance is validated. It requires the value to be among the values of the property, so the instance DSL’s setters accept any value (tag("y") on a property with sh:hasValue "x"); the DSL’s validate() - run when an instance block ends - reports tag must have the value: x when x is missing. (The wrapper’s embedded validate() does not check sh:hasValue; use a ValidationContext for it.)

  • Cardinality counts every value. sh:minCount/sh:maxCount count all values of the path whatever their term kind (IRIs, blank nodes, literals), including literal sh:in enum values. Checking the kind is left to sh:datatype, sh:nodeKind and sh:class.
  • sh:in compares RDF terms. A value must equal a member as a term: the same IRI, or a literal with the same lexical form and the same language tag (ignoring case) or datatype. The member "chat"@en does not accept "chat"@fr or the plain string "chat", and the integer 5 does not accept the string "5". The parser keeps each member’s datatype and language tag (ShaclInValue), and both the wrapper’s validate() and the DSL’s validate() use them.
  • Inherited sh:in lists are intersected. When a subtype restates sh:in for an inherited path, a value must be a member of both lists. Members are compared as RDF terms, language tags ignoring case ("x"@en and "x"@EN are one member). When the lists have no member in common, every value of the path is rejected (the same holds for an explicit sh:in (), which the parser keeps as an empty list), and generation logs a warning naming the shape and the path; no check is silently dropped.
  • Numeric bounds preserve datatype semantics. Bounds retain their numeric values and RDF datatypes in the model. Wrapper validate(), instance-DSL setters and the DSL’s validate() compare integer and decimal values exactly when both operands are integer/decimal. Otherwise both operands are promoted to xsd:double if either is a double, or to xsd:float if either is a float. A float’s value is rounded before promotion: for example, 16777217 rounds to 16777216 as a float, but a double bound of 16777217 retains its value. NaN is never within bounds. Manually constructed decimal-only model bounds retain decimal semantics. Regenerate existing sources to include each bound’s datatype in the generated comparisons.
  • Shape parameters are honoured. A property shape’s sh:severity sets the generated ShaclViolation.severity (sh:Warning → Warning, sh:Info → Info, anything else → Violation), and its sh:message replaces the generated message. A node shape with sh:deactivated true validates nothing (its types are still generated); a property shape with sh:deactivated true contributes no constraints and does not make its member required, so the member is typed as optional. A subtype of a deactivated node shape keeps the inherited Kotlin signatures (a member the parent types as required or single-valued stays so even when the subtype restates the path without sh:minCount / sh:maxCount); only the parent’s validation constraints are dropped.

With validationMode = EXTERNAL the wrapper’s validate() uses the process-wide instance of externalValidatorClass from SharedValidators: one instance per validator class, created on first use and shared by every wrapper type that names that class. It lives until SharedValidators.close(type) or SharedValidators.closeAll() (see the validation reference).

Embedded validation covers the constraints above only; use a ValidationContext (JenaValidation/Rdf4jValidation) for full SHACL semantics.

Type Mapping

Literal properties are typed from sh:datatype; values are decoded with XsdLiterals (see the runtime reference). A value is decoded only when its datatype belongs to the member’s XSD family (any XSD integer type for Int/Long/BigInteger, any XSD numeric type for Float/Double, …); "42"^^xsd:string read as a number is ill-typed and follows MaterializationPolicy.illTypedValues:

SHACL datatype Kotlin type Notes
xsd:string, no datatype String the lexical form of any literal; a language tag is not part of the value (the generated replaceInGraph keeps it on unchanged values)
xsd:boolean Boolean accepts true/false and 1/0
xsd:int, xsd:short, xsd:byte, xsd:unsignedShort, xsd:unsignedByte Int values outside the datatype’s range ("300"^^xsd:unsignedByte) are ill-typed
xsd:long, xsd:unsignedInt Long  
xsd:integer, xsd:nonNegativeInteger, xsd:positiveInteger, xsd:nonPositiveInteger, xsd:negativeInteger, xsd:unsignedLong java.math.BigInteger exact, unbounded; XSD lexical rules (a leading + is accepted)
xsd:decimal java.math.BigDecimal exact; exponents (1e3) are not decimal syntax and are rejected
xsd:float Float accepts INF, -INF, NaN
xsd:double Double accepts INF, -INF, NaN
xsd:date java.time.LocalDate an optional timezone is accepted and dropped on read (replaceInGraph keeps the original literal while the date is unchanged); years above 9999 (12345-06-07) and negative years round-trip
rdf:langString com.geoknoesis.kastor.rdf.LangString value and language tag
xsd:dateTime, xsd:time, xsd:duration String lexical form of a well-formed literal of that datatype ("2020"^^xsd:gYear is ill-typed for an xsd:dateTime member); no single java.time type represents their optional timezones losslessly
any other datatype (e.g. xsd:anyURI) String lexical form of a literal of that datatype

Writers (data-class toTriples, DSL builders) always emit the declared datatype, so a String declared xsd:dateTime is written back as "…"^^xsd:dateTime. A value that is not valid for the declared datatype ("yesterday" for xsd:dateTime, 300 for xsd:unsignedByte) is rejected with an IllegalArgumentException instead of being written as an ill-typed literal.

Non-literal properties:

SHACL Kotlin type
sh:class C where C has a shape the generated interface for C
sh:class C where C has no shape String (the IRI)
sh:node S (S has one sh:targetClass) the generated interface for S’s target class
sh:nodeKind sh:IRI only String (the IRI)
sh:nodeKind sh:BlankNodeOrIRI / sh:BlankNode only com.geoknoesis.kastor.rdf.RdfResource (IRI or blank node; blank nodes are kept)
sh:nodeKind sh:Literal only String (lexical form)
sh:or / sh:xone whose members agree on one sh:class or one sh:datatype that class / datatype
sh:or / sh:xone over several classes String (the IRI), with a warning
sh:in a generated enum (see below)

Cardinality:

  • sh:maxCount 1 with sh:minCount 1 → non-null value
  • sh:maxCount 1 without sh:minCount → nullable value
  • no sh:maxCount, or sh:maxCount > 1 → List<T>

sh:in enums

Interfaces, wrappers and data classes expose an sh:in value set as a generated enum. When distinct values map to the same constant name ("in-progress" and "in_progress", "Draft" and "draft", or equal local names in different namespaces), later members in sh:in order get _2, _3, … suffixes (with a warning), each keeping its own value.

The instance DSL keeps String setters for sh:in properties, checked against the allowed values, because the DSL can be generated without the enums; where it refers to an enum it uses the enum’s package-qualified name, so the DSL compiles when it is generated into a different package.

Naming

  • Built-in names are not shadowed. A class whose name is a type that Kotlin imports by default (ex:String, ex:Pair, ex:List, ex:Result, …) would shadow that type in every generated file of its package. It is generated with the suffix Type (StringType, PairType) and a warning says so; a JSON-LD context term cannot choose a shadowing name either.
  • Types come from the JSON-LD context term mapped to the class IRI, else the IRI’s local name, converted to PascalCase.
  • Properties come from sh:name, else the path’s local name, converted to camelCase (date-issued, Date issued → dateIssued). Kotlin keywords are backtick-escaped (`class`); a leading digit gets _; names that clash with generated members (rdf, validate, copy, …) get a Value suffix. Names are computed once and shared by every generator.
  • Collisions fail the build and list the IRIs involved: two classes mapping to the same type name (compared case-insensitively, since generated file names must be distinct on case-insensitive file systems), several node shapes for one class, or two properties of one shape with the same Kotlin name. Resolve them with distinct context terms or sh:name values; for example the DCAT-US example maps vcard:Address and locn:Address to VcardAddress and LocnAddress in its context.
  • Descriptions are copied into KDoc with /*, */ and % escaped, so shape text cannot break the generated source.

Inheritance

A node shape with sh:node <OtherShape>, or a target class that is rdfs:subClassOf another shaped class, produces an interface that extends the parent’s generated interface. Every generator (interfaces, wrappers, data classes, factories, writers, DSL) uses the same member list, so the output always compiles:

  • Inherited properties are not redeclared unless the child shape restates the path.
  • A restated path keeps the parent’s member name; a different sh:name on the child is ignored with a warning.
  • The restated signature must be a valid Kotlin override: a nullable single value may become non-null (child adds sh:minCount 1), but a List stays a List and the value type stays the parent’s.
  • Stricter child constraints that a signature cannot express (e.g. sh:maxCount 1 on a path the parent exposes as a List, a narrower sh:datatype/sh:class, tighter bounds or patterns) are combined with the parent’s and enforced by generated validation.
  • When two parents expose the same path under different names, the child has both members.
  • Parents that declare one path both as a list and as a single value, or with different value types, fail generation with a message naming the shape and path (align their sh:maxCount, sh:datatype/sh:class).

Parser behaviour

  • A SHACL file that is not valid Turtle fails the build; it is never treated as “no shapes”.
  • When one node shape lists the same path in several property shapes, one member is generated, named after the alphabetically first sh:name, with the constraints of all declarations combined (highest sh:minCount, lowest sh:maxCount, …); a warning names the shape and path.
  • sh:pattern values are XPath regular expressions, run on the Kastor SHACL validator’s linear-time engine with XPath semantics (see “sh:pattern means the same as in the Kastor SHACL validator” above):
    • \d/\D are Unicode decimal digits (\p{Nd}); \w is XPath’s definition (every character except punctuation, separators and “other” characters, so symbols such as $ are word characters) plus the underscore _, and \W is its complement (it does not match _); \s/\S are exactly space, tab, newline and carriage return.
    • \i/\I and \c/\C are the XML name characters.
    • Character-class subtraction [base-[excluded]] follows XPath precedence, including inside negated classes: [^a-z-[0-9]] means “not a-z, minus digits”.
    • Unicode block escapes \p{IsBlock} are blocks.
    • The x flag removes whitespace outside character classes; # is not a comment.
    • The q flag makes the pattern literal; with q only i still applies.

    A pattern that is not a valid regular expression, or that the engine refuses, fails generation, naming the shape, path and pattern.

  • Blank-node node shapes are supported, as are implicit class targets (a shape that is also an rdfs:Class/owl:Class).
  • Constructs that cannot be represented are skipped with a warning naming the shape and property: complex property paths (inverse/sequence/alternative), properties with none of sh:datatype, sh:class, sh:node or sh:nodeKind, shapes without a target class, non-integer cardinalities, etc.

Usage

After generation, use the interfaces like any other domain objects:

// Materialize from RDF
val catalogRef = RdfRef(iri("https://data.example.org/catalog"), repo.defaultGraph)
val catalog: Catalog = catalogRef.asType()

// Pure domain usage
println("Title: ${catalog.title}")
println("Description: ${catalog.description}")
println("Dataset count: ${catalog.dataset.size}")

// Side-channel access
val extras = catalog.asRdf().extras
val altLabels = extras.strings(SKOS.altLabel)
println("Alternative labels: ${altLabels.joinToString()}")

For SHACL validation at runtime use a ValidationContext such as JenaValidation together with materializeValidated (see Validation).

Configuration options

Ontology-driven generation uses the @Rdf annotation fields documented in reference/annotations.md. Common options:

import com.geoknoesis.kastor.gen.annotations.Rdf
import com.geoknoesis.kastor.gen.annotations.ValidationMode

@Rdf(
    shacl = "ontologies/my-ontology.shacl.ttl",              // Required for ontology mode
    context = "ontologies/my-ontology.context.jsonld",       // Recommended (property / type names)
    packageName = "com.example.mydomain.generated",          // Optional; defaults to declaration package
    generateInterfaces = true,                               // Default true
    generateWrappers = true,                                 // Default true
    generateDsl = false,                                     // Optional instance-DSL output
    validationMode = ValidationMode.EMBEDDED,                // How validation is wired into wrappers
)
class OntologyGenerator

Benefits

1. Consistency (100% Guarantee)

  • Interfaces always match your ontology definitions
  • No manual synchronization between ontology and code
  • Changes to ontology automatically propagate to code
  • Impact: Zero sync errors, always up-to-date

2. Type Safety (Compile-Time)

  • Automatic type mapping from SHACL datatypes
  • Compile-time validation of property types
  • Cardinality constraints enforced at type level
  • Impact: 100% type safety, zero runtime type errors

3. Productivity (90% Time Savings)

  • Generate interfaces in 2 minutes vs 1-2 hours manually
  • Update after ontology changes in 17 minutes vs 40-65 minutes
  • No manual property definitions needed
  • Impact: 90% reduction in manual code writing

4. Documentation

  • Generated interfaces include ontology descriptions
  • Property constraints (min/max count) documented
  • Clear mapping from ontology to code
  • Impact: Self-documenting code, easier onboarding

5. Maintainability (Single Source of Truth)

  • Single source of truth (ontology files)
  • No duplicate interface definitions
  • Easy to update when ontology changes
  • Impact: 60-75% faster maintenance cycles

6. Validation

  • SHACL constraints can be used for runtime validation
  • Generated wrappers support validation hooks
  • Consistent validation across all generated types
  • Impact: Consistent validation, fewer data quality issues

See detailed benefits and metrics →

Best Practices

1. File Organization

src/main/resources/
├── ontologies/
│   ├── my-ontology.shacl.ttl
│   └── my-ontology.context.jsonld
└── ...

src/main/kotlin/
└── com/example/mydomain/
    ├── generated/
    │   └── OntologyGenerator.kt
    └── ...

2. Naming Conventions

  • Use descriptive names in SHACL (sh:name)
  • Follow Kotlin naming conventions for generated interfaces
  • Use consistent prefixes in JSON-LD context

3. Version Control

  • Commit ontology files to version control
  • Generated code should be in .gitignore
  • KSP does not see edits to ontology files (the processor warns about each file it reads): declare src/main/resources as an input of the kspKotlin task and set kastor.gen.resources.tracked=true (see Incremental Builds)

4. Testing

  • Test generated interfaces with sample data
  • Validate generated wrappers work correctly
  • Test side-channel access functionality

Limitations

1. SHACL Support

  • Complex property paths (inverse, sequence, alternative) are skipped with a warning
  • sh:or/sh:xone are typed only when their members agree on one class or datatype; sh:and/sh:not do not influence types
  • Full SHACL semantics at runtime require a ValidationContext (JenaValidation/Rdf4jValidation)

2. Type System

  • xsd:dateTime, xsd:time, xsd:duration and custom datatypes are exposed as String
  • xsd:date drops a timezone suffix on read

3. Performance

  • Code generation happens at compile time
  • Large ontologies may slow down builds
  • Generated code is optimized for readability over performance

Future Enhancements

1. Advanced SHACL Support

  • Support for complex constraint combinations
  • Custom validation rule generation

2. Enhanced Type System

  • Custom datatype mapping
  • Generic type support
  • Union type handling

3. Code Generation Options

  • Customizable code templates
  • Multiple output formats
  • Integration with other code generators

4. Tooling Integration

  • IDE support for ontology files
  • Real-time validation
  • Code completion for generated interfaces

Conclusion

Ontology-driven code generation provides a powerful way to maintain consistency between your RDF ontology and Kotlin domain code. By generating interfaces and wrappers from SHACL shapes and JSON-LD context, you eliminate manual synchronization and ensure type safety.

The generated code follows the same patterns as manually written interfaces, providing pure domain objects with optional RDF side-channel access. This approach scales well for large ontologies and complex domain models.