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-type prefixes, which override on name clash).
  • Ontology entry points — on a class or file, set shacl, optional context, 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 interface
  • PersonWrapper — live RDF-backed wrapper (lazy delegates)
  • PersonRecord — immutable data class snapshot
  • PersonRecordFactory — object that loads PersonRecord eagerly and registers in OntoMapper

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 resolves iri from property, then getter, then setter.
  • iri may be an absolute IRI or a QName (prefix:local) when the prefix is bound on @file:Rdf or on the interface @Rdf(prefixes = …).
  • Use val for read-only generated accessors (delegates). Use var only 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 and Iri / RdfResource members stay read-only even with var (the processor warns and generates a read-only accessor). Mutation requires a MutableRdfGraph backing 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 @Rdf with a non-blank iri and no shacl on 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

  1. Type processing
    • Only Kotlin interfaces annotated with @Rdf and a non-blank iri, with shacl left blank, are treated as domain types for OntoMapper wrapper generation.
    • Inheritance is preserved in generated wrappers.
  2. Property processing
    • Mapped properties are those carrying @Rdf with an iri on the property, getter, or setter (checked in that order).
    • Supported shapes: single, nullable or List members of the types below.
  3. Type support
    • Literals: String, Int, Long, Float, Double, Boolean, java.math.BigInteger, java.math.BigDecimal, java.time.LocalDate and com.geoknoesis.kastor.rdf.LangString. They are read through the same policy-aware XsdLiterals decoders as SHACL-generated code (rdfLiteral, rdfLiteralOrNull, rdfLiterals), so ill-typed values follow MaterializationPolicy. String members read literals only (their lexical form); IRIs and blank nodes are not read as strings. Mutable accessors write Long, Float, BigInteger, BigDecimal and LocalDate values with their XSD datatype (xsd:long, xsd:float, xsd:integer, xsd:decimal, xsd:date).
    • Enums: a Kotlin enum class is read by constant name (a literal whose lexical form is not a constant name is ill-typed); an enum generated from sh:in is read through its from factory, from literal codes or from IRIs.
    • RDF terms: Iri reads IRI values and RdfResource reads IRI and blank-node values; a value of another term kind follows MaterializationPolicy.unexpectedTerm (throws by default).
    • Objects: other @Rdf domain 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 with No 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 option kastor.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 @Rdf of the property it overrides). rdf of RdfBacked is exempt: every wrapper implements it, so @Rdf interface Person : RdfBacked is supported and gives person.rdf without a cast;
    • error - two @Rdf types that map to one wrapper class, e.g. a nested Outer.Inner and a top-level Outer_Inner (both Outer_InnerWrapper);
    • error - two @Rdf types whose wrapper classes differ only in case, e.g. Foo and FOO in one package (FooWrapper.kt and FOOWrapper.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>Wrapper already exists but is not an RdfBacked implementation of the interface, so no wrapper is generated. An existing RdfBacked implementation (generated from SHACL, or hand-written on purpose) is used without a warning.
  4. Missing values
    • Missing values are never replaced by invented defaults ("", 0, false).
    • A non-null single-valued property throws MaterializationException (an IllegalStateException) with the message Required 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:integer for an Int, or "42"^^xsd:string, whose datatype is not an XSD integer type) follows MaterializationPolicy.illTypedValues: THROW (default) raises MaterializationException naming the value, its datatype, the member and the expected type; SKIP omits the value and logs a warning. This applies to lists, nullable and non-null members, and mutable getters. Under SKIP, a non-null property whose only values were skipped counts as missing and throws.
    • A nullable property returns null when the value is missing; assigning null to a nullable var removes the triples for that predicate.
    • Lists are empty when there are no values.
    • See the runtime reference.

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)

  • ontologyPath names 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 and validate() 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:minCount and sh:maxCount are checked by validate() over all the values.
  • The setter of a single-valued property (sh:maxCount 0 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:

  1. the resource directories of the source set containing the annotated file (src/<set>/kotlin/…/File.kt → src/<set>/resources, then src/main/resources, then the project directory);
  2. the directories listed in the KSP option kastor.gen.resources (separated by the platform path separator);
  3. 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 @Rdf to 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:

  1. The SHACL file is not valid Turtle — reported as invalid Turtle: ….
  2. 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:name values.
  3. dataClassSuffix is 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

  1. Annotations not processed:
    • Check KSP configuration
    • Ensure annotations are imported correctly
    • Verify build completes successfully
  2. Properties not mapped:
    • Ensure @Rdf(iri = …) is present on the property or its getter/setter.
    • Verify iri is a valid IRI or expandable QName.
    • Ensure the property uses a supported List<…> or scalar type (for var, see mutability rules above).
  3. 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.