Annotations API Reference
Complete reference for Kastor Gen annotations used in domain modeling and ontology-driven generation.
@Rdf
Single source-level annotation (com.geoknoesis.kastor.gen.annotations.Rdf) used for:
- Domain interfaces — mark the RDF class IRI (
iri) and optional prefix map (prefixes) for expanding QNames in this declaration and its properties. - Domain properties — mark the predicate IRI or QName for each mapped property.
- File scope —
@file:Rdf(prefixes = …)supplies default prefix bindings for every@Rdf(iri = …)in that Kotlin file (merged with per-typeprefixes, which override on name clash). - Ontology entry points — on a class or file, set
shacl, optionalcontext, and generation flags for SHACL-driven interfaces and wrappers (see tutorials).
Relevant declaration (simplified; see source in kastor-gen:runtime for the full signature):
@Target(CLASS, PROPERTY, PROPERTY_GETTER, PROPERTY_SETTER, FILE)
@Retention(AnnotationRetention.SOURCE)
annotation class Rdf(
val iri: String = "",
val prefixes: Array<Prefix> = [],
val shacl: String = "",
val context: String = "",
val packageName: String = "",
val generateInterfaces: Boolean = true,
val generateWrappers: Boolean = true,
val generateDsl: Boolean = false,
val dslName: String = "",
val ontologyPath: String = "",
val validationMode: ValidationMode = ValidationMode.EMBEDDED,
val validationAnnotations: ValidationAnnotations = ValidationAnnotations.JAKARTA,
val externalValidatorClass: String = "",
// ── Data-class generation ────────────────────────────────────────────────
val generateDataClass: Boolean = false,
val dataClassSuffix: String = "Record",
val dataClassImplementsInterface: Boolean = false,
val nestedMode: NestedMode = NestedMode.INTERFACE,
val generateWriteSupport: Boolean = false,
)
Ontology generation fields
| Field | Default | Description |
|---|---|---|
shacl |
"" |
Path of the SHACL shapes (Turtle), resolved against the annotated file’s src/<set>/resources, src/main/resources, the project directory, the KSP option kastor.gen.resources and the processor class path. Setting it turns the annotation into a generation request. |
context |
"" |
Optional JSON-LD @context that names types and properties. |
packageName |
annotated package | Package of the generated code. |
generateInterfaces / generateWrappers |
true / true |
Emit the domain interfaces and their RDF-backed wrappers. |
generateDsl, dslName, ontologyPath |
false, "", "" |
Emit an instance-building DSL (see ontology generation). |
validationMode |
EMBEDDED |
How wrappers validate: EMBEDDED (generated SHACL checks), EXTERNAL (through externalValidatorClass) or NONE. |
validationAnnotations |
JAKARTA |
Bean Validation annotations on generated interfaces (@get:) and data classes (@field:): @NotNull for a required single value, @NotEmpty for a required list, @Size(min, max) for a bounded list. JAKARTA uses jakarta.validation-api, which kastor-gen-runtime provides at compile time (Gradle compileOnlyApi, so it is not on your runtime class path); JAVAX needs javax.validation:validation-api in your dependencies; NONE emits none. If the API of the selected flavour is missing, the processor reports an error at the annotation naming the dependency to add. |
externalValidatorClass |
"" |
Validator class for ValidationMode.EXTERNAL. |
A problem with a request’s input (a missing, unreadable or invalid shapes, context or ontology file, shapes that
cannot be generated, a missing Bean Validation API) is reported once, as a KSP error at the annotated
declaration or file, and the other @Rdf requests of the compilation are still generated.
Data-class generation fields
| Field | Default | Description |
|---|---|---|
generateDataClass |
false |
Emit an immutable Kotlin data class (and its factory) alongside or instead of the interface+wrapper pair. |
dataClassSuffix |
"Record" |
Suffix appended to the shape name for the generated data class (Person → PersonRecord). An empty suffix is rejected when interfaces or wrappers are also generated (or dataClassImplementsInterface = true), because the data class would collide with the interface. |
dataClassImplementsInterface |
false |
When true, the generated data class also implements the generated interface, providing structural alignment. |
nestedMode |
INTERFACE |
How sh:class object properties are typed inside the data class — see NestedMode below. |
generateWriteSupport |
false |
Adds toTriples(record, subject): List<RdfTriple> (the record as triples) and replaceInGraph(graph, record, subject) (the recommended write path: one atomic, lossless edit of the predicates the record models) to the factory object. Requires generateDataClass = true. |
Example — generate both modes:
@Rdf(
shacl = "ontologies/person.shacl.ttl",
context = "ontologies/person.context.jsonld",
packageName = "com.example.generated",
generateInterfaces = true,
generateWrappers = true,
generateDataClass = true,
dataClassSuffix = "Record",
dataClassImplementsInterface = true,
nestedMode = NestedMode.DATA_CLASS,
)
class OntologyGenerator
The processor emits (for each SHACL shape):
Person— generated domain interfacePersonWrapper— live RDF-backed wrapper (lazy delegates)PersonRecord— immutable data class snapshotPersonRecordFactory— object that loadsPersonRecordeagerly and registers inOntoMapper
Example — data class with write support:
@Rdf(
shacl = "ontologies/person.shacl.ttl",
packageName = "com.example.generated",
generateInterfaces = false,
generateWrappers = false,
generateDataClass = true,
dataClassSuffix = "Record",
nestedMode = NestedMode.IRI_ONLY,
generateWriteSupport = true,
)
class OntologyGenerator
With generateWriteSupport = true the factory gains toTriples and replaceInGraph functions alongside from:
// Generated (simplified):
object PersonRecordFactory {
init { OntoMapper.register(PersonRecord::class.java) { handle -> from(handle) } }
fun from(handle: RdfHandle): PersonRecord { ... }
fun toTriples(record: PersonRecord, subject: Iri): List<RdfTriple> {
val triples = mutableListOf<RdfTriple>()
triples += RdfTriple(subject, RDF.type, Iri("http://example.org/Person"))
triples += RdfTriple(subject, Iri("http://example.org/name"), Literal(record.name))
// … one statement per property …
return triples
}
// Replaces only the predicates toTriples writes (and adds rdf:type), in one atomic edit.
fun replaceInGraph(graph: MutableRdfGraph, record: PersonRecord, subject: Iri) { ... }
}
Write a modified record back with replaceInGraph:
val updated = record.copy(name = "New Name")
PersonRecordFactory.replaceInGraph(graph, updated, subject) // atomic, scoped to the record's predicates
replaceInGraph edits only the values of the predicates the record models and writes, and adds the
rdf:type. Within those predicates it changes only what the record changed: a value the record still holds
keeps its original term (language tag, datatype, lexical form), an unchanged single-valued member keeps the
values beyond sh:maxCount its reader did not return, and a term the record cannot represent is kept (see
mergeRecord in the runtime reference). A single-valued member is
unchanged only when it equals the value the reader returned; setting it to another value, even one the graph
already holds beyond sh:maxCount, writes it and removes the other values, so the next read returns it.
Which of several values a single-valued reader returns follows the provider’s triple order, which differs
between the memory, Jena and RDF4J stores; only a graph that violates sh:maxCount can tell them apart.
Everything else about the
subject is kept: other types, predicates the shape does not model,
triples of complex sh:paths (which are not generated), and object links that toTriples cannot write
(NestedMode.DATA_CLASS snapshots carry no subject for nested objects). Do not use
graph.replaceResource(subject, toTriples(...)) for this: replaceResource removes every triple of the
subject, so it would delete all of those.
Replacement helpers require AtomicMutableRdfGraph: standalone MemoryGraph and graphs of
Memory, Jena, and RDF4J repositories support it. Graphs without atomic support (including standalone
Jena model adapters) reject the edit before changing data. Replacement triples must match the supplied
subject, and replaceValues also checks the predicate (replacePredicates checks the subject; mergeRecord, used by replaceInGraph, builds its triples for the subject itself). An edit joins an existing repository transaction;
there is no nested savepoint, so let failures escape that transaction to roll it back.
Example — data class only (no live wrappers):
@Rdf(
shacl = "ontologies/person.shacl.ttl",
packageName = "com.example.generated",
generateInterfaces = false,
generateWrappers = false,
generateDataClass = true,
dataClassSuffix = "Record",
)
class OntologyGenerator
NestedMode
Controls how sh:class object properties are typed inside a generated data class. Has no effect on interface or wrapper generation.
enum class NestedMode {
INTERFACE, // property type is the generated interface (default)
DATA_CLASS, // property type is the generated data class (fully recursive snapshot)
IRI_ONLY, // property type is String (IRI value; no sub-object materialisation)
}
| Value | Resulting property type | When to use |
|---|---|---|
INTERFACE |
Dataset (the generated interface) |
Default; the data class holds references typed by the same interface the wrapper uses. |
DATA_CLASS |
DatasetRecord (the generated data class) |
When you want a fully recursive, self-contained snapshot with no live wrappers anywhere in the object graph. |
IRI_ONLY |
String (IRI value) |
When you only need the IRI of the related resource, not a materialised sub-object. Avoids recursive loading. |
NestedMode.IRI_ONLY cannot be combined with dataClassImplementsInterface = true when a shape has object
members (properties the generated interface types with another generated interface): the data class would
have to override those members with String. Generation fails with an InvalidConfigurationException naming
the members. Use INTERFACE or DATA_CLASS with dataClassImplementsInterface = true, or set
dataClassImplementsInterface = false to keep IRI_ONLY.
Prefix
annotation class Prefix(val name: String, val namespace: String)
Used inside @Rdf(prefixes = [Prefix("dcat", "http://www.w3.org/ns/dcat#"), …]) or @file:Rdf(prefixes = […]).
Domain modeling conventions
- Put
@Rdf(iri = …)on the property line (preferred). You may still use@get:Rdf(iri = …)or@set:Rdf(iri = …)if you need use-site targets; the processor resolvesirifrom property, then getter, then setter. irimay be an absolute IRI or a QName (prefix:local) when the prefix is bound on@file:Rdfor on the interface@Rdf(prefixes = …).- Use
valfor read-only generated accessors (delegates). Usevaronly when you need a mutable wrapper: supported for single-valued literal members of every supported literal type except enums, and a single object reference;List<…>, enum andIri/RdfResourcemembers stay read-only even withvar(the processor warns and generates a read-only accessor). Mutation requires aMutableRdfGraphbacking the same handle the wrapper reads from.
Class example:
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
// ...
}
Property example (absolute IRI or QName):
@file:Rdf(
prefixes = [
Prefix("foaf", "http://xmlns.com/foaf/0.1/"),
],
)
package com.example
import com.geoknoesis.kastor.gen.annotations.Prefix
import com.geoknoesis.kastor.gen.annotations.Rdf
@Rdf(iri = "foaf:Person")
interface Person {
@Rdf(iri = "foaf:name")
val name: List<String>
}
Notes:
- Apply
@Rdfwith a non-blankiriand noshaclon the interface to opt into OntoMapper wrapper generation for that domain type. - Prefer interfaces, not concrete classes, for generated wrappers.
Annotation Processing
KSP Processor
The Kastor Gen KSP processor scans for these annotations and generates wrapper classes:
// Input: Domain interface
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
@Rdf(iri = "http://xmlns.com/foaf/0.1/name")
val name: List<String>
}
// Output: Generated wrapper class (simplified)
internal class PersonWrapper(override val rdf: RdfHandle) : Person, RdfBacked {
override val name: List<String> by rdfStrings(Iri("http://xmlns.com/foaf/0.1/name"))
companion object {
init {
OntoMapper.register(Person::class.java) { handle -> PersonWrapper(handle) }
}
}
}
Processing rules
- Type processing
- Only Kotlin interfaces annotated with
@Rdfand a non-blankiri, withshaclleft blank, are treated as domain types for OntoMapper wrapper generation. - Inheritance is preserved in generated wrappers.
- Only Kotlin interfaces annotated with
- Property processing
- Mapped properties are those carrying
@Rdfwith anirion the property, getter, or setter (checked in that order). - Supported shapes: single, nullable or
Listmembers of the types below.
- Mapped properties are those carrying
- Type support
- Literals:
String,Int,Long,Float,Double,Boolean,java.math.BigInteger,java.math.BigDecimal,java.time.LocalDateandcom.geoknoesis.kastor.rdf.LangString. They are read through the same policy-awareXsdLiteralsdecoders as SHACL-generated code (rdfLiteral,rdfLiteralOrNull,rdfLiterals), so ill-typed values followMaterializationPolicy.Stringmembers read literals only (their lexical form); IRIs and blank nodes are not read as strings. Mutable accessors writeLong,Float,BigInteger,BigDecimalandLocalDatevalues with their XSD datatype (xsd:long,xsd:float,xsd:integer,xsd:decimal,xsd:date). - Enums: a Kotlin
enum classis read by constant name (a literal whose lexical form is not a constant name is ill-typed); an enum generated fromsh:inis read through itsfromfactory, from literal codes or from IRIs. - RDF terms:
Irireads IRI values andRdfResourcereads IRI and blank-node values; a value of another term kind followsMaterializationPolicy.unexpectedTerm(throws by default). - Objects: other
@Rdfdomain interfaces. - Single values may be declared nullable (
String?,Int?,Organization?). - Type aliases are resolved to the type they stand for (
typealias Name = String,typealias Names = List<String>).
Diagnostics. What the generator cannot handle is reported by KSP, naming the type or member, and no wrapper is generated for the affected interface:
- error - a member type without a reader: anything not listed above, e.g.
Short,java.time.Instant, or a class that is not@Rdf-annotated (and has no<Type>Wrapper/<Type>Factory), which would otherwise fail at the first read withNo wrapper factory registered. Opt-in for hand-registered types: when the application registers the factory of a member type itself (OntoMapper.register(Address::class.java) { handle -> AddressView(handle) }, under any class name), tell the processor so, either by annotating the type with@RdfMaterializable(com.geoknoesis.kastor.gen.annotations), or, for a type that cannot be annotated (a library type), by listing its qualified name in the KSP optionkastor.gen.materializableTypes(ksp { arg("kastor.gen.materializableTypes", "com.acme.Address,com.lib.Legacy") }). The processor then accepts the member; registering the factory before the first read stays the application’s job; - error - an abstract property or function without
@Rdf, which the wrapper could not implement (members with a default implementation are fine, also when they are inherited from a library interface; an override inherits the@Rdfof the property it overrides).rdfofRdfBackedis exempt: every wrapper implements it, so@Rdf interface Person : RdfBackedis supported and givesperson.rdfwithout a cast; - error - two
@Rdftypes that map to one wrapper class, e.g. a nestedOuter.Innerand a top-levelOuter_Inner(bothOuter_InnerWrapper); - error - two
@Rdftypes whose wrapper classes differ only in case, e.g.FooandFOOin one package (FooWrapper.ktandFOOWrapper.kt). The classes are distinct, but their source and class files are one file on a case-insensitive file system (Windows, macOS), so the build would only work on a case-sensitive one; the message says so. Types whose wrapper names differ by more than case never collide; - warning - a class named
<Type>Wrapperalready exists but is not anRdfBackedimplementation of the interface, so no wrapper is generated. An existingRdfBackedimplementation (generated from SHACL, or hand-written on purpose) is used without a warning.
- Literals:
- Missing values
- Missing values are never replaced by invented defaults (
"",0,false). - A non-null single-valued property throws
MaterializationException(anIllegalStateException) with the messageRequired value missing for <member> <path>when its value is missing. There is no lenient option. - A value that cannot be decoded (e.g.
"abc"^^xsd:integerfor anInt, or"42"^^xsd:string, whose datatype is not an XSD integer type) followsMaterializationPolicy.illTypedValues:THROW(default) raisesMaterializationExceptionnaming the value, its datatype, the member and the expected type;SKIPomits the value and logs a warning. This applies to lists, nullable and non-null members, and mutable getters. UnderSKIP, a non-null property whose only values were skipped counts as missing and throws. - A nullable property returns
nullwhen the value is missing; assigningnullto a nullablevarremoves the triples for that predicate. - Lists are empty when there are no values.
- See the runtime reference.
- Missing values are never replaced by invented defaults (
Common Patterns
Materialize a domain view with graph.materialize<YourType>(node) (recommended), node.materializeIn(graph), or repo.materialize<YourType>(node) when using a repository. These use OntoMapper and the same path as RdfRef(node, graph).asType(). Here node is the RDF subject (Iri or BlankNode) and graph is the RdfGraph that contains its triples.
Single Value Properties
Use List<T> and access with firstOrNull():
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
@Rdf(iri = "http://xmlns.com/foaf/0.1/name")
val name: List<String> // Single name
@Rdf(iri = "http://xmlns.com/foaf/0.1/age")
val age: List<Int> // Single age
}
// Usage
val person: Person = graph.materialize(node)
val name = person.name.firstOrNull() ?: "Unknown"
val age = person.age.firstOrNull() ?: 0
Multiple Value Properties
Use List<T> for multiple values:
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
@Rdf(iri = "http://xmlns.com/foaf/0.1/name")
val names: List<String> // Multiple names
@Rdf(iri = "http://xmlns.com/foaf/0.1/mbox")
val emails: List<String> // Multiple email addresses
}
// Usage
val person: Person = graph.materialize(node)
val allNames = person.names
val primaryEmail = person.emails.firstOrNull()
Object Properties
Map to domain interfaces:
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
@Rdf(iri = "http://xmlns.com/foaf/0.1/knows")
val friends: List<Person> // Related Person objects
@Rdf(iri = "http://example.org/employer")
val employer: List<Organization> // Related Organization objects
}
// Usage
val person: Person = graph.materialize(node)
val friends = person.friends
val employer = person.employer.firstOrNull()
Inheritance
Inheritance is supported:
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
@Rdf(iri = "http://xmlns.com/foaf/0.1/name")
val name: List<String>
}
@Rdf(iri = "http://example.org/Employee")
interface Employee : Person {
@Rdf(iri = "http://example.org/employeeId")
val employeeId: List<String>
@Rdf(iri = "http://example.org/salary")
val salary: List<Double>
}
Configuration
Generation is configured through the @Rdf fields above; the processor needs no KSP arguments.
Notes on the instance DSL (generateDsl = true)
ontologyPathnames an OWL/RDFS file that is read together with the shapes: a file that is missing or cannot be parsed is a KSP error. Its named classes are logged; anonymous class expressions ([ a owl:Class ; owl:unionOf (...) ],rdfs:subClassOf [ a owl:Restriction ; ... ]) are not generation targets - they are skipped, as classes and as superclasses, and what was skipped is reported at info level.- An instance block (
person("urn:p") { ... }) builds the instance in a scratch graph and writes it to the DSL’s graph only after the block ran andvalidate()passed: a rejected value or a failed validation leaves no triple of that instance behind. A second block for the same resource is validated together with what the first one wrote, and replaces it. - Setters check the constraints that concern a single value (
sh:pattern, lengths, bounds,sh:in);sh:hasValue,sh:minCountandsh:maxCountare checked byvalidate()over all the values. - The setter of a single-valued property (
sh:maxCount0 or 1) replaces its value, also one written by an earlier block for the same resource; the setters of a multi-valued property add values.
Which annotation is @Rdf
The processors identify @Rdf by the qualified name of the annotation class
(com.geoknoesis.kastor.gen.annotations.Rdf). An annotation of another library that is also named Rdf is
not taken for it: a type that only carries such an annotation is not an @Rdf type, and a member that only
carries it is not mapped.
Resolving shacl, context and ontologyPath
A relative path such as @Rdf(shacl = "person-shape.ttl") is resolved, in order, against:
- the resource directories of the source set containing the annotated file
(
src/<set>/kotlin/…/File.kt→src/<set>/resources, thensrc/main/resources, then the project directory); - the directories listed in the KSP option
kastor.gen.resources(separated by the platform path separator); - the processor class path (legacy behaviour).
Absolute paths are used as-is.
ksp {
// Optional: extra directories to search for ontology files
arg("kastor.gen.resources", "${projectDir}/ontologies")
}
Relative kastor.gen.resources entries are resolved against the KSP option kastor.gen.projectDir
(arg("kastor.gen.projectDir", projectDir.absolutePath)); without it, a relative entry that does not exist
relative to the compiler’s working directory fails generation. See the
processor options.
Incremental builds
KSP regenerates when the annotated Kotlin source changes, but it does not see edits to the SHACL or
JSON-LD files themselves, and the processor warns about every ontology file it reads. Declare
src/main/resources as an input of the kspKotlin task and set kastor.gen.resources.tracked=true
(as the repository examples do; see Incremental Builds),
or use the Gradle plugin, which tracks those files as task inputs.
Best Practices
✅ Do
- Use descriptive IRIs or QNames with an explicit
@file:Rdf(prefixes = …)or@Rdf(prefixes = …)map. - Apply
@Rdfto interfaces, not concrete classes, for generated wrappers. - Prefer
@Rdf(iri = …)on the property itself. - Use
List<T>for properties that may have multiple RDF objects. - Group related properties logically and use inheritance where it matches the ontology.
❌ Don’t
- Use invalid IRIs or QNames without a prefix binding.
- Annotate concrete classes when you expect the Kastor OntoMapper wrapper to be generated.
- Rely on
var+List<…>for mutation (wrappers keep lists read-only; use a mutable graph and replace triples via RDF APIs if you need bulk updates). - Mix unrelated RDF predicates into a single domain type without a clear model.
Error Handling
Common Errors
Ontology-driven generation fails the build (rather than silently producing partial or empty output) when:
- The SHACL file is not valid Turtle — reported as
invalid Turtle: …. - Generated names collide — two classes map to the same type name (compared case-insensitively, since
file names must differ on case-insensitive file systems), a class has more than one node shape, or two
properties of one shape map to the same Kotlin name. The message lists the IRIs involved; fix it with
distinct JSON-LD context terms or
sh:namevalues. dataClassSuffixis empty while interfaces/wrappers are generated.
Constructs the generator cannot represent are skipped with a warning naming the shape and property
(complex property paths, properties without sh:datatype/sh:class/sh:node/sh:nodeKind, shapes
without a target class, …); the rest of the ontology is still generated.
Troubleshooting
- Annotations not processed:
- Check KSP configuration
- Ensure annotations are imported correctly
- Verify build completes successfully
- Properties not mapped:
- Ensure
@Rdf(iri = …)is present on the property or its getter/setter. - Verify
iriis a valid IRI or expandable QName. - Ensure the property uses a supported
List<…>or scalar type (forvar, see mutability rules above).
- Ensure
- Inheritance issues:
- Check parent class annotations
- Verify type compatibility
- Ensure proper interface inheritance
Examples
Complete Example
// Domain interfaces
@Rdf(iri = "http://xmlns.com/foaf/0.1/Person")
interface Person {
@Rdf(iri = "http://xmlns.com/foaf/0.1/name")
val name: List<String>
@Rdf(iri = "http://xmlns.com/foaf/0.1/age")
val age: List<Int>
@Rdf(iri = "http://xmlns.com/foaf/0.1/mbox")
val email: List<String>
@Rdf(iri = "http://xmlns.com/foaf/0.1/knows")
val friends: List<Person>
}
@Rdf(iri = "http://example.org/Organization")
interface Organization {
@Rdf(iri = "http://example.org/name")
val name: List<String>
@Rdf(iri = "http://example.org/employee")
val employees: List<Person>
}
// Usage
val person: Person = graph.materialize(node)
val name = person.name.firstOrNull() ?: "Unknown"
val friends = person.friends
val employer = person.asRdf().extras.objects(EMPLOYER, Organization::class.java).firstOrNull()
Complex Example
@Rdf(iri = "http://example.org/Project")
interface Project {
@Rdf(iri = "http://example.org/name")
val name: List<String>
@Rdf(iri = "http://example.org/description")
val description: List<String>
@Rdf(iri = "http://example.org/startDate")
val startDate: List<String>
@Rdf(iri = "http://example.org/endDate")
val endDate: List<String>
@Rdf(iri = "http://example.org/manager")
val manager: List<Person>
@Rdf(iri = "http://example.org/teamMember")
val teamMembers: List<Person>
@Rdf(iri = "http://example.org/task")
val tasks: List<Task>
@Rdf(iri = "http://example.org/budget")
val budget: List<Double>
@Rdf(iri = "http://example.org/isActive")
val isActive: List<Boolean>
}
@Rdf(iri = "http://example.org/Task")
interface Task {
@Rdf(iri = "http://example.org/name")
val name: List<String>
@Rdf(iri = "http://example.org/status")
val status: List<String>
@Rdf(iri = "http://example.org/assignee")
val assignee: List<Person>
@Rdf(iri = "http://example.org/dueDate")
val dueDate: List<String>
}
This comprehensive annotation system provides a clean, type-safe way to map RDF data to Kotlin domain objects while maintaining the purity of domain interfaces.